diff --git a/build.gradle.kts b/build.gradle.kts index cfa2953..5432c74 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -40,9 +40,10 @@ dependencies { implementation("org.springframework.boot:spring-boot-devtools:2.3.4.RELEASE") testImplementation("org.springframework:spring-test:5.1.6.RELEASE") - testImplementation("org.mockito:mockito-core:3.6.0") + testImplementation("org.mockito:mockito-inline:3.6.0") testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0") testImplementation("org.junit.jupiter:junit-jupiter-api:5.3.2") + testImplementation("io.mockk:mockk:1.10.6") testImplementation("org.springframework.boot:spring-boot-starter-test:2.3.4.RELEASE") testImplementation("org.springframework.boot:spring-boot-test-autoconfigure:2.3.4.RELEASE") testImplementation("org.jetbrains.kotlin:kotlin-test:1.4.10") diff --git a/src/main/java/dev/fyloz/colorrecipesexplorer/service/files/TouchUpKitService.java b/src/main/java/dev/fyloz/colorrecipesexplorer/service/files/TouchUpKitService.java deleted file mode 100644 index 93df0aa..0000000 --- a/src/main/java/dev/fyloz/colorrecipesexplorer/service/files/TouchUpKitService.java +++ /dev/null @@ -1,42 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service.files; - -import dev.fyloz.colorrecipesexplorer.utils.PdfBuilder; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.io.ResourceLoader; -import org.springframework.stereotype.Service; - -import java.io.IOException; - -@Service -public class TouchUpKitService { - - private static final String TOUCH_UP_FR = "KIT DE RETOUCHE"; - private static final String TOUCH_UP_EN = "TOUCH UP KIT"; - public static final int FONT_SIZE = 42; - - private final ResourceLoader resourceLoader; - - @Autowired - public TouchUpKitService(ResourceLoader resourceLoader) { - this.resourceLoader = resourceLoader; - } - - /** - * Génère un PDF de kit de retouche pour une job. - * - * @param jobNumber La job - * @return Le PDF de kit de retouche pour la job - */ - public byte[] generatePdfForJobNumber(String jobNumber) { - try { - return new PdfBuilder(resourceLoader, true, FONT_SIZE) - .addLine(TOUCH_UP_FR, true, 0) - .addLine(TOUCH_UP_EN, true, 0) - .addLine(jobNumber, false, 10) - .build(); - } catch (IOException ex) { - throw new RuntimeException(String.format("Impossible de générer un PDF de kit de retouche pour la job '%s': %s", jobNumber, ex.getMessage())); - } - } - -} diff --git a/src/main/java/dev/fyloz/colorrecipesexplorer/utils/PdfBuilder.java b/src/main/java/dev/fyloz/colorrecipesexplorer/utils/PdfBuilder.java deleted file mode 100644 index 39ea4ec..0000000 --- a/src/main/java/dev/fyloz/colorrecipesexplorer/utils/PdfBuilder.java +++ /dev/null @@ -1,130 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.utils; - -import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDPage; -import org.apache.pdfbox.pdmodel.PDPageContentStream; -import org.apache.pdfbox.pdmodel.font.PDFont; -import org.apache.pdfbox.pdmodel.font.PDType0Font; -import org.springframework.core.io.ResourceLoader; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collection; - -public class PdfBuilder { - - private static final String PATH_FONT_ARIAL_BOLD = "classpath:fonts/arialbd.ttf"; - - private final PDFont font; - private final PDDocument document = new PDDocument(); - private final PDPage page = new PDPage(); - private final Collection lines = new ArrayList<>(); - private final boolean duplicated; - private final int fontSize; - private final int fontSizeBold; - private final int lineSpacing; - - public PdfBuilder(ResourceLoader resourceLoader, boolean duplicated, int fontSize) throws IOException { - this.duplicated = duplicated; - this.fontSize = fontSize; - this.fontSizeBold = this.fontSize + 12; - this.lineSpacing = (int) (this.fontSize * 1.5f); - - document.addPage(page); - font = PDType0Font.load(document, resourceLoader.getResource(PATH_FONT_ARIAL_BOLD).getInputStream()); - } - - public PdfBuilder addLine(String text, boolean bold, int marginTop) { - lines.add(new PdfLine(text, bold, marginTop)); - - return this; - } - - public byte[] build() throws IOException { - writeContent(); - - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - document.save(outputStream); - document.close(); - - return outputStream.toByteArray(); - } - - private void writeContent() throws IOException { - PDPageContentStream contentStream = new PDPageContentStream(document, page); - contentStream.beginText(); - - int marginTop = 30; - for (PdfLine line : lines) { - writeCenteredText(contentStream, line, marginTop); - marginTop += lineSpacing; - } - - if (duplicated) { - marginTop = (int) page.getMediaBox().getHeight() / 2; - for (PdfLine line : lines) { - writeCenteredText(contentStream, line, marginTop); - marginTop += lineSpacing; - } - } - - contentStream.endText(); - contentStream.close(); - } - - private void writeCenteredText(PDPageContentStream contentStream, PdfLine line, int marginTop) throws IOException { - float textWidth = font.getStringWidth(line.getText()) / 1000 * (line.isBold() ? fontSizeBold : fontSize); - float textHeight = font.getFontDescriptor().getFontBoundingBox().getHeight() / 1000 * (line.isBold() ? fontSizeBold : fontSize); - float textX = (page.getMediaBox().getWidth() - textWidth) / 2f; - float textY = (page.getMediaBox().getHeight() - (marginTop + line.getMarginTop()) - textHeight); - - if (line.isBold()) contentStream.setFont(font, fontSizeBold); - else contentStream.setFont(font, fontSize); - - contentStream.newLineAtOffset(textX, textY); - contentStream.showText(line.getText()); - contentStream.newLineAtOffset(-textX, -textY); // Réinitialise la position pour la prochaine ligne - } - - public static class PdfLine { - - private String text; - private boolean bold; - private int marginTop; - - public PdfLine() { - } - - public PdfLine(String text, boolean bold, int marginTop) { - this.text = text; - this.bold = bold; - this.marginTop = marginTop; - } - - public String getText() { - return text; - } - - public void setText(String text) { - this.text = text; - } - - public boolean isBold() { - return bold; - } - - public void setBold(boolean bold) { - this.bold = bold; - } - - public int getMarginTop() { - return marginTop; - } - - public void setMarginTop(int marginTop) { - this.marginTop = marginTop; - } - } - -} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/InitialDataLoader.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationReadyListener.kt similarity index 58% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/InitialDataLoader.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationReadyListener.kt index 0e1ca4b..f135661 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/InitialDataLoader.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationReadyListener.kt @@ -1,6 +1,8 @@ package dev.fyloz.colorrecipesexplorer.config +import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties import dev.fyloz.colorrecipesexplorer.config.properties.MaterialTypeProperties +import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES import dev.fyloz.colorrecipesexplorer.service.MaterialTypeService import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.context.ApplicationListener @@ -10,10 +12,13 @@ import org.springframework.core.annotation.Order @Configuration @Order(Ordered.HIGHEST_PRECEDENCE) -class InitialDataLoader( - private val materialTypeService: MaterialTypeService, - private val materialTypeProperties: MaterialTypeProperties +class ApplicationReadyListener( + private val materialTypeService: MaterialTypeService, + private val materialTypeProperties: MaterialTypeProperties, + private val creProperties: CreProperties ) : ApplicationListener { - override fun onApplicationEvent(event: ApplicationReadyEvent) = - materialTypeService.saveSystemTypes(materialTypeProperties.systemTypes) + override fun onApplicationEvent(event: ApplicationReadyEvent) { + materialTypeService.saveSystemTypes(materialTypeProperties.systemTypes) + CRE_PROPERTIES = creProperties + } } 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 4a14568..9ebf91f 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/CreProperties.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/CreProperties.kt @@ -5,4 +5,6 @@ import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties(prefix = "cre.server") class CreProperties { var workingDirectory: String = "data" + var deploymentUrl: String = "http://localhost" + var cacheGeneratedFiles: Boolean = false } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Employee.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Employee.kt index fa564eb..1b98d41 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Employee.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Employee.kt @@ -1,7 +1,5 @@ package dev.fyloz.colorrecipesexplorer.model -import com.fasterxml.jackson.annotation.JsonIgnore -import com.fasterxml.jackson.annotation.JsonProperty import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import dev.fyloz.colorrecipesexplorer.model.validation.NullOrNotBlank @@ -25,139 +23,143 @@ private const val EMPLOYEE_PASSWORD_TOO_SHORT_MESSAGE = "Le mot de passe doit co @Entity @Table(name = "employee") data class Employee( - @Id - override val id: Long, + @Id + override val id: Long, - @Column(name = "first_name") - val firstName: String = "", + @Column(name = "first_name") + val firstName: String = "", - @Column(name = "last_name") - val lastName: String = "", + @Column(name = "last_name") + val lastName: String = "", - @JsonIgnore - val password: String = "", + val password: String = "", - @JsonIgnore - @Column(name = "default_group_user") - val isDefaultGroupUser: Boolean = false, + @Column(name = "default_group_user") + val isDefaultGroupUser: Boolean = false, - @JsonIgnore - @Column(name = "system_user") - val isSystemUser: Boolean = false, + @Column(name = "system_user") + val isSystemUser: Boolean = false, - @ManyToOne - @JoinColumn(name = "group_id") - @Fetch(FetchMode.SELECT) - var group: EmployeeGroup? = null, + @ManyToOne + @JoinColumn(name = "group_id") + @Fetch(FetchMode.SELECT) + var group: EmployeeGroup? = null, - @Enumerated(EnumType.STRING) - @ElementCollection(fetch = FetchType.EAGER) - @CollectionTable(name = "employee_permission", joinColumns = [JoinColumn(name = "employee_id")]) - @Column(name = "permission") - @Fetch(FetchMode.SUBSELECT) - @get:JsonProperty("explicitPermissions") - val permissions: MutableSet = mutableSetOf(), + @Enumerated(EnumType.STRING) + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "employee_permission", joinColumns = [JoinColumn(name = "employee_id")]) + @Column(name = "permission") + @Fetch(FetchMode.SUBSELECT) + val permissions: MutableSet = mutableSetOf(), - @Column(name = "last_login_time") - var lastLoginTime: LocalDateTime? = null + @Column(name = "last_login_time") + var lastLoginTime: LocalDateTime? = null ) : Model { - @get:JsonProperty("permissions") val flatPermissions: Set get() = permissions - .flatMap { it.flat() } - .filter { !it.deprecated } - .toMutableSet() - .apply { - if (group != null) this.addAll(group!!.flatPermissions) - } + .flatMap { it.flat() } + .filter { !it.deprecated } + .toMutableSet() + .apply { + if (group != null) this.addAll(group!!.flatPermissions) + } - @get:JsonIgnore val authorities: Set get() = flatPermissions.map { it.toAuthority() }.toMutableSet() } /** DTO for creating employees. Allows a [password] a [groupId]. */ open class EmployeeSaveDto( - @field:NotNull(message = EMPLOYEE_ID_NULL_MESSAGE) - val id: Long, + @field:NotNull(message = EMPLOYEE_ID_NULL_MESSAGE) + val id: Long, - @field:NotBlank(message = EMPLOYEE_FIRST_NAME_EMPTY_MESSAGE) - val firstName: String, + @field:NotBlank(message = EMPLOYEE_FIRST_NAME_EMPTY_MESSAGE) + val firstName: String, - @field:NotBlank(message = EMPLOYEE_LAST_NAME_EMPTY_MESSAGE) - val lastName: String, + @field:NotBlank(message = EMPLOYEE_LAST_NAME_EMPTY_MESSAGE) + val lastName: String, - @field:NotBlank(message = EMPLOYEE_PASSWORD_EMPTY_MESSAGE) - @field:Size(min = 8, message = EMPLOYEE_PASSWORD_TOO_SHORT_MESSAGE) - val password: String, + @field:NotBlank(message = EMPLOYEE_PASSWORD_EMPTY_MESSAGE) + @field:Size(min = 8, message = EMPLOYEE_PASSWORD_TOO_SHORT_MESSAGE) + val password: String, - val groupId: Long?, + val groupId: Long?, - @Enumerated(EnumType.STRING) - val permissions: MutableSet = mutableSetOf() + @Enumerated(EnumType.STRING) + val permissions: MutableSet = mutableSetOf() ) : EntityDto open class EmployeeUpdateDto( - @field:NotNull(message = EMPLOYEE_ID_NULL_MESSAGE) - val id: Long, + @field:NotNull(message = EMPLOYEE_ID_NULL_MESSAGE) + val id: Long, - @field:NullOrNotBlank(message = EMPLOYEE_FIRST_NAME_EMPTY_MESSAGE) - val firstName: String?, + @field:NullOrNotBlank(message = EMPLOYEE_FIRST_NAME_EMPTY_MESSAGE) + val firstName: String?, - @field:NullOrNotBlank(message = EMPLOYEE_LAST_NAME_EMPTY_MESSAGE) - val lastName: String?, + @field:NullOrNotBlank(message = EMPLOYEE_LAST_NAME_EMPTY_MESSAGE) + val lastName: String?, - val groupId: Long?, + val groupId: Long?, - @Enumerated(EnumType.STRING) - val permissions: Set? + @Enumerated(EnumType.STRING) + val permissions: Set? ) : EntityDto +data class EmployeeOutputDto( + override val id: Long, + val firstName: String, + val lastName: String, + val group: EmployeeGroup?, + val permissions: Set, + val explicitPermissions: Set, + val lastLoginTime: LocalDateTime? +) : Model + data class EmployeeLoginRequest(val id: Long, val password: String) // ==== DSL ==== fun employee( - passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(), - id: Long = 0L, - firstName: String = "firstName", - lastName: String = "lastName", - password: String = passwordEncoder.encode("password"), - isDefaultGroupUser: Boolean = false, - isSystemUser: Boolean = false, - group: EmployeeGroup? = null, - permissions: MutableSet = mutableSetOf(), - lastLoginTime: LocalDateTime? = null, - op: Employee.() -> Unit = {} + passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(), + id: Long = 0L, + firstName: String = "firstName", + lastName: String = "lastName", + password: String = passwordEncoder.encode("password"), + isDefaultGroupUser: Boolean = false, + isSystemUser: Boolean = false, + group: EmployeeGroup? = null, + permissions: MutableSet = mutableSetOf(), + lastLoginTime: LocalDateTime? = null, + op: Employee.() -> Unit = {} ) = Employee( - id, - firstName, - lastName, - password, - isDefaultGroupUser, - isSystemUser, - group, - permissions, - lastLoginTime + id, + firstName, + lastName, + password, + isDefaultGroupUser, + isSystemUser, + group, + permissions, + lastLoginTime ).apply(op) fun employeeSaveDto( - passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(), - id: Long = 0L, - firstName: String = "firstName", - lastName: String = "lastName", - password: String = passwordEncoder.encode("password"), - groupId: Long? = null, - permissions: MutableSet = mutableSetOf(), - op: EmployeeSaveDto.() -> Unit = {} + passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(), + id: Long = 0L, + firstName: String = "firstName", + lastName: String = "lastName", + password: String = passwordEncoder.encode("password"), + groupId: Long? = null, + permissions: MutableSet = mutableSetOf(), + op: EmployeeSaveDto.() -> Unit = {} ) = EmployeeSaveDto(id, firstName, lastName, password, groupId, permissions).apply(op) fun employeeUpdateDto( - id: Long = 0L, - firstName: String = "firstName", - lastName: String = "lastName", - groupId: Long? = null, - permissions: MutableSet = mutableSetOf(), - op: EmployeeUpdateDto.() -> Unit = {} + id: Long = 0L, + firstName: String = "firstName", + lastName: String = "lastName", + groupId: Long? = null, + permissions: MutableSet = mutableSetOf(), + op: EmployeeUpdateDto.() -> Unit = {} ) = EmployeeUpdateDto(id, firstName, lastName, groupId, permissions).apply(op) // ==== Exceptions ==== @@ -166,26 +168,26 @@ private const val EMPLOYEE_ALREADY_EXISTS_EXCEPTION_TITLE = "Employee already ex private const val EMPLOYEE_EXCEPTION_ERROR_CODE = "employee" fun employeeIdNotFoundException(id: Long) = - NotFoundException( - EMPLOYEE_EXCEPTION_ERROR_CODE, - EMPLOYEE_NOT_FOUND_EXCEPTION_TITLE, - "An employee with the id $id could not be found", - id - ) + NotFoundException( + EMPLOYEE_EXCEPTION_ERROR_CODE, + EMPLOYEE_NOT_FOUND_EXCEPTION_TITLE, + "An employee with the id $id could not be found", + id + ) fun employeeIdAlreadyExistsException(id: Long) = - AlreadyExistsException( - EMPLOYEE_EXCEPTION_ERROR_CODE, - EMPLOYEE_ALREADY_EXISTS_EXCEPTION_TITLE, - "An employee with the id $id already exists", - id - ) + AlreadyExistsException( + EMPLOYEE_EXCEPTION_ERROR_CODE, + EMPLOYEE_ALREADY_EXISTS_EXCEPTION_TITLE, + "An employee with the id $id already exists", + id + ) fun employeeFullNameAlreadyExistsException(firstName: String, lastName: String) = - AlreadyExistsException( - EMPLOYEE_EXCEPTION_ERROR_CODE, - EMPLOYEE_ALREADY_EXISTS_EXCEPTION_TITLE, - "An employee with the name '$firstName $lastName' already exists", - "$firstName $lastName", - "fullName" - ) + AlreadyExistsException( + EMPLOYEE_EXCEPTION_ERROR_CODE, + EMPLOYEE_ALREADY_EXISTS_EXCEPTION_TITLE, + "An employee with the name '$firstName $lastName' already exists", + "$firstName $lastName", + "fullName" + ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/EmployeeGroup.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/EmployeeGroup.kt index a0d467e..a88e0e4 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/EmployeeGroup.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/EmployeeGroup.kt @@ -31,10 +31,8 @@ data class EmployeeGroup( @CollectionTable(name = "group_permission", joinColumns = [JoinColumn(name = "group_id")]) @Column(name = "permission") @Fetch(FetchMode.SUBSELECT) - @get:JsonProperty("explicitPermissions") val permissions: MutableSet = mutableSetOf(), ) : NamedModel { - @get:JsonProperty("permissions") val flatPermissions: Set get() = this.permissions .flatMap { it.flat() } @@ -69,6 +67,13 @@ open class EmployeeGroupUpdateDto( EmployeeGroup(id, name, permissions) } +data class EmployeeGroupOutputDto( + override val id: Long, + val name: String, + val permissions: Set, + val explicitPermissions: Set +): Model + fun employeeGroup( id: Long? = null, name: String = "name", diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/EmployeePermission.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/EmployeePermission.kt index e6ef1b7..5124243 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/EmployeePermission.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/EmployeePermission.kt @@ -2,48 +2,53 @@ package dev.fyloz.colorrecipesexplorer.model import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority -import java.util.* enum class EmployeePermission( - val impliedPermissions: List = listOf(), - val deprecated: Boolean = false + val impliedPermissions: List = listOf(), + val deprecated: Boolean = false ) { - VIEW_RECIPES, - VIEW_CATALOG, + READ_FILE, + WRITE_FILE(listOf(READ_FILE)), + REMOVE_FILE(listOf(WRITE_FILE)), + + VIEW_RECIPES(listOf(READ_FILE)), + VIEW_CATALOG(listOf(READ_FILE)), VIEW_USERS, PRINT_MIXES(listOf(VIEW_RECIPES)), EDIT_RECIPES_PUBLIC_DATA(listOf(VIEW_RECIPES)), - EDIT_RECIPES(listOf(EDIT_RECIPES_PUBLIC_DATA)), - EDIT_MATERIALS(listOf(VIEW_CATALOG)), + EDIT_RECIPES(listOf(EDIT_RECIPES_PUBLIC_DATA, WRITE_FILE)), + EDIT_MATERIALS(listOf(VIEW_CATALOG, WRITE_FILE)), EDIT_MATERIAL_TYPES(listOf(VIEW_CATALOG)), EDIT_COMPANIES(listOf(VIEW_CATALOG)), EDIT_USERS(listOf(VIEW_USERS)), EDIT_CATALOG(listOf(EDIT_MATERIALS, EDIT_MATERIAL_TYPES, EDIT_COMPANIES)), - ADD_TO_INVENTORY(listOf(VIEW_CATALOG)), - DEDUCT_FROM_INVENTORY(listOf(VIEW_RECIPES)), - - REMOVE_RECIPES(listOf(EDIT_RECIPES)), - REMOVE_MATERIALS(listOf(EDIT_MATERIALS)), + REMOVE_RECIPES(listOf(EDIT_RECIPES, REMOVE_FILE)), + REMOVE_MATERIALS(listOf(EDIT_MATERIALS, REMOVE_FILE)), REMOVE_MATERIAL_TYPES(listOf(EDIT_MATERIAL_TYPES)), REMOVE_COMPANIES(listOf(EDIT_COMPANIES)), REMOVE_USERS(listOf(EDIT_USERS)), REMOVE_CATALOG(listOf(REMOVE_MATERIALS, REMOVE_MATERIAL_TYPES, REMOVE_COMPANIES)), + ADD_TO_INVENTORY(listOf(VIEW_CATALOG)), + DEDUCT_FROM_INVENTORY(listOf(VIEW_RECIPES)), + GENERATE_TOUCH_UP_KIT, + ADMIN( - listOf( - EDIT_CATALOG, + listOf( + EDIT_CATALOG, - REMOVE_RECIPES, - REMOVE_USERS, - REMOVE_CATALOG, + REMOVE_RECIPES, + REMOVE_USERS, + REMOVE_CATALOG, - PRINT_MIXES, - ADD_TO_INVENTORY, - DEDUCT_FROM_INVENTORY - ) + PRINT_MIXES, + ADD_TO_INVENTORY, + DEDUCT_FROM_INVENTORY, + GENERATE_TOUCH_UP_KIT + ) ), // deprecated permissions diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Material.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Material.kt index 3b060a1..5dc53de 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Material.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Material.kt @@ -1,11 +1,16 @@ 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 dev.fyloz.colorrecipesexplorer.model.validation.NullOrNotBlank import dev.fyloz.colorrecipesexplorer.model.validation.NullOrSize +import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES +import dev.fyloz.colorrecipesexplorer.rest.files.FILE_CONTROLLER_PATH import org.springframework.web.multipart.MultipartFile +import java.net.URLEncoder +import java.nio.charset.StandardCharsets import javax.persistence.* import javax.validation.constraints.Min import javax.validation.constraints.NotBlank @@ -21,151 +26,170 @@ private const val MATERIAL_QUANTITY_MATERIAL_NULL_MESSAGE = "Un produit est requ private const val MATERIAL_QUANTITY_QUANTITY_NULL_MESSAGE = "Une quantité est requises" private const val MATERIAL_QUANTITY_QUANTITY_NEGATIVE_MESSAGE = "La quantité doit être supérieure ou égale à 0" +const val SIMDUT_FILES_PATH = "pdf/simdut" + @Entity @Table(name = "material") data class Material( - @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, + @Column(unique = true) + override var name: String, - @Column(name = "inventory_quantity") - var inventoryQuantity: Float, + @Column(name = "inventory_quantity") + var inventoryQuantity: Float, - @Column(name = "mix_type") - val isMixType: Boolean, + @Column(name = "mix_type") + val isMixType: Boolean, - @ManyToOne - @JoinColumn(name = "material_type_id") - var materialType: MaterialType? -) : NamedModel + @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(message = MATERIAL_NAME_NULL_MESSAGE) - val name: String, + @field:NotBlank(message = MATERIAL_NAME_NULL_MESSAGE) + val name: String, - @field:NotNull(message = MATERIAL_INVENTORY_QUANTITY_NULL_MESSAGE) - @field:Min(value = 0, message = MATERIAL_INVENTORY_QUANTITY_NEGATIVE_MESSAGE) - val inventoryQuantity: Float, + @field:NotNull(message = MATERIAL_INVENTORY_QUANTITY_NULL_MESSAGE) + @field:Min(value = 0, message = MATERIAL_INVENTORY_QUANTITY_NEGATIVE_MESSAGE) + val inventoryQuantity: Float, - @field:NotNull(message = MATERIAL_TYPE_NULL_MESSAGE) - val materialTypeId: Long, + @field:NotNull(message = MATERIAL_TYPE_NULL_MESSAGE) + val materialTypeId: Long, - val simdutFile: MultipartFile? = null + val simdutFile: MultipartFile? = null ) : EntityDto open class MaterialUpdateDto( - @field:NotNull(message = MATERIAL_ID_NULL_MESSAGE) - val id: Long, + @field:NotNull(message = MATERIAL_ID_NULL_MESSAGE) + val id: Long, - @field:NullOrNotBlank(message = MATERIAL_NAME_NULL_MESSAGE) - val name: String?, + @field:NullOrNotBlank(message = MATERIAL_NAME_NULL_MESSAGE) + val name: String?, - @field:NullOrSize(min = 0, message = MATERIAL_INVENTORY_QUANTITY_NEGATIVE_MESSAGE) - val inventoryQuantity: Float?, + @field:NullOrSize(min = 0, message = MATERIAL_INVENTORY_QUANTITY_NEGATIVE_MESSAGE) + val inventoryQuantity: Float?, - val materialTypeId: Long?, + val materialTypeId: Long?, - val simdutFile: MultipartFile? = null + val simdutFile: MultipartFile? = null ) : EntityDto -data class MaterialQuantityDto( - @field:NotNull(message = MATERIAL_QUANTITY_MATERIAL_NULL_MESSAGE) - val material: Long, +data class MaterialOutputDto( + override val id: Long, + val name: String, + val inventoryQuantity: Float, + val isMixType: Boolean, + val materialType: MaterialType, + val simdutUrl: String? +) : Model - @field:NotNull(message = MATERIAL_QUANTITY_QUANTITY_NULL_MESSAGE) - @field:Min(value = 0, message = MATERIAL_QUANTITY_QUANTITY_NEGATIVE_MESSAGE) - val quantity: Float +data class MaterialQuantityDto( + @field:NotNull(message = MATERIAL_QUANTITY_MATERIAL_NULL_MESSAGE) + val material: Long, + + @field:NotNull(message = MATERIAL_QUANTITY_QUANTITY_NULL_MESSAGE) + @field:Min(value = 0, message = MATERIAL_QUANTITY_QUANTITY_NEGATIVE_MESSAGE) + val quantity: Float ) // === DSL === fun material( - id: Long? = null, - name: String = "name", - inventoryQuantity: Float = 0f, - isMixType: Boolean = false, - materialType: MaterialType? = materialType(), - op: Material.() -> Unit = {} + 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: Material, + id: Long? = null, + name: String? = null, ) = Material( - id ?: material.id, name - ?: material.name, material.inventoryQuantity, material.isMixType, material.materialType + 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 = {} + 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 = {} + 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 = {} + 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_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 - ) + 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" - ) + 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 - ) + 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" - ) + 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" - ) + CannotDeleteException( + MATERIAL_EXCEPTION_ERROR_CODE, + MATERIAL_CANNOT_DELETE_EXCEPTION_TITLE, + "Cannot delete the material ${material.name} because one or more recipes depends on it" + ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Mix.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Mix.kt index 4dcb08f..e332b36 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Mix.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Mix.kt @@ -22,107 +22,116 @@ private const val MIX_DEDUCT_RATION_NEGATIVE_MESSAGE = "Le ratio doit être éga @Entity @Table(name = "mix") data class Mix( - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - override val id: Long?, + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + override val id: Long?, - var location: String?, + var location: String?, - @JsonIgnore - @ManyToOne - @JoinColumn(name = "recipe_id") - val recipe: Recipe, + @JsonIgnore + @ManyToOne + @JoinColumn(name = "recipe_id") + val recipe: Recipe, - @ManyToOne - @JoinColumn(name = "mix_type_id") - var mixType: MixType, + @ManyToOne + @JoinColumn(name = "mix_type_id") + var mixType: MixType, - @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true) - @JoinColumn(name = "mix_id") - var mixMaterials: MutableSet, + @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true) + @JoinColumn(name = "mix_id") + var mixMaterials: MutableSet, ) : Model open class MixSaveDto( - @field:NotBlank(message = MIX_NAME_NULL_MESSAGE) - val name: String, + @field:NotBlank(message = MIX_NAME_NULL_MESSAGE) + val name: String, - @field:NotNull(message = MIX_RECIPE_NULL_MESSAGE) - val recipeId: Long, + @field:NotNull(message = MIX_RECIPE_NULL_MESSAGE) + val recipeId: Long, - @field:NotNull(message = MIX_MATERIAL_TYPE_NULL_MESSAGE) - val materialTypeId: Long, + @field:NotNull(message = MIX_MATERIAL_TYPE_NULL_MESSAGE) + val materialTypeId: Long, - val mixMaterials: Set? + val mixMaterials: Set? ) : EntityDto { override fun toEntity(): Mix = throw UnsupportedOperationException() } open class MixUpdateDto( - @field:NotNull(message = MIX_ID_NULL_MESSAGE) - val id: Long, + @field:NotNull(message = MIX_ID_NULL_MESSAGE) + val id: Long, - @field:NullOrNotBlank(message = MIX_NAME_NULL_MESSAGE) - val name: String?, + @field:NullOrNotBlank(message = MIX_NAME_NULL_MESSAGE) + val name: String?, - val materialTypeId: Long?, + val materialTypeId: Long?, - var mixMaterials: Set? + var mixMaterials: Set? ) : EntityDto { override fun toEntity(): Mix = throw UnsupportedOperationException() } -data class MixDeductDto( - @field:NotNull(message = MIX_DEDUCT_MIX_ID_NULL_MESSAGE) - val id: Long, +data class MixOutputDto( + val id: Long, + val location: String?, + val mixType: MixType, + val mixMaterials: Set +) - @field:NotNull(message = MIX_DEDUCT_RATIO_NULL_MESSAGE) - @field:Min(value = 0, message = MIX_DEDUCT_RATION_NEGATIVE_MESSAGE) - val ratio: Float +data class MixDeductDto( + @field:NotNull(message = MIX_DEDUCT_MIX_ID_NULL_MESSAGE) + val id: Long, + + @field:NotNull(message = MIX_DEDUCT_RATIO_NULL_MESSAGE) + @field:Min(value = 0, message = MIX_DEDUCT_RATION_NEGATIVE_MESSAGE) + val ratio: Float ) data class MixLocationDto( - @field:NotNull(message = MIX_DEDUCT_MIX_ID_NULL_MESSAGE) - val mixId: Long, + @field:NotNull(message = MIX_DEDUCT_MIX_ID_NULL_MESSAGE) + val mixId: Long, - val location: String? + 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 = {} + 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 = {} + 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 = {} + 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 = {} + 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 = {} + mixId: Long = 0L, + location: String? = "location", + op: MixLocationDto.() -> Unit = {} ) = MixLocationDto(mixId, location).apply(op) // ==== Exceptions ==== @@ -132,24 +141,24 @@ 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 - ) + 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 - ) + 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" - ) + 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" + ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MixMaterial.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MixMaterial.kt index 090a9bd..97f751c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MixMaterial.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MixMaterial.kt @@ -13,44 +13,51 @@ private const val MIX_MATERIAL_DTO_QUANTITY_NEGATIVE_MESSAGE = "La quantité ne @Entity @Table(name = "mix_material") data class MixMaterial( - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - override val id: Long?, + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + override val id: Long?, - @ManyToOne - @JoinColumn(name = "material_id") - val material: Material, + @ManyToOne + @JoinColumn(name = "material_id") + val material: Material, - var quantity: Float, + var quantity: Float, - var position: Int + var position: Int ) : Model +data class MixMaterialOutputDto( + val id: Long, + val material: MaterialOutputDto, + val quantity: Float, + val position: Int +) + data class MixMaterialDto( - @field:NotNull(message = MIX_MATERIAL_DTO_MATERIAL_ID_NULL_MESSAGE) - val materialId: Long, + @field:NotNull(message = MIX_MATERIAL_DTO_MATERIAL_ID_NULL_MESSAGE) + val materialId: Long, - @field:NotNull(message = MIX_MATERIAL_DTO_QUANTITY_NULL_MESSAGE) - @field:Min(value = 0, message = MIX_MATERIAL_DTO_QUANTITY_NEGATIVE_MESSAGE) - val quantity: Float, + @field:NotNull(message = MIX_MATERIAL_DTO_QUANTITY_NULL_MESSAGE) + @field:Min(value = 0, message = MIX_MATERIAL_DTO_QUANTITY_NEGATIVE_MESSAGE) + val quantity: Float, - val position: Int + val position: Int ) // ==== DSL ==== fun mixMaterial( - id: Long? = null, - material: Material = material(), - quantity: Float = 0f, - position: Int = 0, - op: MixMaterial.() -> Unit = {} + 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 = {} + materialId: Long = 0L, + quantity: Float = 0f, + position: Int = 0, + op: MixMaterialDto.() -> Unit = {} ) = MixMaterialDto(materialId, quantity, position).apply(op) // ==== Exceptions ==== @@ -59,17 +66,17 @@ private const val MIX_MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE = "Mix material al 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 - ) + 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 - ) + AlreadyExistsException( + MIX_MATERIAL_EXCEPTION_ERROR_CODE, + MIX_MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE, + "A mix material with the id $id already exists", + id + ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt index 8d32a87..367375a 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt @@ -3,10 +3,12 @@ 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.exception.RestException import dev.fyloz.colorrecipesexplorer.model.validation.NullOrNotBlank import dev.fyloz.colorrecipesexplorer.model.validation.NullOrSize -import org.springframework.http.HttpStatus +import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES +import dev.fyloz.colorrecipesexplorer.rest.files.FILE_CONTROLLER_PATH +import java.net.URLEncoder +import java.nio.charset.StandardCharsets import java.time.LocalDate import javax.persistence.* import javax.validation.constraints.* @@ -25,230 +27,260 @@ private const val RECIPE_STEPS_DTO_MESSAGES_NULL_MESSAGE = "Des messages sont re private const val NOTE_GROUP_ID_NULL_MESSAGE = "Un identifiant de groupe est requis" +const val RECIPE_IMAGES_DIRECTORY = "images/recipes" + @Entity @Table(name = "recipe") data class Recipe( - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - override val id: Long?, + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + 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, + /** 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, - val description: String, + val description: String, - /** The color produced by the recipe. The string should be formatted as a hexadecimal color without the sharp (#). */ - val color: String, + /** The color produced by the recipe. The string should be formatted as a hexadecimal color without the sharp (#). */ + val color: String, - /** The gloss of the color in percents. (0-100) */ - val gloss: Byte, + /** The gloss of the color in percents. (0-100) */ + val gloss: Byte, - val sample: Int?, + val sample: Int?, - @Column(name = "approbation_date") - val approbationDate: LocalDate?, + @Column(name = "approbation_date") + val approbationDate: LocalDate?, - /** A remark given by the creator of the recipe. */ - val remark: String, + /** A remark given by the creator of the recipe. */ + val remark: String, - @ManyToOne - @JoinColumn(name = "company_id") - val company: Company, + @ManyToOne + @JoinColumn(name = "company_id") + val company: Company, - @OneToMany(cascade = [CascadeType.ALL], mappedBy = "recipe") - val mixes: MutableList, + @OneToMany(cascade = [CascadeType.ALL], mappedBy = "recipe") + val mixes: MutableList, - @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true) - @JoinColumn(name = "recipe_id") - val groupsInformation: Set + @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, 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 } + groupsInformation.firstOrNull { it.group.id == groupId } + + fun imageUrl(name: String) = + "${CRE_PROPERTIES.deploymentUrl}$FILE_CONTROLLER_PATH?path=${ + URLEncoder.encode( + "${this.imagesDirectoryPath}/$name", + StandardCharsets.UTF_8 + ) + }" } open class RecipeSaveDto( - @field:NotBlank(message = RECIPE_NAME_NULL_MESSAGE) - val name: String, + @field:NotBlank(message = RECIPE_NAME_NULL_MESSAGE) + val name: String, - @field:NotBlank(message = RECIPE_DESCRIPTION_NULL_MESSAGE) - val description: String, + @field:NotBlank(message = RECIPE_DESCRIPTION_NULL_MESSAGE) + val description: String, - @field:NotBlank(message = RECIPE_COLOR_NULL_MESSAGE) - @field:Pattern(regexp = "^#([0-9a-f]{6})$") - val color: String, + @field:NotBlank(message = RECIPE_COLOR_NULL_MESSAGE) + @field:Pattern(regexp = "^#([0-9a-f]{6})$") + val color: String, - @field:NotNull(message = RECIPE_GLOSS_NULL_MESSAGE) - @field:Min(value = 0, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE) - @field:Max(value = 100, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE) - val gloss: Byte, + @field:NotNull(message = RECIPE_GLOSS_NULL_MESSAGE) + @field:Min(value = 0, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE) + @field:Max(value = 100, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE) + val gloss: Byte, - @field:Min(value = 0, message = RECIPE_SAMPLE_TOO_SMALL_MESSAGE) - val sample: Int?, + @field:Min(value = 0, message = RECIPE_SAMPLE_TOO_SMALL_MESSAGE) + val sample: Int?, - val approbationDate: LocalDate?, + val approbationDate: LocalDate?, - val remark: String?, + val remark: String?, - @field:Min(value = 0, message = RECIPE_COMPANY_NULL_MESSAGE) - val companyId: Long = -1L, + @field:Min(value = 0, message = RECIPE_COMPANY_NULL_MESSAGE) + val companyId: Long = -1L, ) : EntityDto { override fun toEntity(): Recipe = recipe( - name = name, - description = description, - sample = sample, - approbationDate = approbationDate, - remark = remark ?: "", - company = company(id = companyId) + name = name, + description = description, + sample = sample, + approbationDate = approbationDate, + remark = remark ?: "", + company = company(id = companyId) ) } open class RecipeUpdateDto( - @field:NotNull(message = RECIPE_ID_NULL_MESSAGE) - val id: Long, + @field:NotNull(message = RECIPE_ID_NULL_MESSAGE) + val id: Long, - @field:NullOrNotBlank(message = RECIPE_NAME_NULL_MESSAGE) - val name: String?, + @field:NullOrNotBlank(message = RECIPE_NAME_NULL_MESSAGE) + val name: String?, - @field:NullOrNotBlank(message = RECIPE_DESCRIPTION_NULL_MESSAGE) - val description: String?, + @field:NullOrNotBlank(message = RECIPE_DESCRIPTION_NULL_MESSAGE) + val description: String?, - @field:NullOrNotBlank(message = RECIPE_COLOR_NULL_MESSAGE) - @field:Pattern(regexp = "^#([0-9a-f]{6})$") - val color: String?, + @field:NullOrNotBlank(message = RECIPE_COLOR_NULL_MESSAGE) + @field:Pattern(regexp = "^#([0-9a-f]{6})$") + val color: String?, - @field:Min(value = 0, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE) - @field:Max(value = 100, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE) - val gloss: Byte?, + @field:Min(value = 0, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE) + @field:Max(value = 100, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE) + val gloss: Byte?, - @field:NullOrSize(min = 0, message = RECIPE_SAMPLE_TOO_SMALL_MESSAGE) - val sample: Int?, + @field:NullOrSize(min = 0, message = RECIPE_SAMPLE_TOO_SMALL_MESSAGE) + val sample: Int?, - val approbationDate: LocalDate?, + val approbationDate: LocalDate?, - val remark: String?, + val remark: String?, - val steps: Set? + 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 remark: String?, + val company: Company, + val mixes: Set, + val groupsInformation: Set, + var imagesUrls: Set +) : Model + @Entity @Table(name = "recipe_group_information") data class RecipeGroupInformation( - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - val id: Long?, + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long?, - @ManyToOne - @JoinColumn(name = "group_id") - val group: EmployeeGroup, + @ManyToOne + @JoinColumn(name = "group_id") + val group: EmployeeGroup, - var note: String?, + var note: String?, - @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true) - @JoinColumn(name = "recipe_group_information_id") - var steps: MutableSet? + @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true) + @JoinColumn(name = "recipe_group_information_id") + var steps: MutableSet? ) data class RecipeStepsDto( - @field:NotNull(message = RECIPE_STEPS_DTO_GROUP_ID_NULL_MESSAGE) - val groupId: Long, + @field:NotNull(message = RECIPE_STEPS_DTO_GROUP_ID_NULL_MESSAGE) + val groupId: Long, - @field:NotNull(message = RECIPE_STEPS_DTO_MESSAGES_NULL_MESSAGE) - val steps: Set + @field:NotNull(message = RECIPE_STEPS_DTO_MESSAGES_NULL_MESSAGE) + val steps: Set ) data class RecipePublicDataDto( - @field:NotNull(message = RECIPE_ID_NULL_MESSAGE) - val recipeId: Long, + @field:NotNull(message = RECIPE_ID_NULL_MESSAGE) + val recipeId: Long, - val notes: Set?, + val notes: Set?, - val mixesLocation: Set? + val mixesLocation: Set? ) data class NoteDto( - @field:NotNull(message = NOTE_GROUP_ID_NULL_MESSAGE) - val groupId: Long, + @field:NotNull(message = NOTE_GROUP_ID_NULL_MESSAGE) + val groupId: Long, - val content: String? + 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 = {} + 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 + 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 = {} + 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 = {} + 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: EmployeeGroup = employeeGroup(), - note: String? = null, - steps: MutableSet? = mutableSetOf(), - op: RecipeGroupInformation.() -> Unit = {} + id: Long? = null, + group: EmployeeGroup = employeeGroup(), + 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 = {} + 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 = {} + groupId: Long = 0L, + content: String? = "note", + op: NoteDto.() -> Unit = {} ) = NoteDto(groupId, content).apply(op) // ==== Exceptions ==== @@ -256,30 +288,18 @@ 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" -class RecipeImageNotFoundException(id: Long, recipe: Recipe) : - RestException( - "notfound-recipeimage-id", - "Recipe image not found", - HttpStatus.NOT_FOUND, - "A recipe image with the id $id could no be found for the recipe ${recipe.name}", - mapOf( - "id" to id, - "recipe" to recipe.name - ) - ) - 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 - ) + 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 - ) + AlreadyExistsException( + RECIPE_EXCEPTION_ERROR_CODE, + RECIPE_ALREADY_EXISTS_EXCEPTION_TITLE, + "A recipe with the id $id already exists", + id + ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt index 0776aa5..ea9efde 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt @@ -4,7 +4,7 @@ import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditUsers import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeRemoveUsers import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewUsers import dev.fyloz.colorrecipesexplorer.model.* -import dev.fyloz.colorrecipesexplorer.service.EmployeeGroupServiceImpl +import dev.fyloz.colorrecipesexplorer.service.EmployeeGroupService import dev.fyloz.colorrecipesexplorer.service.EmployeeService import org.springframework.http.MediaType import org.springframework.security.access.prepost.PreAuthorize @@ -23,52 +23,56 @@ class EmployeeController(private val employeeService: EmployeeService) { @GetMapping @PreAuthorizeViewUsers fun getAll() = - ok(employeeService.getAll()) + ok(employeeService.getAllForOutput()) @GetMapping("{id}") @PreAuthorizeViewUsers fun getById(@PathVariable id: Long) = - ok(employeeService.getById(id)) + ok(employeeService.getByIdForOutput(id)) @GetMapping("current") fun getCurrent(loggedInEmployee: Principal?) = - if (loggedInEmployee != null) - ok( - employeeService.getById( - loggedInEmployee.name.toLong(), - ignoreDefaultGroupUsers = false, - ignoreSystemUsers = false + if (loggedInEmployee != null) + ok( + with(employeeService) { + getById( + loggedInEmployee.name.toLong(), + ignoreDefaultGroupUsers = false, + ignoreSystemUsers = false + ).toOutput() + } ) - ) - else - forbidden() + else + forbidden() @PostMapping @PreAuthorizeEditUsers fun save(@Valid @RequestBody employee: EmployeeSaveDto) = - created(EMPLOYEE_CONTROLLER_PATH) { - employeeService.save(employee) - } + created(EMPLOYEE_CONTROLLER_PATH) { + with(employeeService) { + save(employee).toOutput() + } + } @PutMapping @PreAuthorizeEditUsers fun update(@Valid @RequestBody employee: EmployeeUpdateDto) = - noContent { - employeeService.update(employee) - } + noContent { + employeeService.update(employee) + } @PutMapping("{id}/password", consumes = [MediaType.TEXT_PLAIN_VALUE]) @PreAuthorizeEditUsers fun updatePassword(@PathVariable id: Long, @RequestBody password: String) = - noContent { - employeeService.updatePassword(id, password) - } + noContent { + employeeService.updatePassword(id, password) + } @PutMapping("{employeeId}/permissions/{permission}") @PreAuthorizeEditUsers fun addPermission( - @PathVariable employeeId: Long, - @PathVariable permission: EmployeePermission + @PathVariable employeeId: Long, + @PathVariable permission: EmployeePermission ) = noContent { employeeService.addPermission(employeeId, permission) } @@ -76,8 +80,8 @@ class EmployeeController(private val employeeService: EmployeeService) { @DeleteMapping("{employeeId}/permissions/{permission}") @PreAuthorizeEditUsers fun removePermission( - @PathVariable employeeId: Long, - @PathVariable permission: EmployeePermission + @PathVariable employeeId: Long, + @PathVariable permission: EmployeePermission ) = noContent { employeeService.removePermission(employeeId, permission) } @@ -85,59 +89,69 @@ class EmployeeController(private val employeeService: EmployeeService) { @DeleteMapping("{id}") @PreAuthorizeRemoveUsers fun deleteById(@PathVariable id: Long) = - employeeService.deleteById(id) + employeeService.deleteById(id) } @RestController @RequestMapping(EMPLOYEE_GROUP_CONTROLLER_PATH) -class GroupsController(private val groupService: EmployeeGroupServiceImpl) { +class GroupsController( + private val groupService: EmployeeGroupService, + private val employeeService: EmployeeService +) { @GetMapping @PreAuthorize("hasAnyAuthority('VIEW_RECIPES', 'VIEW_USERS')") fun getAll() = - ok(groupService.getAll()) + ok(groupService.getAllForOutput()) @GetMapping("{id}") @PreAuthorizeViewUsers fun getById(@PathVariable id: Long) = - ok(groupService.getById(id)) + ok(groupService.getByIdForOutput(id)) @GetMapping("{id}/employees") @PreAuthorizeViewUsers fun getEmployeesForGroup(@PathVariable id: Long) = - ok(groupService.getEmployeesForGroup(id)) + ok(with(employeeService) { + groupService.getEmployeesForGroup(id) + .map { it.toOutput() } + }) @PostMapping("default/{groupId}") @PreAuthorizeViewUsers fun setDefaultGroup(@PathVariable groupId: Long, response: HttpServletResponse) = - noContent { - groupService.setResponseDefaultGroup(groupId, response) - } + noContent { + groupService.setResponseDefaultGroup(groupId, response) + } @GetMapping("default") @PreAuthorizeViewUsers fun getRequestDefaultGroup(request: HttpServletRequest) = - ok(groupService.getRequestDefaultGroup(request)) + ok(with(groupService) { + getRequestDefaultGroup(request).toOutput() + }) @PostMapping @PreAuthorizeEditUsers fun save(@Valid @RequestBody group: EmployeeGroupSaveDto) = - created(EMPLOYEE_GROUP_CONTROLLER_PATH) { - groupService.save(group) - } + created(EMPLOYEE_GROUP_CONTROLLER_PATH) { + with(groupService) { + save(group).toOutput() + } + } @PutMapping @PreAuthorizeEditUsers fun update(@Valid @RequestBody group: EmployeeGroupUpdateDto) = - noContent { - groupService.update(group) - } + noContent { + groupService.update(group) + } @DeleteMapping("{id}") @PreAuthorizeRemoveUsers fun deleteById(@PathVariable id: Long) = - noContent { - groupService.deleteById(id) - } + noContent { + groupService.deleteById(id) + } } @RestController @@ -145,7 +159,7 @@ class GroupsController(private val groupService: EmployeeGroupServiceImpl) { class LogoutController(private val employeeService: EmployeeService) { @GetMapping("logout") fun logout(request: HttpServletRequest) = - ok { - employeeService.logout(request) - } + ok { + employeeService.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 ef59303..e1acc00 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/CompanyController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/CompanyController.kt @@ -17,11 +17,11 @@ private const val COMPANY_CONTROLLER_PATH = "api/company" class CompanyController(private val companyService: CompanyService) { @GetMapping fun getAll() = - ok(companyService.getAll()) + ok(companyService.getAllForOutput()) @GetMapping("{id}") fun getById(@PathVariable id: Long) = - ok(companyService.getById(id)) + ok(companyService.getByIdForOutput(id)) @PostMapping @PreAuthorize("hasAuthority('EDIT_COMPANIES')") diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/InventoryController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/InventoryController.kt index 832e14d..636c3af 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/InventoryController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/InventoryController.kt @@ -15,21 +15,20 @@ private const val INVENTORY_CONTROLLER_PATH = "api/inventory" @RestController @RequestMapping(INVENTORY_CONTROLLER_PATH) class InventoryController( - private val inventoryService: InventoryService + private val inventoryService: InventoryService ) { @PutMapping("add") @PreAuthorize("hasAuthority('ADD_TO_INVENTORY')") - fun add(@RequestBody quantities: Collection): ResponseEntity> { - return ResponseEntity.ok(inventoryService.add(quantities)) - } + fun add(@RequestBody quantities: Collection) = + ok(inventoryService.add(quantities)) @PutMapping("deduct") @PreAuthorize("hasAuthority('DEDUCT_FROM_INVENTORY')") fun deduct(@RequestBody quantities: Collection) = - ok(inventoryService.deduct(quantities)) + ok(inventoryService.deduct(quantities)) @PutMapping("deduct/mix") @PreAuthorize("hasAuthority('DEDUCT_FROM_INVENTORY')") fun deduct(@RequestBody mixRatio: MixDeductDto) = - ok(inventoryService.deductMix(mixRatio)) + ok(inventoryService.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 a44d493..8e99b03 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialController.kt @@ -1,6 +1,7 @@ package dev.fyloz.colorrecipesexplorer.rest 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 org.springframework.http.MediaType @@ -8,6 +9,7 @@ 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" @@ -15,78 +17,65 @@ private const val MATERIAL_CONTROLLER_PATH = "api/material" @RestController @RequestMapping(MATERIAL_CONTROLLER_PATH) @PreAuthorizeViewCatalog -class MaterialController(private val materialService: MaterialService) { +class MaterialController( + private val materialService: MaterialService +) { @GetMapping fun getAll() = - ok(materialService.getAll()) + ok(materialService.getAllForOutput()) @GetMapping("notmixtype") fun getAllNotMixType() = - ok(materialService.getAllNotMixType()) + ok(materialService.getAllNotMixType()) @GetMapping("{id}") fun getById(@PathVariable id: Long) = - ok(materialService.getById(id)) + ok(materialService.getByIdForOutput(id)) @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) @PreAuthorize("hasAuthority('EDIT_MATERIALS')") fun save(@Valid material: MaterialSaveDto, simdutFile: MultipartFile?) = - created(MATERIAL_CONTROLLER_PATH) { - materialService.save( - materialSaveDto( - name = material.name, - inventoryQuantity = material.inventoryQuantity, - materialTypeId = material.materialTypeId, - simdutFile = simdutFile - ) - ) - } + created(MATERIAL_CONTROLLER_PATH) { + with(materialService) { + save( + materialSaveDto( + name = material.name, + inventoryQuantity = material.inventoryQuantity, + materialTypeId = material.materialTypeId, + simdutFile = simdutFile + ) + ).toOutput() + } + } @PutMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) @PreAuthorize("hasAuthority('EDIT_MATERIALS')") fun update(@Valid material: MaterialUpdateDto, simdutFile: MultipartFile?) = - noContent { - materialService.update( - materialUpdateDto( - id = material.id, - name = material.name, - inventoryQuantity = material.inventoryQuantity, - materialTypeId = material.materialTypeId, - simdutFile = simdutFile + noContent { + materialService.update( + materialUpdateDto( + id = material.id, + name = material.name, + inventoryQuantity = material.inventoryQuantity, + materialTypeId = material.materialTypeId, + simdutFile = simdutFile + ) ) - ) - } + } @DeleteMapping("{id}") @PreAuthorize("hasAuthority('REMOVE_MATERIALS')") fun deleteById(@PathVariable id: Long) = - noContent { - materialService.deleteById(id) - } - - @GetMapping("{id}/simdut/exists") - fun hasSimdut(@PathVariable id: Long) = - ok(materialService.hasSimdut(id)) - - @GetMapping("{id}/simdut", produces = [MediaType.APPLICATION_PDF_VALUE]) - fun getSimdut(@PathVariable id: Long): ResponseEntity = with(materialService.getSimdut(id)) { - if (this.isEmpty()) { - notFound() - } else { - ok(this, httpHeaders(contentType = MediaType.APPLICATION_PDF)) + noContent { + materialService.deleteById(id) } - } - - @GetMapping("/simdut") - fun getAllIdsWithSimdut() = - ok(materialService.getAllIdsWithSimdut()) @GetMapping("mix/create/{recipeId}") fun getAllForMixCreation(@PathVariable recipeId: Long) = - ok(materialService.getAllForMixCreation(recipeId)) + ok(materialService.getAllForMixCreation(recipeId)) @GetMapping("mix/update/{mixId}") fun getAllForMixUpdate(@PathVariable mixId: Long) = - ok(materialService.getAllForMixUpdate(mixId)) + ok(materialService.getAllForMixUpdate(mixId)) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialTypeController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialTypeController.kt index 3991873..50e26f0 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialTypeController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialTypeController.kt @@ -17,11 +17,11 @@ private const val MATERIAL_TYPE_CONTROLLER_PATH = "api/materialtype" class MaterialTypeController(private val materialTypeService: MaterialTypeService) { @GetMapping fun getAll() = - ok(materialTypeService.getAll()) + ok(materialTypeService.getAllForOutput()) @GetMapping("{id}") fun getById(@PathVariable id: Long) = - ok(materialTypeService.getById(id)) + ok(materialTypeService.getByIdForOutput(id)) @PostMapping @PreAuthorize("hasAuthority('EDIT_MATERIAL_TYPES')") diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt index a4f39fc..8bf447e 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt @@ -3,7 +3,9 @@ package dev.fyloz.colorrecipesexplorer.rest import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditRecipes import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeRemoveRecipes import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewRecipes +import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties import dev.fyloz.colorrecipesexplorer.model.* +import dev.fyloz.colorrecipesexplorer.rest.files.FILE_CONTROLLER_PATH import dev.fyloz.colorrecipesexplorer.service.MixService import dev.fyloz.colorrecipesexplorer.service.RecipeImageService import dev.fyloz.colorrecipesexplorer.service.RecipeService @@ -12,7 +14,8 @@ 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 java.net.URLEncoder +import java.nio.charset.StandardCharsets import javax.validation.Valid @@ -22,69 +25,61 @@ private const val MIX_CONTROLLER_PATH = "api/recipe/mix" @RestController @RequestMapping(RECIPE_CONTROLLER_PATH) @PreAuthorizeViewRecipes -class RecipeController(private val recipeService: RecipeService) { +class RecipeController( + private val recipeService: RecipeService, + private val recipeImageService: RecipeImageService +) { @GetMapping fun getAll() = - ok(recipeService.getAll()) + ok(recipeService.getAllForOutput()) @GetMapping("{id}") fun getById(@PathVariable id: Long) = - ok(recipeService.getById(id)) + ok(recipeService.getByIdForOutput(id)) @PostMapping @PreAuthorizeEditRecipes fun save(@Valid @RequestBody recipe: RecipeSaveDto) = - created(RECIPE_CONTROLLER_PATH) { - recipeService.save(recipe) - } + created(RECIPE_CONTROLLER_PATH) { + with(recipeService) { + save(recipe).toOutput() + } + } @PutMapping @PreAuthorizeEditRecipes fun update(@Valid @RequestBody recipe: RecipeUpdateDto) = - noContent { - recipeService.update(recipe) - } + noContent { + recipeService.update(recipe) + } @PutMapping("public") @PreAuthorize("hasAuthority('EDIT_RECIPES_PUBLIC_DATA')") fun updatePublicData(@Valid @RequestBody publicDataDto: RecipePublicDataDto) = - noContent { - recipeService.updatePublicData(publicDataDto) - } + noContent { + recipeService.updatePublicData(publicDataDto) + } @DeleteMapping("{id}") @PreAuthorizeRemoveRecipes fun deleteById(@PathVariable id: Long) = - noContent { - recipeService.deleteById(id) - } -} + noContent { + recipeService.deleteById(id) + } -@RestController -@RequestMapping(RECIPE_CONTROLLER_PATH) -@PreAuthorizeViewRecipes -class RecipeImageController(val recipeImageService: RecipeImageService) { - @GetMapping("{recipeId}/image") - fun getAllIdsForRecipe(@PathVariable recipeId: Long) = - ok(recipeImageService.getAllIdsForRecipe(recipeId)) - - @GetMapping("{recipeId}/image/{id}", produces = [MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE]) - fun getById(@PathVariable recipeId: Long, @PathVariable id: Long) = - ok(recipeImageService.getByIdForRecipe(id, recipeId)) - - @PostMapping("{recipeId}/image", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @PutMapping("{recipeId}/image", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) @PreAuthorizeEditRecipes - fun save(@PathVariable recipeId: Long, image: MultipartFile): ResponseEntity { - val id = recipeImageService.save(image, recipeId) - return ResponseEntity.created(URI.create("/$RECIPE_CONTROLLER_PATH/$recipeId/image/$id")).build() + fun downloadImage(@PathVariable recipeId: Long, image: MultipartFile): ResponseEntity { + recipeImageService.download(image, recipeService.getById(recipeId)) + return getById(recipeId) } - @DeleteMapping("{recipeId}/image/{id}") - @PreAuthorizeRemoveRecipes - fun delete(@PathVariable recipeId: Long, @PathVariable id: Long) = - noContent { - recipeImageService.delete(id, recipeId) - } + @DeleteMapping("{recipeId}/image/{name}") + @PreAuthorizeEditRecipes + fun deleteImage(@PathVariable recipeId: Long, @PathVariable name: String) = + noContent { + recipeImageService.delete(recipeService.getById(recipeId), name) + } } @RestController @@ -93,26 +88,26 @@ class RecipeImageController(val recipeImageService: RecipeImageService) { class MixController(private val mixService: MixService) { @GetMapping("{id}") fun getById(@PathVariable id: Long) = - ok(mixService.getById(id)) + ok(mixService.getByIdForOutput(id)) @PostMapping @PreAuthorizeEditRecipes fun save(@Valid @RequestBody mix: MixSaveDto) = - created(MIX_CONTROLLER_PATH) { - mixService.save(mix) - } + created(MIX_CONTROLLER_PATH) { + mixService.save(mix) + } @PutMapping @PreAuthorizeEditRecipes fun update(@Valid @RequestBody mix: MixUpdateDto) = - noContent { - mixService.update(mix) - } + noContent { + mixService.update(mix) + } @DeleteMapping("{id}") @PreAuthorizeRemoveRecipes fun deleteById(@PathVariable id: Long) = - noContent { - mixService.deleteById(id) - } + 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 46004d1..c7a82fc 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RestUtils.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RestUtils.kt @@ -1,5 +1,6 @@ package dev.fyloz.colorrecipesexplorer.rest +import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties import dev.fyloz.colorrecipesexplorer.model.Model import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus @@ -7,13 +8,15 @@ import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import java.net.URI +lateinit var CRE_PROPERTIES: CreProperties + /** Creates a HTTP OK [ResponseEntity] from the given [body]. */ fun ok(body: T): ResponseEntity = - ResponseEntity.ok(body) + ResponseEntity.ok(body) /** Creates a HTTP OK [ResponseEntity] from the given [body] and [headers]. */ fun ok(body: T, headers: HttpHeaders): ResponseEntity = - ResponseEntity(body, headers, HttpStatus.OK) + ResponseEntity(body, headers, HttpStatus.OK) /** Executes the given [action] then returns an HTTP OK [ResponseEntity] form the given [body]. */ fun ok(action: () -> Unit): ResponseEntity { @@ -23,19 +26,23 @@ fun ok(action: () -> Unit): ResponseEntity { /** Creates a HTTP CREATED [ResponseEntity] from the given [body] with the location set to [controllerPath]/id. */ fun created(controllerPath: String, body: T): ResponseEntity = - ResponseEntity.created(URI.create("$controllerPath/${body.id}")).body(body) + 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 = - created(controllerPath, producer()) + created(controllerPath, producer()) + +/** Creates a HTTP CREATED [ResponseEntity] from the given [body] with the location set to [controllerPath]/id. */ +fun created(controllerPath: String, body: T, id: Any): ResponseEntity = + ResponseEntity.created(URI.create("$controllerPath/$id")).body(body) /** Creates a HTTP NOT FOUND [ResponseEntity]. */ fun notFound(): ResponseEntity = - ResponseEntity.notFound().build() + ResponseEntity.notFound().build() /** Creates a HTTP NO CONTENT [ResponseEntity]. */ fun noContent(): ResponseEntity = - ResponseEntity.noContent().build() + ResponseEntity.noContent().build() /** Executes the given [action] then returns an HTTP NO CONTENT [ResponseEntity]. */ fun noContent(action: () -> Unit): ResponseEntity { @@ -45,12 +52,12 @@ fun noContent(action: () -> Unit): ResponseEntity { /** Creates a HTTP FORBIDDEN [ResponseEntity]. */ fun forbidden(): ResponseEntity = - ResponseEntity.status(HttpStatus.FORBIDDEN).build() + ResponseEntity.status(HttpStatus.FORBIDDEN).build() /** Creates an [HttpHeaders] instance from the given options. */ fun httpHeaders( - contentType: MediaType = MediaType.APPLICATION_JSON, - op: HttpHeaders.() -> Unit = {} + contentType: MediaType = MediaType.APPLICATION_JSON, + op: HttpHeaders.() -> Unit = {} ) = HttpHeaders().apply { this.contentType = contentType diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/FileController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/FileController.kt new file mode 100644 index 0000000..9e5125b --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/FileController.kt @@ -0,0 +1,63 @@ +package dev.fyloz.colorrecipesexplorer.rest.files + +import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties +import dev.fyloz.colorrecipesexplorer.rest.noContent +import dev.fyloz.colorrecipesexplorer.service.files.FileService +import org.springframework.core.io.ByteArrayResource +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile +import java.net.URI + +const val FILE_CONTROLLER_PATH = "/api/file" +private const val DEFAULT_MEDIA_TYPE = MediaType.APPLICATION_OCTET_STREAM_VALUE + +@RestController +@RequestMapping(FILE_CONTROLLER_PATH) +class FileController( + private val fileService: FileService, + private val creProperties: CreProperties +) { + @GetMapping(produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE]) + @PreAuthorize("hasAnyAuthority('READ_FILE')") + fun upload( + @RequestParam path: String, + @RequestParam(required = false) mediaType: String? + ): ResponseEntity { + val file = fileService.read(path) + return ResponseEntity.ok() + .header("Content-Disposition", "filename=${getFileNameFromPath(path)}") + .contentLength(file.contentLength()) + .contentType(MediaType.parseMediaType(mediaType ?: DEFAULT_MEDIA_TYPE)) + .body(file) + } + + @PutMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @PreAuthorize("hasAnyAuthority('WRITE_FILE')") + fun download( + file: MultipartFile, + @RequestParam path: String, + @RequestParam(required = false) overwrite: Boolean = false + ): ResponseEntity { + fileService.write(file, path, overwrite) + return created(path) + } + + @DeleteMapping + @PreAuthorize("hasAnyAuthority('REMOVE_FILE')") + fun delete(@RequestParam path: String): ResponseEntity { + return noContent { + fileService.delete(path) + } + } + + private fun created(path: String): ResponseEntity = + ResponseEntity + .created(URI.create("${creProperties.deploymentUrl}$FILE_CONTROLLER_PATH?path=$path")) + .build() + + private fun getFileNameFromPath(path: String) = + path.split("/").last() +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/TouchUpKitController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/TouchUpKitController.kt new file mode 100644 index 0000000..6993025 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/TouchUpKitController.kt @@ -0,0 +1,26 @@ +package dev.fyloz.colorrecipesexplorer.rest.files + +import dev.fyloz.colorrecipesexplorer.service.files.TouchUpKitService +import org.springframework.core.io.ByteArrayResource +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/touchup") +@PreAuthorize("hasAuthority('GENERATE_TOUCH_UP_KIT')") +class TouchUpKitController( + private val touchUpKitService: TouchUpKitService +) { + @GetMapping + fun getJobPdf(@RequestParam job: String): ResponseEntity { + with(touchUpKitService.generateJobPdfResource(job)) { + return ResponseEntity.ok() + .header("Content-Disposition", "filename=TouchUpKit_$job.pdf") + .contentLength(this.contentLength()) + .contentType(MediaType.APPLICATION_PDF) + .body(this) + } + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt index d3b81ba..8f08ed4 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt @@ -20,7 +20,8 @@ import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse import javax.transaction.Transactional -interface EmployeeService : ExternalModelService { +interface EmployeeService : + ExternalModelService { /** Check if an [Employee] with the given [firstName] and [lastName] exists. */ fun existsByFirstNameAndLastName(firstName: String, lastName: String): Boolean @@ -56,7 +57,7 @@ interface EmployeeService : ExternalModelService { + ExternalNamedModelService { /** Gets all the employees of the group with the given [id]. */ fun getEmployeesForGroup(id: Long): Collection @@ -74,50 +75,62 @@ interface EmployeeUserDetailsService : UserDetailsService { @Service class EmployeeServiceImpl( - employeeRepository: EmployeeRepository, - @Lazy val groupService: EmployeeGroupService, - @Lazy val passwordEncoder: PasswordEncoder, -) : AbstractExternalModelService(employeeRepository), - EmployeeService { + employeeRepository: EmployeeRepository, + @Lazy val groupService: EmployeeGroupService, + @Lazy val passwordEncoder: PasswordEncoder, +) : AbstractExternalModelService( + employeeRepository +), + EmployeeService { override fun idNotFoundException(id: Long) = employeeIdNotFoundException(id) override fun idAlreadyExistsException(id: Long) = employeeIdAlreadyExistsException(id) + override fun Employee.toOutput() = EmployeeOutputDto( + this.id, + this.firstName, + this.lastName, + this.group, + this.flatPermissions, + this.permissions, + this.lastLoginTime + ) + override fun existsByFirstNameAndLastName(firstName: String, lastName: String): Boolean = - repository.existsByFirstNameAndLastName(firstName, lastName) + repository.existsByFirstNameAndLastName(firstName, lastName) override fun getAll(): Collection = - super.getAll().filter { !it.isSystemUser && !it.isDefaultGroupUser } + super.getAll().filter { !it.isSystemUser && !it.isDefaultGroupUser } override fun getById(id: Long): Employee = - getById(id, ignoreDefaultGroupUsers = true, ignoreSystemUsers = true) + getById(id, ignoreDefaultGroupUsers = true, ignoreSystemUsers = true) override fun getById(id: Long, ignoreDefaultGroupUsers: Boolean, ignoreSystemUsers: Boolean): Employee = - super.getById(id).apply { - if (ignoreSystemUsers && isSystemUser || ignoreDefaultGroupUsers && isDefaultGroupUser) - throw idNotFoundException(id) - } + super.getById(id).apply { + if (ignoreSystemUsers && isSystemUser || ignoreDefaultGroupUsers && isDefaultGroupUser) + throw idNotFoundException(id) + } override fun getByGroup(group: EmployeeGroup): Collection = - repository.findAllByGroup(group).filter { - !it.isSystemUser && !it.isDefaultGroupUser - } + repository.findAllByGroup(group).filter { + !it.isSystemUser && !it.isDefaultGroupUser + } override fun getDefaultGroupEmployee(group: EmployeeGroup): Employee = - repository.findByIsDefaultGroupUserIsTrueAndGroupIs(group) + repository.findByIsDefaultGroupUserIsTrueAndGroupIs(group) override fun save(entity: EmployeeSaveDto): Employee = - save(with(entity) { - Employee( - id, - firstName, - lastName, - passwordEncoder.encode(password), - isDefaultGroupUser = false, - isSystemUser = false, - group = if (groupId != null) groupService.getById(groupId) else null, - permissions = permissions - ) - }) + save(with(entity) { + Employee( + id, + firstName, + lastName, + passwordEncoder.encode(password), + isDefaultGroupUser = false, + isSystemUser = false, + group = if (groupId != null) groupService.getById(groupId) else null, + permissions = permissions + ) + }) override fun save(entity: Employee): Employee { if (existsById(entity.id)) @@ -129,14 +142,14 @@ class EmployeeServiceImpl( override fun saveDefaultGroupEmployee(group: EmployeeGroup) { save( - employee( - id = 1000000L + group.id!!, - firstName = group.name, - lastName = "EmployeeModel", - password = passwordEncoder.encode(group.name), - group = group, - isDefaultGroupUser = true - ) + employee( + id = 1000000L + group.id!!, + firstName = group.name, + lastName = "EmployeeModel", + password = passwordEncoder.encode(group.name), + group = group, + isDefaultGroupUser = true + ) ) } @@ -144,9 +157,9 @@ class EmployeeServiceImpl( val employee = getById(employeeId, ignoreDefaultGroupUsers = true, ignoreSystemUsers = false) employee.lastLoginTime = time return update( - employee, - ignoreDefaultGroupUsers = true, - ignoreSystemUsers = false + employee, + ignoreDefaultGroupUsers = true, + ignoreSystemUsers = false ) } @@ -154,21 +167,21 @@ class EmployeeServiceImpl( val persistedEmployee by lazy { getById(entity.id) } return update(with(entity) { Employee( - id = id, - firstName = firstName or persistedEmployee.firstName, - lastName = lastName or persistedEmployee.lastName, - password = persistedEmployee.password, - isDefaultGroupUser = false, - isSystemUser = false, - group = if (entity.groupId != null) groupService.getById(entity.groupId) else persistedEmployee.group, - permissions = permissions?.toMutableSet() ?: persistedEmployee.permissions, - lastLoginTime = persistedEmployee.lastLoginTime + id = id, + firstName = firstName or persistedEmployee.firstName, + lastName = lastName or persistedEmployee.lastName, + password = persistedEmployee.password, + isDefaultGroupUser = false, + isSystemUser = false, + group = if (entity.groupId != null) groupService.getById(entity.groupId) else persistedEmployee.group, + permissions = permissions?.toMutableSet() ?: persistedEmployee.permissions, + lastLoginTime = persistedEmployee.lastLoginTime ) }) } override fun update(entity: Employee): Employee = - update(entity, ignoreDefaultGroupUsers = true, ignoreSystemUsers = true) + update(entity, ignoreDefaultGroupUsers = true, ignoreSystemUsers = true) override fun update(entity: Employee, ignoreDefaultGroupUsers: Boolean, ignoreSystemUsers: Boolean): Employee { with(repository.findByFirstNameAndLastName(entity.firstName, entity.lastName)) { @@ -183,24 +196,24 @@ class EmployeeServiceImpl( val persistedEmployee = getById(id, ignoreDefaultGroupUsers = true, ignoreSystemUsers = true) return super.update(with(persistedEmployee) { Employee( - id, - firstName, - lastName, - passwordEncoder.encode(password), - isDefaultGroupUser, - isSystemUser, - group, - permissions, - lastLoginTime + id, + firstName, + lastName, + passwordEncoder.encode(password), + isDefaultGroupUser, + isSystemUser, + group, + permissions, + lastLoginTime ) }) } override fun addPermission(employeeId: Long, permission: EmployeePermission): Employee = - super.update(getById(employeeId).apply { permissions += permission }) + super.update(getById(employeeId).apply { permissions += permission }) override fun removePermission(employeeId: Long, permission: EmployeePermission): Employee = - super.update(getById(employeeId).apply { permissions -= permission }) + super.update(getById(employeeId).apply { permissions -= permission }) override fun logout(request: HttpServletRequest) { val authorizationCookie = WebUtils.getCookie(request, "Authorization") @@ -217,20 +230,27 @@ const val defaultGroupCookieMaxAge = 10 * 365 * 24 * 60 * 60 // 10 ans @Service class EmployeeGroupServiceImpl( - private val employeeService: EmployeeService, - employeeGroupRepository: EmployeeGroupRepository -) : AbstractExternalNamedModelService( - employeeGroupRepository + private val employeeService: EmployeeService, + employeeGroupRepository: EmployeeGroupRepository +) : AbstractExternalNamedModelService( + employeeGroupRepository ), - EmployeeGroupService { + EmployeeGroupService { override fun idNotFoundException(id: Long) = employeeGroupIdNotFoundException(id) override fun idAlreadyExistsException(id: Long) = employeeGroupIdAlreadyExistsException(id) override fun nameNotFoundException(name: String) = employeeGroupNameNotFoundException(name) override fun nameAlreadyExistsException(name: String) = employeeGroupNameAlreadyExistsException(name) + override fun EmployeeGroup.toOutput() = EmployeeGroupOutputDto( + this.id!!, + this.name, + this.permissions, + this.flatPermissions + ) + override fun existsByName(name: String): Boolean = repository.existsByName(name) override fun getEmployeesForGroup(id: Long): Collection = - employeeService.getByGroup(getById(id)) + employeeService.getByGroup(getById(id)) @Transactional override fun save(entity: EmployeeGroup): EmployeeGroup { @@ -243,9 +263,9 @@ class EmployeeGroupServiceImpl( val persistedGroup by lazy { getById(entity.id) } return update(with(entity) { EmployeeGroup( - entity.id, - if (name.isNotBlank()) entity.name else persistedGroup.name, - if (permissions.isNotEmpty()) entity.permissions else persistedGroup.permissions + entity.id, + if (name.isNotBlank()) entity.name else persistedGroup.name, + if (permissions.isNotEmpty()) entity.permissions else persistedGroup.permissions ) }) } @@ -258,11 +278,11 @@ class EmployeeGroupServiceImpl( override fun getRequestDefaultGroup(request: HttpServletRequest): EmployeeGroup { val defaultGroupCookie = WebUtils.getCookie(request, defaultGroupCookieName) - ?: throw NoDefaultGroupException() + ?: throw NoDefaultGroupException() val defaultGroupUser = employeeService.getById( - defaultGroupCookie.value.toLong(), - ignoreDefaultGroupUsers = false, - ignoreSystemUsers = true + defaultGroupCookie.value.toLong(), + ignoreDefaultGroupUsers = false, + ignoreSystemUsers = true ) return defaultGroupUser.group!! } @@ -271,17 +291,17 @@ class EmployeeGroupServiceImpl( val group = getById(groupId) val defaultGroupUser = employeeService.getDefaultGroupEmployee(group) response.addHeader( - "Set-Cookie", - "$defaultGroupCookieName=${defaultGroupUser.id}; Max-Age=${defaultGroupCookieMaxAge}; Path=/api; HttpOnly; Secure; SameSite=strict" + "Set-Cookie", + "$defaultGroupCookieName=${defaultGroupUser.id}; Max-Age=${defaultGroupCookieMaxAge}; Path=/api; HttpOnly; Secure; SameSite=strict" ) } } @Service class EmployeeUserDetailsServiceImpl( - private val employeeService: EmployeeService + private val employeeService: EmployeeService ) : - EmployeeUserDetailsService { + EmployeeUserDetailsService { override fun loadUserByUsername(username: String): UserDetails { try { return loadUserByEmployeeId(username.toLong(), true) @@ -294,9 +314,9 @@ class EmployeeUserDetailsServiceImpl( override fun loadUserByEmployeeId(employeeId: Long, ignoreDefaultGroupUsers: Boolean): UserDetails { val employee = employeeService.getById( - employeeId, - ignoreDefaultGroupUsers = ignoreDefaultGroupUsers, - ignoreSystemUsers = false + employeeId, + ignoreDefaultGroupUsers = ignoreDefaultGroupUsers, + ignoreSystemUsers = false ) return User(employee.id.toString(), employee.password, employee.authorities) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/CompanyService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/CompanyService.kt index 5e2d495..72a2f47 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/CompanyService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/CompanyService.kt @@ -5,23 +5,28 @@ import dev.fyloz.colorrecipesexplorer.repository.CompanyRepository import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Service -interface CompanyService : ExternalNamedModelService { +interface CompanyService : + ExternalNamedModelService { /** Checks if the given [company] is used by one or more recipes. */ fun isLinkedToRecipes(company: Company): Boolean } @Service class CompanyServiceImpl( - companyRepository: CompanyRepository, - @Lazy val recipeService: RecipeService + companyRepository: CompanyRepository, + @Lazy val recipeService: RecipeService ) : - AbstractExternalNamedModelService(companyRepository), - CompanyService { + 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) + override fun Company.toOutput() = this + override fun isLinkedToRecipes(company: Company): Boolean = recipeService.existsByCompany(company) override fun update(entity: CompanyUpdateDto): Company { @@ -30,8 +35,8 @@ class CompanyServiceImpl( return update(with(entity) { company( - id = id, - name = if (name != null && name.isNotBlank()) name else persistedCompany.name + id = id, + name = if (name != null && name.isNotBlank()) name else persistedCompany.name ) }) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/InventoryService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/InventoryService.kt index cefb0b0..4fb4ee3 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/InventoryService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/InventoryService.kt @@ -2,7 +2,7 @@ package dev.fyloz.colorrecipesexplorer.service import dev.fyloz.colorrecipesexplorer.exception.RestException import dev.fyloz.colorrecipesexplorer.model.* -import dev.fyloz.colorrecipesexplorer.service.utils.mapMayThrow +import dev.fyloz.colorrecipesexplorer.utils.mapMayThrow import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import javax.transaction.Transactional diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt index 0b9831b..02b843d 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt @@ -1,35 +1,32 @@ package dev.fyloz.colorrecipesexplorer.service -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.repository.MaterialRepository -import dev.fyloz.colorrecipesexplorer.service.files.SimdutService +import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES +import dev.fyloz.colorrecipesexplorer.rest.files.FILE_CONTROLLER_PATH +import dev.fyloz.colorrecipesexplorer.service.files.FileService import io.jsonwebtoken.lang.Assert import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Service +import java.net.URLEncoder +import java.nio.charset.StandardCharsets interface MaterialService : - ExternalNamedModelService { + ExternalNamedModelService { /** Checks if a material with the given [materialType] exists. */ fun existsByMaterialType(materialType: MaterialType): Boolean - /** Checks if the material with the given [id] has a SIMDUT file. */ - fun hasSimdut(id: Long): Boolean - - /** Gets the SIMDUT file of the material with the given [id]. */ - fun getSimdut(id: Long): ByteArray + /** Checks if the given [material] has a SIMDUT file. */ + fun hasSimdut(material: Material): Boolean /** Gets all materials that are not a mix type. */ - fun getAllNotMixType(): Collection + 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 + 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 - - /** Gets the identifier of materials for which a SIMDUT exists. */ - fun getAllIdsWithSimdut(): Collection + 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 @@ -37,44 +34,59 @@ interface MaterialService : @Service class MaterialServiceImpl( - materialRepository: MaterialRepository, - val simdutService: SimdutService, - val recipeService: RecipeService, - val mixService: MixService, - @Lazy val materialTypeService: MaterialTypeService + materialRepository: MaterialRepository, + val recipeService: RecipeService, + val mixService: MixService, + @Lazy val materialTypeService: MaterialTypeService, + val fileService: FileService ) : - AbstractExternalNamedModelService( - materialRepository - ), - MaterialService { + 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) + 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)) + "${CRE_PROPERTIES.deploymentUrl}$FILE_CONTROLLER_PATH?path=${ + URLEncoder.encode( + this.simdutFilePath, + StandardCharsets.UTF_8 + ) + }" + else null + ) + override fun existsByMaterialType(materialType: MaterialType): Boolean = - repository.existsByMaterialType(materialType) + repository.existsByMaterialType(materialType) - override fun hasSimdut(id: Long): Boolean = simdutService.exists(getById(id)) - override fun getSimdut(id: Long): ByteArray = simdutService.read(getById(id)) - override fun getAllNotMixType(): Collection = getAll().filter { !it.isMixType } - - override fun getAllIdsWithSimdut(): Collection = - getAllNotMixType() - .filter { simdutService.exists(it) } - .map { it.id!! } + 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) simdutService.write(this, entity.simdutFile) - } + 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 { @@ -83,14 +95,18 @@ class MaterialServiceImpl( 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 + 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) simdutService.update(entity.simdutFile, this) + if (entity.simdutFile != null && !entity.simdutFile.isEmpty) fileService.write( + entity.simdutFile, + this.simdutFilePath, + true + ) } } @@ -100,18 +116,18 @@ class MaterialServiceImpl( updatedQuantity } - override fun getAllForMixCreation(recipeId: Long): Collection { + override fun getAllForMixCreation(recipeId: Long): Collection { val recipesMixTypes = recipeService.getById(recipeId).mixTypes - return getAll() - .filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } } + return getAllForOutput() + .filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } } } - override fun getAllForMixUpdate(mixId: Long): Collection { + override fun getAllForMixUpdate(mixId: Long): Collection { val mix = mixService.getById(mixId) val recipesMixTypes = mix.recipe.mixTypes - return getAll() - .filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } } - .filter { it.id != mix.mixType.material.id } + 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) { @@ -120,6 +136,7 @@ class MaterialServiceImpl( override fun delete(entity: Material) { if (!repository.canBeDeleted(entity.id!!)) throw cannotDeleteMaterialException(entity) + fileService.delete(entity.simdutFilePath) super.delete(entity) } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialTypeService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialTypeService.kt index 3a9f108..e088ee4 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialTypeService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialTypeService.kt @@ -8,7 +8,7 @@ import dev.fyloz.colorrecipesexplorer.repository.MaterialTypeRepository import org.springframework.stereotype.Service interface MaterialTypeService : - ExternalNamedModelService { + ExternalNamedModelService { /** Checks if a material type with the given [prefix] exists. */ fun existsByPrefix(prefix: String): Boolean @@ -27,7 +27,7 @@ interface MaterialTypeService : @Service class MaterialTypeServiceImpl(repository: MaterialTypeRepository, private val materialService: MaterialService) : - AbstractExternalNamedModelService( + AbstractExternalNamedModelService( repository ), MaterialTypeService { override fun idNotFoundException(id: Long) = materialTypeIdNotFoundException(id) @@ -35,6 +35,8 @@ class MaterialTypeServiceImpl(repository: MaterialTypeRepository, private val ma override fun nameNotFoundException(name: String) = materialTypeNameNotFoundException(name) override fun nameAlreadyExistsException(name: String) = materialTypeNameAlreadyExistsException(name) + override fun MaterialType.toOutput() = this + override fun existsByPrefix(prefix: String): Boolean = repository.existsByPrefix(prefix) override fun isUsedByMaterial(materialType: MaterialType): Boolean = materialService.existsByMaterialType(materialType) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMaterialService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMaterialService.kt index 636196d..92339e8 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMaterialService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMaterialService.kt @@ -3,8 +3,8 @@ package dev.fyloz.colorrecipesexplorer.service import dev.fyloz.colorrecipesexplorer.exception.RestException import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.repository.MixMaterialRepository -import dev.fyloz.colorrecipesexplorer.service.utils.findDuplicated -import dev.fyloz.colorrecipesexplorer.service.utils.hasGaps +import dev.fyloz.colorrecipesexplorer.utils.findDuplicated +import dev.fyloz.colorrecipesexplorer.utils.hasGaps import org.springframework.context.annotation.Lazy import org.springframework.http.HttpStatus import org.springframework.stereotype.Service @@ -28,31 +28,40 @@ interface MixMaterialService : ModelService * If any of those criteria are not met, an [InvalidGroupStepsPositionsException] will be thrown. */ fun validateMixMaterials(mixMaterials: Set) + + fun MixMaterial.toOutput(): MixMaterialOutputDto } @Service class MixMaterialServiceImpl( - mixMaterialRepository: MixMaterialRepository, - @Lazy val materialService: MaterialService + mixMaterialRepository: MixMaterialRepository, + @Lazy val materialService: MaterialService ) : AbstractModelService(mixMaterialRepository), MixMaterialService { override fun idNotFoundException(id: Long) = mixMaterialIdNotFoundException(id) override fun idAlreadyExistsException(id: Long) = mixMaterialIdAlreadyExistsException(id) + 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() + mixMaterials.map(::create).toSet() override fun create(mixMaterial: MixMaterialDto): MixMaterial = - mixMaterial( - material = materialService.getById(mixMaterial.materialId), - quantity = mixMaterial.quantity, - position = mixMaterial.position - ) + 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 - }) + update(mixMaterial.apply { + this.quantity = quantity + }) override fun validateMixMaterials(mixMaterials: Set) { if (mixMaterials.isEmpty()) return @@ -63,17 +72,17 @@ class MixMaterialServiceImpl( // Check if the first mix material position is 1 fun isFirstMixMaterialPositionInvalid() = - sortedMixMaterials[0].position != 1 + sortedMixMaterials[0].position != 1 // Check if the first mix material is expressed in percents fun isFirstMixMaterialPercentages() = - sortedMixMaterials[0].material.materialType!!.usePercentages + sortedMixMaterials[0].material.materialType!!.usePercentages // Check if any positions is duplicated fun getDuplicatedPositionsErrors() = - sortedMixMaterials - .findDuplicated { it.position } - .map { duplicatedMixMaterialsPositions(it) } + sortedMixMaterials + .findDuplicated { it.position } + .map { duplicatedMixMaterialsPositions(it) } // Find all errors and throw if there is any if (isFirstMixMaterialPositionInvalid()) errors += invalidFirstMixMaterialPosition(sortedMixMaterials[0]) @@ -90,32 +99,32 @@ class MixMaterialServiceImpl( } class InvalidMixMaterialsPositionsError( - val type: String, - val details: String + val type: String, + val details: String ) class InvalidMixMaterialsPositionsException( - val errors: Set + 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 - ) + "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 + 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 - ) + "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" @@ -123,20 +132,20 @@ 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" - ) + 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" - ) + 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" - ) + InvalidMixMaterialsPositionsError( + GAP_BETWEEN_MIX_MATERIALS_POSITIONS_ERROR_CODE, + "There is a gap between mix materials positions" + ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixService.kt index 8416417..af2217c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixService.kt @@ -2,12 +2,12 @@ package dev.fyloz.colorrecipesexplorer.service import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.repository.MixRepository -import dev.fyloz.colorrecipesexplorer.service.utils.setAll +import dev.fyloz.colorrecipesexplorer.utils.setAll import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Service import javax.transaction.Transactional -interface MixService : ExternalModelService { +interface MixService : ExternalModelService { /** Gets all mixes with the given [mixType]. */ fun getAllByMixType(mixType: MixType): Collection @@ -23,19 +23,30 @@ interface MixService : ExternalModelService(mixRepository), - MixService { + 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) override fun getAllByMixType(mixType: MixType): Collection = repository.findAllByMixType(mixType) override fun mixTypeIsShared(mixType: MixType): Boolean = getAllByMixType(mixType).count() > 1 + 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) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt index 7df7296..4ace03b 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt @@ -4,15 +4,15 @@ import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.model.validation.or import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository import dev.fyloz.colorrecipesexplorer.service.files.FileService -import dev.fyloz.colorrecipesexplorer.service.utils.setAll +import dev.fyloz.colorrecipesexplorer.utils.setAll import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Service import org.springframework.web.multipart.MultipartFile import java.io.File -import java.nio.file.NoSuchFileException import javax.transaction.Transactional -interface RecipeService : ExternalModelService { +interface RecipeService : + ExternalModelService { /** Checks if one or more recipes have the given [company]. */ fun existsByCompany(company: Company): Boolean @@ -31,17 +31,41 @@ interface RecipeService : ExternalModelService(recipeRepository), - RecipeService { + AbstractExternalModelService( + recipeRepository + ), + RecipeService { override fun idNotFoundException(id: Long) = recipeIdNotFoundException(id) override fun idAlreadyExistsException(id: Long) = recipeIdAlreadyExistsException(id) + override fun Recipe.toOutput() = RecipeOutputDto( + this.id!!, + this.name, + this.description, + this.color, + this.gloss, + this.sample, + this.approbationDate, + this.remark, + this.company, + this.mixes.map { + with(mixService) { + it.toOutput() + } + }.toSet(), + this.groupsInformation, + recipeImageService.getAllImages(this) + .map { this.imageUrl(it) } + .toSet() + ) + override fun existsByCompany(company: Company): Boolean = repository.existsByCompany(company) override fun getAllByCompany(company: Company): Collection = repository.findAllByCompany(company) @@ -49,14 +73,14 @@ class RecipeServiceImpl( // TODO checks if name is unique in the scope of the [company] return save(with(entity) { recipe( - name = name, - description = description, - color = color, - gloss = gloss, - sample = sample, - approbationDate = approbationDate, - remark = remark ?: "", - company = companyService.getById(companyId) + name = name, + description = description, + color = color, + gloss = gloss, + sample = sample, + approbationDate = approbationDate, + remark = remark ?: "", + company = companyService.getById(companyId) ) }) } @@ -67,17 +91,17 @@ class RecipeServiceImpl( return update(with(entity) { recipe( - id = id, - name = name or persistedRecipe.name, - description = description or persistedRecipe.description, - color = color or persistedRecipe.color, - gloss = gloss ?: persistedRecipe.gloss, - sample = sample ?: persistedRecipe.sample, - approbationDate = approbationDate ?: persistedRecipe.approbationDate, - remark = remark or persistedRecipe.remark, - company = persistedRecipe.company, - mixes = persistedRecipe.mixes, - groupsInformation = updateGroupsInformation(persistedRecipe, entity) + id = id, + name = name or persistedRecipe.name, + description = description or persistedRecipe.description, + color = color or persistedRecipe.color, + gloss = gloss ?: persistedRecipe.gloss, + sample = sample ?: persistedRecipe.sample, + approbationDate = approbationDate ?: persistedRecipe.approbationDate, + remark = remark or persistedRecipe.remark, + company = persistedRecipe.company, + mixes = persistedRecipe.mixes, + groupsInformation = updateGroupsInformation(persistedRecipe, entity) ) }) } @@ -96,8 +120,8 @@ class RecipeServiceImpl( this.steps = it.steps.toMutableSet() } } ?: recipeGroupInformation( - group = groupService.getById(it.groupId), - steps = it.steps.toMutableSet() + group = groupService.getById(it.groupId), + steps = it.steps.toMutableSet() ) updatedGroupsInformation.add(updatedGroupInformation) @@ -114,7 +138,7 @@ class RecipeServiceImpl( val recipe = getById(publicDataDto.recipeId) fun noteForGroup(group: EmployeeGroup) = - publicDataDto.notes.firstOrNull { it.groupId == group.id }?.content + publicDataDto.notes.firstOrNull { it.groupId == group.id }?.content // Notes recipe.groupsInformation.map { @@ -133,68 +157,74 @@ class RecipeServiceImpl( } override fun addMix(recipe: Recipe, mix: Mix) = - update(recipe.apply { mixes.add(mix) }) + update(recipe.apply { mixes.add(mix) }) override fun removeMix(mix: Mix): Recipe = - update(mix.recipe.apply { mixes.remove(mix) }) + update(mix.recipe.apply { mixes.remove(mix) }) } -const val RECIPE_IMAGES_DIRECTORY = "images/recipe" - interface RecipeImageService { - fun getByIdForRecipe(id: Long, recipeId: Long): ByteArray + /** Gets the name of every images associated to the recipe with the given [recipe]. */ + fun getAllImages(recipe: Recipe): Set - /** Gets the identifier of every images associated to the recipe with the given [recipeId]. */ - fun getAllIdsForRecipe(recipeId: Long): Collection + /** 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 - /** Saves the given [image] and associate it to the recipe with the given [recipeId]. Returns the identifier of the saved image. */ - fun save(image: MultipartFile, recipeId: Long): Long + /** Deletes the image with the given [name] for the given [recipe]. */ + fun delete(recipe: Recipe, name: String) - /** Deletes the image with the given [recipeId] and [id]. */ - fun delete(id: Long, recipeId: Long) + /** 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 -class RecipeImageServiceImpl(val recipeService: RecipeService, val fileService: FileService) : RecipeImageService { - override fun getByIdForRecipe(id: Long, recipeId: Long): ByteArray = - try { - fileService.readAsBytes(getPath(id, recipeId)) - } catch (ex: NoSuchFileException) { - throw RecipeImageNotFoundException(id, recipeService.getById(recipeId)) - } - - override fun getAllIdsForRecipe(recipeId: Long): Collection { - val recipe = recipeService.getById(recipeId) - val recipeDirectory = getRecipeDirectory(recipe.id!!) +class RecipeImageServiceImpl( + val fileService: FileService +) : RecipeImageService { + override fun getAllImages(recipe: Recipe): Set { + val recipeDirectory = recipe.getDirectory() if (!recipeDirectory.exists() || !recipeDirectory.isDirectory) { - return listOf() + return setOf() } - return recipeDirectory.listFiles()!! // Should never be null because we check if recipeDirectory is a directory and exists before - .filterNotNull() - .map { it.name.toLong() } + 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 save(image: MultipartFile, recipeId: Long): Long { - /** Gets the next id available for a new image for the recipe with the given [recipeId]. */ + 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(getAllIdsForRecipe(recipeId)) { - if (isEmpty()) - 0 - else - maxOrNull()!! + 1L // maxOrNull() cannot return null because existingIds cannot be empty at this point - } + with(getAllImages(recipe)) { + if (isEmpty()) + 0 + else + maxOf { + it.split(RECIPE_IMAGE_ID_DELIMITER) + .last() + .replace(RECIPE_IMAGE_EXTENSION, "") + .toLong() + } + 1L + } - val nextAvailableId = getNextAvailableId() - fileService.write(image, getPath(nextAvailableId, recipeId)) - return nextAvailableId + return getImageFileName(recipe, getNextAvailableId()).apply { + fileService.write(image, getImagePath(recipe, this), true) + } } - override fun delete(id: Long, recipeId: Long) = - fileService.delete(getPath(id, recipeId)) + override fun delete(recipe: Recipe, name: String) = + fileService.delete(getImagePath(recipe, name)) - /** Gets the images directory of the recipe with the given [recipeId]. */ - fun getRecipeDirectory(recipeId: Long) = File(fileService.getPath("$RECIPE_IMAGES_DIRECTORY/$recipeId")) + override fun Recipe.getDirectory(): File = File(with(fileService) { + this@getDirectory.imagesDirectoryPath.fullPath().path + }) - /** Gets the file of the image with the given [recipeId] and [id]. */ - fun getPath(id: Long, recipeId: Long): String = fileService.getPath("$RECIPE_IMAGES_DIRECTORY/$recipeId/$id") + 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" } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeStepService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeStepService.kt index 44ba176..a72fc76 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeStepService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeStepService.kt @@ -3,8 +3,8 @@ package dev.fyloz.colorrecipesexplorer.service import dev.fyloz.colorrecipesexplorer.exception.RestException import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.repository.RecipeStepRepository -import dev.fyloz.colorrecipesexplorer.service.utils.findDuplicated -import dev.fyloz.colorrecipesexplorer.service.utils.hasGaps +import dev.fyloz.colorrecipesexplorer.utils.findDuplicated +import dev.fyloz.colorrecipesexplorer.utils.hasGaps import org.springframework.http.HttpStatus import org.springframework.stereotype.Service diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/Service.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/Service.kt index ef45f8e..902a32c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/Service.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/Service.kt @@ -62,7 +62,7 @@ abstract class AbstractService>(override val reposito } abstract class AbstractModelService>(repository: R) : - AbstractService(repository), ModelService { + AbstractService(repository), ModelService { protected abstract fun idNotFoundException(id: Long): NotFoundException protected abstract fun idAlreadyExistsException(id: Long): AlreadyExistsException @@ -83,7 +83,7 @@ abstract class AbstractModelService>(repos } override fun deleteById(id: Long) = - delete(getById(id)) // Use delete(entity) to prevent code duplication and to ease testing + 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") @@ -91,7 +91,7 @@ abstract class AbstractModelService>(repos } abstract class AbstractNamedModelService>(repository: R) : - AbstractModelService(repository), NamedModelService { + AbstractModelService(repository), NamedModelService { protected abstract fun nameNotFoundException(name: String): NotFoundException protected abstract fun nameAlreadyExistsException(name: String): AlreadyExistsException @@ -126,33 +126,57 @@ abstract class AbstractNamedModelService, U : EntityDto, R : JpaRepository> : Service { +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 = update(entity.toEntity()) + 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, R : JpaRepository> : - ModelService, ExternalService +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, R : JpaRepository> : - NamedModelService, ExternalModelService +interface ExternalNamedModelService, U : EntityDto, O, R : JpaRepository> : + NamedModelService, ExternalModelService /** An [AbstractService] with the functionalities of a [ExternalService]. */ @Suppress("unused") -abstract class AbstractExternalService, U : EntityDto, R : JpaRepository>(repository: R) : - AbstractService(repository), ExternalService +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, R : JpaRepository>( - repository: R -) : AbstractModelService(repository), ExternalModelService +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, R : NamedJpaRepository>( - repository: R -) : AbstractNamedModelService(repository), ExternalNamedModelService +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() +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileService.kt index c6c11fd..b514a93 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileService.kt @@ -1,83 +1,215 @@ package dev.fyloz.colorrecipesexplorer.service.files import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties +import dev.fyloz.colorrecipesexplorer.exception.RestException import org.slf4j.Logger -import org.springframework.core.io.ResourceLoader +import org.springframework.core.io.ByteArrayResource +import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.web.multipart.MultipartFile import java.io.File import java.io.IOException -import java.io.InputStream -import java.nio.charset.StandardCharsets import java.nio.file.Files -@Service -class FileService( - private val resourcesLoader: ResourceLoader, - private val creProperties: CreProperties, - private val logger: Logger -) { - /** Reads the resource at the given [path] as a [String]. */ - fun readResource(path: String): String = try { - resourcesLoader.getResource("classpath:$path").inputStream.use { - readInputStreamAsString(it) - } - } catch (ex: IOException) { - logger.error("Could not read resource", ex) - "" - } +/** Banned path shards. These are banned because they can allow access to files outside the data directory. */ +val BANNED_FILE_PATH_SHARDS = setOf( + "~", + "..", + "//" +) - /** Reads the given [stream] as a [String]. */ - fun readInputStreamAsString(stream: InputStream) = with(stream.readAllBytes()) { - String(this, StandardCharsets.UTF_8) - } +interface FileService { + /** Checks if the file at the given [path] exists. */ + fun exists(path: String): Boolean - /** Reads the file at the given [path] as a [ByteArray]. */ - fun readAsBytes(path: String) = - withFileAt(path) { this.readBytes() } + /** Reads the file at the given [path]. */ + fun read(path: String): ByteArrayResource - /** Writes the given [multipartFile] to the file at the given [path]. */ - fun write(multipartFile: MultipartFile, path: String): Boolean = - if (multipartFile.size <= 0) true - else try { - multipartFile.transferTo(create(path).toPath()) - true - } catch (ex: IOException) { - logger.error("Unable to write multipart file", ex) - false - } + /** Creates a file at the given [path]. */ + fun create(path: String) - /** Creates a new file at the given [path]. If the file already exists, nothing will be done. */ - fun create(path: String) = withFileAt(path) { - if (!exists(path)) { - try { - Files.createDirectories(this.parentFile.toPath()) - Files.createFile(this.toPath()) - } catch (ex: IOException) { - logger.error("Unable to create file", ex) - } - } - this - } + /** Writes the given [file] to the given [path]. If the file already exists, it will be overwritten if [overwrite] is enabled. */ + fun write(file: MultipartFile, path: String, overwrite: Boolean) + + /** 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) /** Deletes the file at the given [path]. */ - fun delete(path: String) = withFileAt(path) { - try { - if (exists(path)) Files.delete(this.toPath()) - } catch (ex: IOException) { - logger.error("Unable to delete file", ex) - } - } + fun delete(path: String) - /** Checks if a file with the given [path] exists on the disk. */ - fun exists(path: String): Boolean = withFileAt(path) { + /** Completes the path of the given [String] by adding the working directory. */ + fun String.fullPath(): FilePath +} + +@Service +class FileServiceImpl( + private val creProperties: CreProperties, + private val logger: Logger +) : FileService { + override fun exists(path: String) = withFileAt(path.fullPath()) { this.exists() && this.isFile } - /** Runs the given [block] in the context of a file with the given [path]. */ - fun withFileAt(path: String, block: File.() -> T) = - File(path).block() + override fun read(path: String) = ByteArrayResource( + withFileAt(path.fullPath()) { + if (!exists(path)) throw FileNotFoundException(path) + try { + readBytes() + } catch (ex: IOException) { + FileReadException(path).logAndThrow(ex, logger) + } + } + ) - fun getPath(fileName: String): String = - "${creProperties.workingDirectory}/$fileName" + override fun create(path: String) { + val fullPath = path.fullPath() + if (!exists(path)) { + try { + withFileAt(fullPath) { + this.create() + } + } catch (ex: IOException) { + FileCreateException(path).logAndThrow(ex, logger) + } + } + } + + override fun write(file: MultipartFile, path: String, overwrite: Boolean) = + prepareWrite(path, overwrite) { + file.transferTo(this.toPath()) + } + + override fun write(data: ByteArrayResource, path: String, overwrite: Boolean) = + prepareWrite(path, overwrite) { + this.writeBytes(data.byteArray) + } + + override fun delete(path: String) { + try { + withFileAt(path.fullPath()) { + if (!exists(path)) throw FileNotFoundException(path) + !this.delete() + } + } catch (ex: IOException) { + FileDeleteException(path).logAndThrow(ex, logger) + } + } + + override fun String.fullPath(): FilePath { + BANNED_FILE_PATH_SHARDS + .firstOrNull { this.contains(it) } + ?.let { throw InvalidFilePathException(this, it) } + + return FilePath("${creProperties.workingDirectory}/$this") + } + + private fun prepareWrite(path: String, overwrite: Boolean, op: File.() -> Unit) { + val fullPath = path.fullPath() + + if (exists(path)) { + if (!overwrite) throw FileExistsException(path) + } else { + create(path) + } + + try { + withFileAt(fullPath) { + this.op() + } + } 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 const val FILE_IO_EXCEPTION_TITLE = "File IO error" + +class InvalidFilePathException(val path: String, val fragment: String) : + RestException( + "invalid-filepath", + "Invalid file path", + HttpStatus.BAD_REQUEST, + "The given path is invalid because it contains a potentially malicious String '$fragment'", + mapOf( + "path" to path, + "invalidString" to fragment + ) + ) + +class FileExistsException(val path: String) : + RestException( + "io-exists", + FILE_IO_EXCEPTION_TITLE, + HttpStatus.BAD_REQUEST, + "Could not write file to '$path' because it already exists. To overwrite the file set the overwrite parameter to true", + pathMap(path) + ) + +class FileNotFoundException(val path: String) : + RestException( + "io-notfound", + FILE_IO_EXCEPTION_TITLE, + HttpStatus.NOT_FOUND, + "Could not access file at '$path' because it does not exists", + pathMap(path) + ) + +sealed class FileIOException(type: String, details: String, val path: String) : + RestException( + "io-$type", + FILE_IO_EXCEPTION_TITLE, + HttpStatus.INTERNAL_SERVER_ERROR, + details, + pathMap(path) + ) + +class FileReadException(path: String) : + FileIOException( + "read", + "Could not read file at '$path'", + path + ) + +class FileWriteException(path: String) : + FileIOException( + "write", + "Could not write file to '$path'", + path + ) + +class FileCreateException(path: String) : + FileIOException( + "create", + "Could not create file at '$path'", + path + ) + +class FileDeleteException(path: String) : + FileIOException( + "delete", + "Could not delete file at '$path'", + path + ) + +private fun pathMap(path: String) = + mapOf("path" to path) + +private fun T.logAndThrow(baseException: Exception, logger: Logger): Nothing { + logger.error(this.details, baseException) + throw this } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutService.kt deleted file mode 100644 index e13c501..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutService.kt +++ /dev/null @@ -1,66 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service.files - -import dev.fyloz.colorrecipesexplorer.exception.RestException -import dev.fyloz.colorrecipesexplorer.model.Material -import org.slf4j.Logger -import org.springframework.http.HttpStatus -import org.springframework.stereotype.Service -import org.springframework.web.multipart.MultipartFile -import java.io.IOException - -const val SIMDUT_DIRECTORY = "simdut" - -@Service -class SimdutService( - private val fileService: FileService, - private val logger: Logger -) { - /** Checks if the given [material] has a SIMDUT file. */ - fun exists(material: Material) = - fileService.exists(getPath(material)) - - /** Reads the SIMDUT file of the given [material]. */ - fun read(material: Material): ByteArray { - val path = getPath(material) - if (!fileService.exists(path)) return ByteArray(0) - - return try { - fileService.readAsBytes(path) - } catch (ex: IOException) { - logger.error("Could not read SIMDUT file", ex) - ByteArray(0) - } - } - - /** Writes the given [simdut] file for the given [material] to the disk. */ - fun write(material: Material, simdut: MultipartFile) { - if (!fileService.write(simdut, getPath(material))) - throw SimdutWriteException(material) - } - - /** Updates the SIMDUT file of the given [material] with the given [simdut]. */ - fun update(simdut: MultipartFile, material: Material) { - delete(material) - write(material, simdut) - } - - /** Deletes the SIMDUT file of the given [material]. */ - fun delete(material: Material) = - fileService.delete(getPath(material)) - - /** Gets the path of the SIMDUT file of the given [material]. */ - fun getPath(material: Material) = - fileService.getPath("$SIMDUT_DIRECTORY/${getSimdutFileName(material)}") - - /** Gets the name of the SIMDUT file of the given [material]. */ - fun getSimdutFileName(material: Material) = - material.id.toString() -} - -class SimdutWriteException(material: Material) : - RestException( - "simdut-write", - "Could not write SIMDUT file", - HttpStatus.INTERNAL_SERVER_ERROR, - "Could not write the SIMDUT file for the material ${material.name} to the disk" - ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/TouchUpKitService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/TouchUpKitService.kt new file mode 100644 index 0000000..68b043e --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/TouchUpKitService.kt @@ -0,0 +1,78 @@ +package dev.fyloz.colorrecipesexplorer.service.files + +import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties +import dev.fyloz.colorrecipesexplorer.utils.* +import org.springframework.core.io.ByteArrayResource +import org.springframework.stereotype.Service + +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 { + /** 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 [CreProperties.cacheGeneratedFiles] 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): ByteArrayResource + + /** Writes the given [document] to the [FileService] if [CreProperties.cacheGeneratedFiles] is enabled. */ + fun String.cachePdfDocument(document: PdfDocument) +} + +@Service +class TouchUpKitServiceImpl( + private val fileService: FileService, + private val creProperties: CreProperties +) : TouchUpKitService { + 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): ByteArrayResource { + if (creProperties.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 (!creProperties.cacheGeneratedFiles) return + + fileService.write(document.toByteArrayResource(), this.pdfDocumentPath(), true) + } + + private fun String.pdfDocumentPath() = + "$TOUCH_UP_KIT_FILES_PATH/$this.pdf" +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/utils/Collections.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Collections.kt similarity index 96% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/utils/Collections.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Collections.kt index f461138..c6d4b9c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/utils/Collections.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Collections.kt @@ -1,4 +1,4 @@ -package dev.fyloz.colorrecipesexplorer.service.utils +package dev.fyloz.colorrecipesexplorer.utils /** 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( diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Pdf.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Pdf.kt new file mode 100644 index 0000000..a9f1387 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Pdf.kt @@ -0,0 +1,125 @@ +package dev.fyloz.colorrecipesexplorer.utils + +import org.apache.pdfbox.pdmodel.PDDocument +import org.apache.pdfbox.pdmodel.PDPage +import org.apache.pdfbox.pdmodel.PDPageContentStream +import org.apache.pdfbox.pdmodel.font.PDFont +import org.apache.pdfbox.pdmodel.font.PDType1Font +import org.springframework.core.io.ByteArrayResource +import java.io.ByteArrayOutputStream + +val PDF_DEFAULT_FONT: PDType1Font = PDType1Font.HELVETICA +val PDF_DEFAULT_FONT_BOLD: PDType1Font = PDType1Font.HELVETICA_BOLD +const val PDF_DEFAULT_FONT_SIZE = 42f +val PDF_DASH_LINE_PATTERN = floatArrayOf(4f) + +/** Creates a [PdfContainer] and apply the given [block]. */ +fun pdf(block: PdfDocument.() -> Unit = {}) = + PdfDocument().apply { block() } + +/** Creates a [PdfContainer] in the given [PdfDocument] and apply the given [block]. If a [container] is given, the receiver of the block will be a clone of it. */ +fun PdfDocument.container(container: PdfContainer = PdfContainer(), block: PdfContainer.() -> Unit) { + this.containers += PdfContainer(container).apply(block) +} + +/** Creates a [PdfText] with the given [text] in the given [PdfContainer] and apply the given [block]. */ +fun PdfContainer.text(text: String, block: PdfText.() -> Unit) { + this.texts += PdfText(text = text).apply(block) +} + +fun PdfDocument.toByteArrayResource(): ByteArrayResource = PDDocument().use { document -> + val page = PDPage() + + document.addPage(page) + + fun PDPageContentStream.drawText(text: PdfText, y: Float) { + val font = if (text.bold) fontBold else font + val textWidth = font.getStringWidth(text.text) / 1000 * text.fontSize + val textX = (page.mediaBox.width - textWidth) / 2f + + beginText() + newLineAtOffset(textX, y) + setFont(font, text.fontSize) + showText(text.text) + endText() + } + + fun PDPageContentStream.drawDashLine(y: Float) { + moveTo(0f, y) + lineTo(page.mediaBox.width, y) + setLineDashPattern(PDF_DASH_LINE_PATTERN, 0f) + stroke() + } + + fun PDPageContentStream.drawContainer(container: PdfContainer, y: Float, height: Float) { + var textY = y + + if (container.centeredVertically) { + val textsHeight = container.texts + .map { it.fontSize + it.marginTop } + .reduce { acc, textHeight -> acc + textHeight } + textY -= (height - textsHeight) / 2f + } + + if (container.drawContainerBottom) { + this.drawDashLine(y - height) + } + + container.texts.forEach { text -> + textY -= text.fontSize + text.marginTop + this.drawText(text, textY) + } + } + + PDPageContentStream(document, page).use { + var containerY = page.mediaBox.height + + val computedSizeContainerCount = containers + .filter { it.height < 0 } + .count() + val computedSizeContainersHeight = containerY / computedSizeContainerCount + + containers.forEach { container -> + val height = if (container.height < 0) + computedSizeContainersHeight + else + container.height + + it.drawContainer(container, containerY, height) + + containerY -= height + } + } + + ByteArrayOutputStream().use { + document.save(it) + ByteArrayResource(it.toByteArray()) + } +} + +data class PdfDocument( + var font: PDFont = PDF_DEFAULT_FONT, + var fontBold: PDFont = PDF_DEFAULT_FONT_BOLD, + val containers: MutableList = mutableListOf() +) + +data class PdfContainer( + var height: Float = -1f, + var centeredVertically: Boolean = false, + var drawContainerBottom: Boolean = false, + val texts: MutableList = mutableListOf() +) { + constructor(container: PdfContainer) : this( + container.height, + container.centeredVertically, + container.drawContainerBottom, + container.texts + ) +} + +data class PdfText( + var text: String = "Text", + var bold: Boolean = false, + var fontSize: Float = PDF_DEFAULT_FONT_SIZE, + var marginTop: Float = 0f +) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 492010d..74dd17a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,6 +2,8 @@ server.port=9090 # CRE cre.server.working-directory=data +cre.server.deployment-url=http://localhost:9090 +cre.server.cache-generated-files=true cre.security.jwt-secret=CtnvGQjgZ44A1fh295gE cre.security.jwt-duration=18000000 # Root user diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AbstractServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AbstractServiceTest.kt index 0574fee..8d7f95c 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AbstractServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AbstractServiceTest.kt @@ -27,8 +27,8 @@ abstract class AbstractServiceTest, R : JpaRepository protected val entityList: List get() = listOf( - entity, - anotherEntity + entity, + anotherEntity ) @AfterEach @@ -91,7 +91,7 @@ abstract class AbstractServiceTest, R : JpaRepository } abstract class AbstractModelServiceTest, R : JpaRepository> : - AbstractServiceTest1() { + AbstractServiceTest1() { // existsById() @@ -129,7 +129,7 @@ abstract class AbstractModelServiceTest, R : J whenever(repository.findById(entity.id!!)).doReturn(Optional.empty()) assertThrows { service.getById(entity.id!!) } - .assertErrorCode() + .assertErrorCode() } // save() @@ -139,7 +139,7 @@ abstract class AbstractModelServiceTest, R : J doReturn(true).whenever(service).existsById(entity.id!!) assertThrows { service.save(entity) } - .assertErrorCode() + .assertErrorCode() } // update() @@ -161,7 +161,7 @@ abstract class AbstractModelServiceTest, R : J doReturn(false).whenever(service).existsById(entity.id!!) assertThrows { service.update(entity) } - .assertErrorCode() + .assertErrorCode() } // deleteById() @@ -177,7 +177,7 @@ abstract class AbstractModelServiceTest, R : J } abstract class AbstractNamedModelServiceTest, R : NamedJpaRepository> : - AbstractModelServiceTest() { + AbstractModelServiceTest() { protected abstract val entityWithEntityName: E // existsByName() @@ -216,7 +216,7 @@ abstract class AbstractNamedModelServiceTest { service.getByName(entity.name) } - .assertErrorCode("name") + .assertErrorCode("name") } // save() @@ -226,7 +226,7 @@ abstract class AbstractNamedModelServiceTest { service.save(entity) } - .assertErrorCode("name") + .assertErrorCode("name") } // update() @@ -258,7 +258,7 @@ abstract class AbstractNamedModelServiceTest { service.update(entity) } - .assertErrorCode("name") + .assertErrorCode("name") } } @@ -269,8 +269,8 @@ interface ExternalModelServiceTest { // ==== 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 { +abstract class AbstractExternalModelServiceTest, U : EntityDto, S : ExternalModelService, R : JpaRepository> : + AbstractModelServiceTest(), ExternalModelServiceTest { protected abstract val entitySaveDto: N protected abstract val entityUpdateDto: U @@ -281,8 +281,8 @@ abstract class AbstractExternalModelServiceTest, U : } } -abstract class AbstractExternalNamedModelServiceTest, U : EntityDto, S : ExternalNamedModelService, R : NamedJpaRepository> : - AbstractNamedModelServiceTest(), ExternalModelServiceTest { +abstract class AbstractExternalNamedModelServiceTest, U : EntityDto, S : ExternalNamedModelService, R : NamedJpaRepository> : + AbstractNamedModelServiceTest(), ExternalModelServiceTest { protected abstract val entitySaveDto: N protected abstract val entityUpdateDto: U @@ -294,10 +294,10 @@ abstract class AbstractExternalNamedModelServiceTest> withBaseSaveDtoTest( - entity: E, - entitySaveDto: N, - service: ExternalService, - saveMockMatcher: () -> E = { entity }, - op: () -> Unit = {} + entity: E, + entitySaveDto: N, + service: ExternalService, + saveMockMatcher: () -> E = { entity }, + op: () -> Unit = {} ) { doReturn(entity).whenever(service).save(saveMockMatcher()) doReturn(entity).whenever(entitySaveDto).toEntity() @@ -329,11 +329,11 @@ fun > withBaseSaveDtoTest( } fun > withBaseUpdateDtoTest( - entity: E, - entityUpdateDto: U, - service: ExternalModelService, - updateMockMatcher: () -> E, - op: E.() -> Unit = {} + entity: E, + entityUpdateDto: U, + service: ExternalModelService, + updateMockMatcher: () -> E, + op: E.() -> Unit = {} ) { doAnswer { it.arguments[0] }.whenever(service).update(updateMockMatcher()) doReturn(entity).whenever(entityUpdateDto).toEntity() diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt index 9def652..dc13ca3 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt @@ -4,7 +4,7 @@ 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.SimdutService +import dev.fyloz.colorrecipesexplorer.service.files.FileService import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -14,16 +14,17 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class MaterialServiceTest : - AbstractExternalNamedModelServiceTest() { + AbstractExternalNamedModelServiceTest() { override val repository: MaterialRepository = mock() - private val simdutService: SimdutService = mock() private val recipeService: RecipeService = mock() private val mixService: MixService = mock() private val materialTypeService: MaterialTypeService = mock() + private val fileService: FileService = mock() override val service: MaterialService = - spy(MaterialServiceImpl(repository, simdutService, recipeService, mixService, materialTypeService)) + spy(MaterialServiceImpl(repository, recipeService, mixService, materialTypeService, fileService)) 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()) @@ -33,7 +34,7 @@ class MaterialServiceTest : @AfterEach override fun afterEach() { - reset(simdutService) + reset(recipeService, mixService, materialTypeService, fileService) super.afterEach() } @@ -61,20 +62,20 @@ class MaterialServiceTest : @Test fun `hasSimdut() returns false when simdutService_exists() returns false`() { - whenever(simdutService.exists(entity)).doReturn(false) + whenever(fileService.exists(any())).doReturn(false) doReturn(entity).whenever(service).getById(entity.id!!) - val found = service.hasSimdut(entity.id!!) + val found = service.hasSimdut(entity) assertFalse(found) } @Test fun `hasSimdut() returns true when simdutService_exists() returns true`() { - whenever(simdutService.exists(entity)).doReturn(true) + whenever(fileService.exists(any())).doReturn(true) doReturn(entity).whenever(service).getById(entity.id!!) - val found = service.hasSimdut(entity.id!!) + val found = service.hasSimdut(entity) assertTrue(found) } @@ -83,42 +84,16 @@ class MaterialServiceTest : @Test fun `getAllNotMixType() returns a list containing every material that are not a mix type`() { - val mixTypeMaterial = material(name = "mix type material", isMixType = true) + 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(entity)) - assertFalse(found.contains(mixTypeMaterial)) - } - - // getAllIdsWithSimdut() - - @Test - fun `getAllIdsWithSimdut() returns a list containing the identifier of every material with a SIMDUT file`() { - val materials = listOf( - material(id = 0L), - material(id = 1L), - material(id = 2L), - material(id = 3L) - ) - val hasSimdut = mapOf( - *materials - .map { it.id!! to it.evenId } - .toTypedArray() - ) - val expectedIds = hasSimdut - .filter { it.value } - .map { it.key } - - whenever(simdutService.exists(any())).doAnswer { hasSimdut[(it.arguments[0] as Material).id] } - doReturn(materials).whenever(service).getAllNotMixType() - - val found = service.getAllIdsWithSimdut() - - assertEquals(expectedIds, found) + assertTrue(found.contains(entityOutput)) + assertFalse(found.contains(mixTypeMaterialOutput)) } // save() @@ -128,7 +103,7 @@ class MaterialServiceTest : doReturn(true).whenever(service).existsByName(entity.name) assertThrows { service.save(entity) } - .assertErrorCode("name") + .assertErrorCode("name") } @Test @@ -146,7 +121,7 @@ class MaterialServiceTest : service.save(materialSaveDto) - verify(simdutService).write(entity, mockMultipartFile) + verify(fileService).write(mockMultipartFile, entity.simdutFilePath, false) } // update() @@ -160,7 +135,21 @@ class MaterialServiceTest : doReturn(entity).whenever(service).getById(material.id!!) assertThrows { service.update(material) } - .assertErrorCode("name") + .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() @@ -186,16 +175,16 @@ class MaterialServiceTest : 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)))) + 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(normalMaterial in found) - assertTrue(mixTypeMaterial in found) - assertFalse(anotherMixTypeMaterial in found) + assertTrue(materialOutputDto(normalMaterial) in found) + assertTrue(materialOutputDto(mixTypeMaterial) in found) + assertFalse(materialOutputDto(anotherMixTypeMaterial) in found) } // getAllForMixUpdate() @@ -215,25 +204,9 @@ class MaterialServiceTest : val found = service.getAllForMixUpdate(mix.id!!) - assertTrue(normalMaterial in found) - assertTrue(mixTypeMaterial in found) - assertFalse(anotherMixTypeMaterial in found) - } - - // update() - - @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(materialUpdateDto.id) - doReturn(entity).whenever(service).update(any()) - doReturn(entity).whenever(materialUpdateDto).toEntity() - - service.update(materialUpdateDto) - - verify(simdutService).update(eq(mockSimdutFile), any()) + assertTrue(materialOutputDto(normalMaterial) in found) + assertTrue(materialOutputDto(mixTypeMaterial) in found) + assertFalse(materialOutputDto(anotherMixTypeMaterial) in found) } @@ -262,4 +235,13 @@ class MaterialServiceTest : 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/MixServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MixServiceTest.kt index 22a41b0..00d1c2c 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MixServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MixServiceTest.kt @@ -113,7 +113,7 @@ class MixServiceTest : AbstractExternalModelServiceTest() { + AbstractExternalModelServiceTest() { override val repository: RecipeRepository = mock() private val companyService: CompanyService = mock() private val mixService: MixService = mock() private val groupService: EmployeeGroupService = mock() private val recipeStepService: RecipeStepService = mock() - override val service: RecipeService = spy(RecipeServiceImpl(repository, companyService, mixService, recipeStepService, groupService)) + override val service: RecipeService = + spy(RecipeServiceImpl(repository, companyService, mixService, recipeStepService, groupService, mock())) private val company: Company = company(id = 0L) override val entity: Recipe = recipe(id = 0L, name = "recipe", company = company) @@ -79,22 +80,22 @@ class RecipeServiceTest : @Test override fun `update(dto) calls and returns update() with the created entity`() = - withBaseUpdateDtoTest(entity, entityUpdateDto, service, { any() }) + withBaseUpdateDtoTest(entity, entityUpdateDto, service, { any() }) // updatePublicData() @Test fun `updatePublicData() updates the notes of a recipe groups information according to the RecipePublicDataDto`() { val recipe = recipe( - id = 0L, groupsInformation = setOf( - recipeGroupInformation(id = 0L, group = employeeGroup(id = 1L), note = "Old note"), - recipeGroupInformation(id = 1L, group = employeeGroup(id = 2L), note = "Another note"), - recipeGroupInformation(id = 2L, group = employeeGroup(id = 3L), note = "Up to date note") - ) + id = 0L, groupsInformation = setOf( + recipeGroupInformation(id = 0L, group = employeeGroup(id = 1L), note = "Old note"), + recipeGroupInformation(id = 1L, group = employeeGroup(id = 2L), note = "Another note"), + recipeGroupInformation(id = 2L, group = employeeGroup(id = 3L), note = "Up to date note") + ) ) val notes = setOf( - noteDto(groupId = 1, content = "Note 1"), - noteDto(groupId = 2, content = null) + noteDto(groupId = 1, content = "Note 1"), + noteDto(groupId = 2, content = null) ) val publicData = recipePublicDataDto(recipeId = recipe.id!!, notes = notes) @@ -115,10 +116,10 @@ class RecipeServiceTest : @Test fun `updatePublicData() update the location of a recipe mixes in the mix service according to the RecipePublicDataDto`() { val publicData = recipePublicDataDto( - mixesLocation = setOf( - mixLocationDto(mixId = 0L, location = "Loc 1"), - mixLocationDto(mixId = 1L, location = "Loc 2") - ) + mixesLocation = setOf( + mixLocationDto(mixId = 0L, location = "Loc 1"), + mixLocationDto(mixId = 1L, location = "Loc 2") + ) ) service.updatePublicData(publicData) @@ -163,133 +164,100 @@ class RecipeServiceTest : } } +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 = spyk(File(recipe.imagesDirectoryPath)) { + 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 { - private val recipeService: RecipeService = mock() - private val fileService: FileService = mock() - private val service = spy(RecipeImageServiceImpl(recipeService, fileService)) - - private val recipeId = 1L - private val imageId = 5L - private val imagePath = "$RECIPE_IMAGES_DIRECTORY/$recipeId/$imageId" - private val recipe = recipe(id = recipeId) - private val recipeDirectory: File = mock() - private val imagesIds = listOf(1L, 3L, 10L, 21L) - private val imageData = byteArrayOf(64, 32, 16, 8, 4, 2, 1) - private val image = MockMultipartFile("$imageId", imageData) - @AfterEach - internal fun tearDown() { - reset(recipeService, fileService, service, recipeDirectory) + internal fun afterEach() { + clearAllMocks() } - // getByIdForRecipe() + private fun test(test: RecipeImageServiceTestContext.() -> Unit) { + RecipeImageServiceTestContext().test() + } + + // getAllImages() @Test - fun `getByIdForRecipe() returns data for the given recipe and image id red by the file service`() { - whenever(fileService.getPath(imagePath)).doReturn(imagePath) - whenever(fileService.readAsBytes(imagePath)).doReturn(imageData) + fun `getAllImages() returns a Set containing the name of every files in the recipe's directory`() { + test { + val foundImagesNames = recipeImageService.getAllImages(recipe) - val found = service.getByIdForRecipe(imageId, recipeId) - - assertEquals(imageData, found) + assertEquals(recipeImagesNames, foundImagesNames) + } } @Test - fun `getByIdForRecipe() throws RecipeImageNotFoundException when no image with the given recipe and image id exists`() { - doReturn(imagePath).whenever(service).getPath(imageId, recipeId) - whenever(recipeService.getById(recipeId)).doReturn(recipe) - whenever(fileService.readAsBytes(imagePath)).doAnswer { throw NoSuchFileException(imagePath) } + fun `getAllImages() returns an empty Set when the recipe's directory does not exists`() { + test { + every { recipeDirectory.exists() } returns false - assertThrows { service.getByIdForRecipe(imageId, recipeId) } + assertTrue { + recipeImageService.getAllImages(recipe).isEmpty() + } + } } - // getAllIdsForRecipe() + // download() @Test - fun `getAllIdsForRecipe() returns a list containing all image's identifier of the images of the given recipe`() { - val expectedFiles = imagesIds.map { File(it.toString()) }.toTypedArray() + 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 - whenever(recipeService.getById(recipeId)).doReturn(recipe) - whenever(recipeDirectory.exists()).doReturn(true) - whenever(recipeDirectory.isDirectory).doReturn(true) - whenever(recipeDirectory.listFiles()).doReturn(expectedFiles) - doReturn(recipeDirectory).whenever(service).getRecipeDirectory(recipeId) + val foundImageName = recipeImageService.download(mockImage, recipe) - val found = service.getAllIdsForRecipe(recipeId) + assertEquals(expectedImageName, foundImageName) - assertEquals(imagesIds, found) - } - - @Test - fun `getAllIdsForRecipe() returns an empty list when the given recipe's directory does not exists`() { - whenever(recipeService.getById(recipeId)).doReturn(recipe) - whenever(recipeDirectory.exists()).doReturn(false) - whenever(recipeDirectory.isDirectory).doReturn(true) - doReturn(recipeDirectory).whenever(service).getRecipeDirectory(recipeId) - - val found = service.getAllIdsForRecipe(recipeId) - - assertTrue(found.isEmpty()) - } - - @Test - fun `getAllIdsForRecipe() returns an empty list when the given recipe's directory is not a directory`() { - whenever(recipeService.getById(recipeId)).doReturn(recipe) - whenever(recipeDirectory.exists()).doReturn(true) - whenever(recipeDirectory.isDirectory).doReturn(false) - doReturn(recipeDirectory).whenever(service).getRecipeDirectory(recipeId) - - val found = service.getAllIdsForRecipe(recipeId) - - assertTrue(found.isEmpty()) - } - - // save() - - @Test - fun `save() writes the given image to the file service with the expected path`() { - val expectedNextAvailableId = imagesIds.maxOrNull()!! + 1 - val imagePath = "$RECIPE_IMAGES_DIRECTORY/$recipeId/$expectedNextAvailableId" - - doReturn(imagesIds).whenever(service).getAllIdsForRecipe(recipeId) - doReturn(imagePath).whenever(service).getPath(expectedNextAvailableId, recipeId) - - service.save(image, recipeId) - - verify(fileService).write(image, imagePath) + verify { + fileService.write(mockImage, expectedImagePath, true) + } + } } // delete() @Test - fun `delete() deletes the image with the given recipe and image id from the file service`() { - doReturn(imagePath).whenever(service).getPath(imageId, recipeId) + fun `delete() deletes the image with the given name in the FileService`() { + test { + val imageName = recipeImagesIds.first().imageName + val imagePath = imageName.imagePath - service.delete(imageId, recipeId) + recipeImageService.delete(recipe, imageName) - verify(fileService).delete(imagePath) - } - - // getRecipeDirectory() - - @Test - fun `getRecipeDirectory() returns a file with the expected path`() { - val recipeDirectoryPath = "$RECIPE_IMAGES_DIRECTORY/$recipeId" - whenever(fileService.getPath(recipeDirectoryPath)).doReturn(recipeDirectoryPath) - - val found = service.getRecipeDirectory(recipeId) - - assertEquals(recipeDirectoryPath, found.path) - } - - // getPath() - - @Test - fun `getPath() returns the expected path`() { - whenever(fileService.getPath(any())).doAnswer { it.arguments[0] as String } - - val found = service.getPath(imageId, recipeId) - - assertEquals(imagePath, found) + verify { + fileService.delete(imagePath) + } + } } } diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileServiceTest.kt index 756d7de..5316d9b 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileServiceTest.kt @@ -1,118 +1,298 @@ package dev.fyloz.colorrecipesexplorer.service.files -import com.nhaarman.mockitokotlin2.* import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties +import io.mockk.* import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test -import org.slf4j.Logger -import org.springframework.core.io.Resource -import org.springframework.core.io.ResourceLoader -import org.springframework.web.multipart.MultipartFile +import org.junit.jupiter.api.assertThrows +import org.springframework.mock.web.MockMultipartFile import java.io.File import java.io.IOException -import java.io.InputStream -import java.nio.file.Paths +import java.nio.file.Path import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +private val creProperties = CreProperties().apply { + workingDirectory = "data" + deploymentUrl = "http://localhost" +} +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.workingDirectory}/$mockFilePath")) { + every { file } returns mockFile + + with(fileService) { + every { mockFilePath.fullPath() } returns this@spyk + } + } + val mockMultipartFile = spyk(MockMultipartFile(mockFilePath, mockFileData)) +} + class FileServiceTest { - private val resourcesLoader = mock() - private val logger = mock() - private val properties = CreProperties() - - private val service = spy(FileService(resourcesLoader, properties, logger)) - - private val path = "/var/cre/file" - @AfterEach - fun afterEach() { - reset(resourcesLoader, logger, service) + internal fun afterEach() { + clearAllMocks() } - // readResource() + // exists() @Test - fun `readResource() returns content of the resource at the given path`() { - val resource = mock() - val resourceStream = mock() - val resourceContent = """ - Line 1 - Line 2 - Line 3 - """.trimIndent() - - whenever(resource.inputStream).doReturn(resourceStream) - whenever(resourcesLoader.getResource("classpath:$path")).doReturn(resource) - doReturn(resourceContent).whenever(service).readInputStreamAsString(resourceStream) - - val found = service.readResource(path) - - assertEquals(resourceContent, found) + fun `exists() returns true when the file at the given path exists and is a file`() { + test { + assertTrue { fileService.exists(mockFilePath) } + } } - // readInputStreamAsString() + @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 `readInputStreamAsString() returns a String matching the given input stream's content`() { - val stream = mock() - val streamContent = """ - Line 1 - Line 2 - Line 3 - """.trimIndent() + fun `exists() returns false when the file at the given path is not a file`() { + test { + every { mockFile.isFile } returns false - whenever(stream.readAllBytes()).doAnswer { streamContent.toByteArray() } + assertFalse { fileService.exists(mockFilePath) } + } + } - val found = service.readInputStreamAsString(stream) + // read() - assertEquals(streamContent, found) + @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() - private inline fun withMultipartFile(size: Long = 1000L, test: (MultipartFile) -> Unit) { - val multipartFile = mock() - whenever(multipartFile.size).doReturn(size) - - test(multipartFile) - } - @Test - fun `write() transfers data from the given MultipartFile to the file at the given path and returns true`() { - withMultipartFile { multipartFile -> - val file = mock() - val filePath = Paths.get(path) + 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 - whenever(file.toPath()).doReturn(filePath) - doAnswer { file }.whenever(service).create(path) + fileService.write(mockMultipartFile, mockFilePath, false) - assertTrue { service.write(multipartFile, path) } - - verify(multipartFile).transferTo(filePath) + verify { + fileService.create(mockFilePath) + mockMultipartFile.transferTo(mockFilePathPath) + } + } } } @Test - fun `write() returns true when given MultipartFile is empty`() { - withMultipartFile(size = 0L) { multipartFile -> - assertTrue { service.write(multipartFile, path) } - - verify(multipartFile, never()).transferTo(any()) + 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() returns false when the data transfer throw an IOException`() { - withMultipartFile { multipartFile -> - val file = mock() - val filePath = Paths.get(path) + fun `write() writes the given MultipartFile to an existing file when overwrite is enabled`() { + test { + whenMockFilePathExists { + every { mockMultipartFile.transferTo(mockFilePathPath) } just Runs - whenever(file.toPath()).doReturn(filePath) - whenever(multipartFile.transferTo(filePath)).doThrow(IOException()) - doAnswer { file }.whenever(service).create(path) + fileService.write(mockMultipartFile, mockFilePath, true) - assertFalse { service.write(multipartFile, path) } + 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.workingDirectory}/$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/service/files/SimdutServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutServiceTest.kt deleted file mode 100644 index c80391b..0000000 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutServiceTest.kt +++ /dev/null @@ -1,159 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service.files - -import com.nhaarman.mockitokotlin2.* -import dev.fyloz.colorrecipesexplorer.model.Material -import dev.fyloz.colorrecipesexplorer.model.material -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import org.springframework.web.multipart.MultipartFile -import java.io.IOException -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class SimdutServiceTest { - private val fileService = mock() - private val service = spy(SimdutService(fileService, mock())) - - private val material = material(id = 0L) - - @AfterEach - fun afterEach() { - reset(fileService, service) - } - - @JvmName("withNullableMaterialPath") - private inline fun withMaterialPath(material: Material? = null, exists: Boolean = true, test: (String) -> Unit) = - withMaterialPath(material ?: this.material, exists, test) - - private inline fun withMaterialPath(material: Material, exists: Boolean = true, test: (String) -> Unit) { - val path = "data/simdut/${material.id}" - doReturn(path).whenever(service).getPath(material) - whenever(fileService.exists(path)).doReturn(exists) - - test(path) - } - - // exists() - - @Test - fun `exists() returns true when a SIMDUT file exists for the given material`() { - withMaterialPath { - assertTrue { service.exists(material) } - } - } - - @Test - fun `exists() returns false when no SIMDUT file exists for the given material`() { - withMaterialPath(exists = false) { - assertFalse { service.exists(material) } - } - } - - // read() - - @Test - fun `read() returns a filled ByteArray when a SIMDUT exists for the given material`() { - withMaterialPath { path -> - val simdutContent = byteArrayOf(0xf) - - whenever(fileService.readAsBytes(path)).doReturn(simdutContent) - - val found = service.read(material) - - assertEquals(simdutContent, found) - } - } - - @Test - fun `read() returns a empty ByteArray when no SIMDUT exists for the given material`() { - withMaterialPath(exists = false) { - val found = service.read(material) - - assertTrue { found.isEmpty() } - } - } - - @Test - fun `read() returns a empty ByteArray when reading the SIMDUT throws an IOException`() { - withMaterialPath { path -> - whenever(fileService.readAsBytes(path)).doAnswer { throw IOException() } - - val found = service.read(material) - - assertTrue { found.isEmpty() } - } - } - - // write() - - @Test - fun `write() writes the given MultipartFile to the disk for the given material`() { - withMaterialPath { path -> - val simdutMultipart = mock() - - whenever(fileService.write(simdutMultipart, path)).doReturn(true) - - service.write(material, simdutMultipart) - - verify(fileService).write(simdutMultipart, path) - } - } - - @Test - fun `write() throws a SimdutWriteException when writing the given MultipartFile to the disk fails`() { - withMaterialPath { path -> - val simdutMultipart = mock() - - whenever(fileService.write(simdutMultipart, path)).doReturn(false) - - assertThrows { service.write(material, simdutMultipart) } - } - } - - // update() - - @Test - fun `update() deletes and write the SIMDUT for the given material`() { - val simdutMultipart = mock() - - // Prevents calling the actual implementation - doAnswer { }.whenever(service).delete(material) - doAnswer { }.whenever(service).write(material, simdutMultipart) - - service.update(simdutMultipart, material) - - verify(service).delete(material) - verify(service).write(material, simdutMultipart) - } - - // delete() - - @Test - fun `delete() deletes the SIMDUT of the given material from the disk`() { - withMaterialPath { path -> - service.delete(material) - - verify(fileService).delete(path) - } - } - - // getPath() - - @Test - fun `getPath() returns the appropriate path for the given material`() { - val simdutFileName = material.id.toString() - val workingDirectory = "data" - val expectedPath = "$workingDirectory/$SIMDUT_DIRECTORY/$simdutFileName" - - whenever(fileService.getPath(any())).doAnswer { "$workingDirectory/${it.arguments[0]}" } - doAnswer { simdutFileName }.whenever(service).getSimdutFileName(material) - - val found = service.getPath(material) - - assertEquals(expectedPath, found) - - verify(fileService).getPath("$SIMDUT_DIRECTORY/$simdutFileName") - } -} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/TouchUpKitServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/TouchUpKitServiceTest.kt new file mode 100644 index 0000000..4affc53 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/TouchUpKitServiceTest.kt @@ -0,0 +1,121 @@ +package dev.fyloz.colorrecipesexplorer.service.files + +import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties +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 fileService = mockk { + every { write(any(), any(), any()) } just Runs + } + val creProperties = mockk { + every { cacheGeneratedFiles } returns false + } + val touchUpKitService = spyk(TouchUpKitServiceImpl(fileService, creProperties)) + 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 { + every { creProperties.cacheGeneratedFiles } returns true + every { fileService.exists(any()) } returns true + every { fileService.read(any()) } returns pdfDocumentData + + val redResource = touchUpKitService.generateJobPdfResource(job) + + assertEquals(pdfDocumentData, redResource) + } + } + + // String.cachePdfDocument() + + @Test + fun `cachePdfDocument() does nothing when caching is disabled`() { + test { + every { creProperties.cacheGeneratedFiles } returns false + + 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 { + every { creProperties.cacheGeneratedFiles } returns true + + with(touchUpKitService) { + job.cachePdfDocument(pdfDocument) + } + + verify { + fileService.write(pdfDocumentData, any(), true) + } + } + } + + private fun test(test: TouchUpKitServiceTestContext.() -> Unit) { + TouchUpKitServiceTestContext().test() + } +}