diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AccountController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AccountController.kt index b906419e..d2d9acbd 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AccountController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AccountController.kt @@ -29,22 +29,27 @@ class AccountController(private val service: AccountService) { @GetMapping("/{id}") fun getAccountById(@PathVariable id: Long) = service.getAccountById(id) - @PostMapping("/changePassword/{id}") - fun changePassword(@PathVariable id: Long, @RequestBody dto: ChangePasswordDto): Map { - service.changePassword(id, dto) - return emptyMap() + @PostMapping("/new", consumes = ["multipart/form-data"]) + fun createAccount( + @RequestPart account: CreateAccountDto, + @RequestParam + @ValidImage + photo: MultipartFile? + ): Account { + account.photoFile = photo + return service.createAccount(account) } - @PutMapping("/{id}") + @PutMapping("/{id}", consumes = ["multipart/form-data"]) fun updateAccountById( @PathVariable id: Long, - @RequestPart dto: UpdateAccountDto, + @RequestPart account: UpdateAccountDto, @RequestParam @ValidImage photo: MultipartFile? ): Account { - dto.photoFile = photo - return service.updateAccountById(id, dto) + account.photoFile = photo + return service.updateAccountById(id, account) } @DeleteMapping("/{id}") @@ -53,14 +58,9 @@ class AccountController(private val service: AccountService) { return emptyMap() } - @PostMapping("/new", consumes = ["multipart/form-data"]) - fun createAccount( - @RequestPart dto: CreateAccountDto, - @RequestParam - @ValidImage - photo: MultipartFile? - ): Account { - dto.photoFile = photo - return service.createAccount(dto) + @PostMapping("/changePassword/{id}") + fun changePassword(@PathVariable id: Long, @RequestBody dto: ChangePasswordDto): Map { + service.changePassword(id, dto) + return emptyMap() } } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt index 1546a9d1..c5099fd3 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ErrorController.kt @@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.multipart.MaxUploadSizeExceededException +import org.springframework.web.multipart.support.MissingServletRequestPartException import pt.up.fe.ni.website.backend.config.Logging data class SimpleError( @@ -67,6 +68,12 @@ class ErrorController(private val objectMapper: ObjectMapper) : ErrorController, return CustomError(errors) } + @ExceptionHandler(MissingServletRequestPartException::class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + fun missingPart(e: MissingServletRequestPartException): CustomError { + return wrapSimpleError("required", param = e.requestPartName) + } + @ExceptionHandler(HttpMessageNotReadableException::class) @ResponseStatus(HttpStatus.BAD_REQUEST) fun invalidRequestBody(e: HttpMessageNotReadableException): CustomError { @@ -119,7 +126,7 @@ class ErrorController(private val objectMapper: ObjectMapper) : ErrorController, @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) fun unexpectedError(e: Exception): CustomError { logger.error(e.message) - return wrapSimpleError("unexpected error: " + e.message) + return wrapSimpleError("unexpected error") } @ExceptionHandler(AccessDeniedException::class) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/EventController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/EventController.kt index 9890bf16..69692906 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/EventController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/EventController.kt @@ -1,18 +1,24 @@ package pt.up.fe.ni.website.backend.controller +import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PutMapping -import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestPart import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile import pt.up.fe.ni.website.backend.dto.entity.EventDto +import pt.up.fe.ni.website.backend.model.Event import pt.up.fe.ni.website.backend.service.activity.EventService +import pt.up.fe.ni.website.backend.utils.validation.ValidImage @RestController @RequestMapping("/events") +@Validated class EventController(private val service: EventService) { @GetMapping fun getAllEvents() = service.getAllEvents() @@ -26,8 +32,16 @@ class EventController(private val service: EventService) { @GetMapping("/{eventSlug}**") fun getEvent(@PathVariable eventSlug: String) = service.getEventBySlug(eventSlug) - @PostMapping("/new") - fun createEvent(@RequestBody dto: EventDto) = service.createEvent(dto) + @PostMapping("/new", consumes = ["multipart/form-data"]) + fun createEvent( + @RequestPart event: EventDto, + @RequestParam + @ValidImage + image: MultipartFile + ): Event { + event.imageFile = image + return service.createEvent(event) + } @DeleteMapping("/{id}") fun deleteEventById(@PathVariable id: Long): Map { @@ -35,11 +49,17 @@ class EventController(private val service: EventService) { return emptyMap() } - @PutMapping("/{id}") + @PutMapping("/{id}", consumes = ["multipart/form-data"]) fun updateEventById( @PathVariable id: Long, - @RequestBody dto: EventDto - ) = service.updateEventById(id, dto) + @RequestPart event: EventDto, + @RequestParam + @ValidImage + image: MultipartFile? + ): Event { + event.imageFile = image + return service.updateEventById(id, event) + } @PutMapping("/{idEvent}/addTeamMember/{idAccount}") fun addTeamMemberById( diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ProjectController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ProjectController.kt index 39b6c874..053b0cad 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ProjectController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/ProjectController.kt @@ -1,18 +1,24 @@ package pt.up.fe.ni.website.backend.controller +import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PutMapping -import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestPart import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile import pt.up.fe.ni.website.backend.dto.entity.ProjectDto +import pt.up.fe.ni.website.backend.model.Project import pt.up.fe.ni.website.backend.service.activity.ProjectService +import pt.up.fe.ni.website.backend.utils.validation.ValidImage @RestController @RequestMapping("/projects") +@Validated class ProjectController(private val service: ProjectService) { @GetMapping @@ -24,8 +30,16 @@ class ProjectController(private val service: ProjectService) { @GetMapping("/{projectSlug}**") fun getProjectBySlug(@PathVariable projectSlug: String) = service.getProjectBySlug(projectSlug) - @PostMapping("/new") - fun createNewProject(@RequestBody dto: ProjectDto) = service.createProject(dto) + @PostMapping("/new", consumes = ["multipart/form-data"]) + fun createProject( + @RequestPart project: ProjectDto, + @RequestParam + @ValidImage + image: MultipartFile + ): Project { + project.imageFile = image + return service.createProject(project) + } @DeleteMapping("/{id}") fun deleteProjectById(@PathVariable id: Long): Map { @@ -33,11 +47,17 @@ class ProjectController(private val service: ProjectService) { return emptyMap() } - @PutMapping("/{id}") + @PutMapping("/{id}", consumes = ["multipart/form-data"]) fun updateProjectById( @PathVariable id: Long, - @RequestBody dto: ProjectDto - ) = service.updateProjectById(id, dto) + @RequestPart project: ProjectDto, + @RequestParam + @ValidImage + image: MultipartFile? + ): Project { + project.imageFile = image + return service.updateProjectById(id, project) + } @PutMapping("/{id}/archive") fun archiveProjectById(@PathVariable id: Long) = service.archiveProjectById(id) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/ActivityDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/ActivityDto.kt new file mode 100644 index 00000000..c9d34c29 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/ActivityDto.kt @@ -0,0 +1,15 @@ +package pt.up.fe.ni.website.backend.dto.entity + +import com.fasterxml.jackson.annotation.JsonIgnore +import org.springframework.web.multipart.MultipartFile +import pt.up.fe.ni.website.backend.model.Activity + +abstract class ActivityDto( + val title: String, + val description: String, + val teamMembersIds: List?, + val slug: String?, + var image: String?, + @JsonIgnore + var imageFile: MultipartFile? = null +) : EntityDto() diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/CustomWebsiteDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/CustomWebsiteDto.kt index c3b16a53..7268c3df 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/CustomWebsiteDto.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/CustomWebsiteDto.kt @@ -4,5 +4,6 @@ import pt.up.fe.ni.website.backend.model.CustomWebsite class CustomWebsiteDto( val url: String, - val iconPath: String? + val iconPath: String?, + val label: String? ) : EntityDto() diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/EntityDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/EntityDto.kt index 0f6d77cc..e4289d9b 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/EntityDto.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/EntityDto.kt @@ -6,7 +6,9 @@ import jakarta.persistence.Entity import jakarta.validation.ConstraintViolationException import jakarta.validation.Validator import kotlin.reflect.KClass +import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.full.isSubclassOf import kotlin.reflect.jvm.jvmErasure import pt.up.fe.ni.website.backend.config.ApplicationContextUtils @@ -57,9 +59,13 @@ abstract class EntityDto { // The use of suppress is explained at https://github.com/NIAEFEUP/website-niaefeup-backend/pull/20#discussion_r985236224 @Suppress("UNCHECKED_CAST") private fun getTypeConversionClass(clazz: KClass>): KClass? { - val thisType = clazz.supertypes.first { it.classifier == EntityDto::class } + val superType = clazz.supertypes.firstOrNull { it.jvmErasure.isSubclassOf(EntityDto::class) } ?: return null + val conversionClassArg = + superType.arguments.firstOrNull { + it.type?.jvmErasure?.findAnnotation() != null + } ?: return getTypeConversionClass(superType.jvmErasure as KClass>) - return thisType.arguments.firstOrNull()?.type?.jvmErasure as KClass? + return conversionClassArg.type?.jvmErasure as KClass? } /** diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/EventDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/EventDto.kt index cf073aa0..19fe4673 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/EventDto.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/EventDto.kt @@ -4,13 +4,14 @@ import pt.up.fe.ni.website.backend.model.Event import pt.up.fe.ni.website.backend.model.embeddable.DateInterval class EventDto( - val title: String, - val description: String, - val teamMembersIds: List?, + title: String, + description: String, + teamMembersIds: List?, + slug: String?, + image: String?, + val registerUrl: String?, val dateInterval: DateInterval, val location: String?, - val category: String?, - val thumbnailPath: String, - val slug: String? -) : EntityDto() + val category: String? +) : ActivityDto(title, description, teamMembersIds, slug, image) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/ProjectDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/ProjectDto.kt index f7e701d2..bebe2626 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/ProjectDto.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/ProjectDto.kt @@ -3,11 +3,18 @@ package pt.up.fe.ni.website.backend.dto.entity import pt.up.fe.ni.website.backend.model.Project class ProjectDto( - val title: String, - val description: String, - var hallOfFameIds: List?, - val teamMembersIds: List?, + title: String, + description: String, + teamMembersIds: List?, + slug: String?, + image: String?, + val isArchived: Boolean = false, val technologies: List = emptyList(), - val slug: String? -) : EntityDto() + val slogan: String?, + val targetAudience: String, + val github: String?, + val links: List?, + val timeline: List?, + val hallOfFameIds: List? +) : ActivityDto(title, description, teamMembersIds, slug, image) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/TimelineEventDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/TimelineEventDto.kt new file mode 100644 index 00000000..6b50ad21 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/TimelineEventDto.kt @@ -0,0 +1,9 @@ +package pt.up.fe.ni.website.backend.dto.entity + +import java.util.Date +import pt.up.fe.ni.website.backend.model.TimelineEvent + +class TimelineEventDto( + val date: Date, + val description: String +) : EntityDto() diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Activity.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Activity.kt index 7e446eb1..045684d3 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Activity.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Activity.kt @@ -14,6 +14,7 @@ import jakarta.persistence.JoinColumn import jakarta.persistence.OneToMany import jakarta.persistence.OrderColumn import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Size import pt.up.fe.ni.website.backend.model.constants.ActivityConstants as Constants @@ -41,6 +42,9 @@ abstract class Activity( @field:Size(min = Constants.Slug.minSize, max = Constants.Slug.maxSize) open val slug: String? = null, + @field:NotBlank + open var image: String, + @Id @GeneratedValue open val id: Long? = null diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/CustomWebsite.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/CustomWebsite.kt index 6ba6a57a..42a8104e 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/CustomWebsite.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/CustomWebsite.kt @@ -19,6 +19,9 @@ class CustomWebsite( @field:URL val iconPath: String?, + @field:NullOrNotBlank + val label: String?, + @Id @GeneratedValue val id: Long? = null ) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Event.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Event.kt index f348a46e..38135f16 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Event.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Event.kt @@ -1,10 +1,8 @@ package pt.up.fe.ni.website.backend.model -import com.fasterxml.jackson.annotation.JsonProperty import jakarta.persistence.Embedded import jakarta.persistence.Entity import jakarta.validation.Valid -import jakarta.validation.constraints.NotEmpty import jakarta.validation.constraints.Size import org.hibernate.validator.constraints.URL import pt.up.fe.ni.website.backend.model.constants.EventConstants as Constants @@ -18,6 +16,7 @@ class Event( teamMembers: MutableList = mutableListOf(), associatedRoles: MutableList = mutableListOf(), slug: String? = null, + image: String, @field:NullOrNotBlank @field:URL @@ -33,10 +32,5 @@ class Event( @field:Size(min = Constants.Category.minSize, max = Constants.Category.maxSize) val category: String?, - @JsonProperty(required = true) - @field:NotEmpty - @field:URL - val thumbnailPath: String, - id: Long? = null -) : Activity(title, description, teamMembers, associatedRoles, slug, id) +) : Activity(title, description, teamMembers, associatedRoles, slug, image, id) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Project.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Project.kt index d45e19b2..962b77bb 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Project.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Project.kt @@ -1,25 +1,51 @@ package pt.up.fe.ni.website.backend.model +import jakarta.persistence.CascadeType import jakarta.persistence.Entity import jakarta.persistence.FetchType import jakarta.persistence.JoinColumn import jakarta.persistence.OneToMany +import jakarta.persistence.OrderBy +import jakarta.validation.Valid +import jakarta.validation.constraints.Size +import org.hibernate.validator.constraints.URL +import pt.up.fe.ni.website.backend.model.constants.ProjectConstants as Constants +import pt.up.fe.ni.website.backend.utils.validation.NullOrNotBlank @Entity class Project( title: String, description: String, - - @JoinColumn - @OneToMany(fetch = FetchType.EAGER) - var hallOfFame: MutableList = mutableListOf(), - teamMembers: MutableList = mutableListOf(), associatedRoles: MutableList = mutableListOf(), slug: String? = null, + image: String, var isArchived: Boolean = false, + val technologies: List = emptyList(), + @field:Size(min = Constants.Slogan.minSize, max = Constants.Slogan.maxSize) + var slogan: String? = null, + + @field:Size(min = Constants.TargetAudience.minSize, max = Constants.TargetAudience.maxSize) + var targetAudience: String, + + @field:NullOrNotBlank + @field:URL + var github: String? = null, + + @JoinColumn + @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER) + val links: List<@Valid CustomWebsite> = emptyList(), + + @JoinColumn + @OneToMany(fetch = FetchType.EAGER) + var hallOfFame: MutableList = mutableListOf(), + + @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER) + @OrderBy("date") + val timeline: List<@Valid TimelineEvent> = emptyList(), + id: Long? = null -) : Activity(title, description, teamMembers, associatedRoles, slug, id) +) : Activity(title, description, teamMembers, associatedRoles, slug, image, id) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/TimelineEvent.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/TimelineEvent.kt new file mode 100644 index 00000000..648574c5 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/TimelineEvent.kt @@ -0,0 +1,18 @@ +package pt.up.fe.ni.website.backend.model + +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.Id +import jakarta.validation.constraints.NotEmpty +import java.util.Date + +@Entity +class TimelineEvent( + val date: Date, + + @field:NotEmpty + val description: String, + + @Id @GeneratedValue + val id: Long? = null +) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/ProjectConstants.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/ProjectConstants.kt new file mode 100644 index 00000000..8701d3fc --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/ProjectConstants.kt @@ -0,0 +1,13 @@ +package pt.up.fe.ni.website.backend.model.constants + +object ProjectConstants { + object Slogan { + const val minSize = 2 + const val maxSize = 100 + } + + object TargetAudience { + const val minSize = 2 + const val maxSize = 250 + } +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/UploadConstants.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/UploadConstants.kt index a1e557dd..e77f284a 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/UploadConstants.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/UploadConstants.kt @@ -5,13 +5,15 @@ object UploadConstants { val contentTypes = listOf( "image/png", "image/jpg", - "image/jpeg" + "image/jpeg", + "image/webp" ) val fileExtensions = listOf( "png", "jpg", - "jpeg" + "jpeg", + "webp" ) } } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AccountService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AccountService.kt index c860ac6d..6cbb525f 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AccountService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AccountService.kt @@ -1,17 +1,14 @@ package pt.up.fe.ni.website.backend.service -import java.util.UUID import org.springframework.data.repository.findByIdOrNull import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service -import org.springframework.web.multipart.MultipartFile import pt.up.fe.ni.website.backend.dto.auth.ChangePasswordDto import pt.up.fe.ni.website.backend.dto.entity.account.CreateAccountDto import pt.up.fe.ni.website.backend.dto.entity.account.UpdateAccountDto import pt.up.fe.ni.website.backend.model.Account import pt.up.fe.ni.website.backend.repository.AccountRepository import pt.up.fe.ni.website.backend.service.upload.FileUploader -import pt.up.fe.ni.website.backend.utils.extensions.filenameExtension @Service class AccountService( @@ -30,16 +27,13 @@ class AccountService( account.password = encoder.encode(dto.password) dto.photoFile?.let { - val fileName = photoFilename(dto.email, it) + val fileName = fileUploader.buildFileName(it, dto.email) account.photo = fileUploader.uploadImage("profile", fileName, it.bytes) } return repository.save(account) } - private fun photoFilename(email: String, photoFile: MultipartFile): String = - "$email-${UUID.randomUUID()}.${photoFile.filenameExtension()}" - fun getAccountById(id: Long): Account = repository.findByIdOrNull(id) ?: throw NoSuchElementException(ErrorMessages.accountNotFound(id)) @@ -55,7 +49,7 @@ class AccountService( } dto.photoFile?.let { - val fileName = photoFilename(dto.email, it) + val fileName = fileUploader.buildFileName(it, dto.email) account.photo = fileUploader.uploadImage("profile", fileName, it.bytes) } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/AbstractActivityService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/AbstractActivityService.kt index bda10799..32127552 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/AbstractActivityService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/AbstractActivityService.kt @@ -2,19 +2,68 @@ package pt.up.fe.ni.website.backend.service.activity import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service +import pt.up.fe.ni.website.backend.dto.entity.ActivityDto import pt.up.fe.ni.website.backend.model.Activity import pt.up.fe.ni.website.backend.repository.ActivityRepository import pt.up.fe.ni.website.backend.service.AccountService import pt.up.fe.ni.website.backend.service.ErrorMessages +import pt.up.fe.ni.website.backend.service.upload.FileUploader @Service abstract class AbstractActivityService( protected val repository: ActivityRepository, - protected val accountService: AccountService + protected val accountService: AccountService, + protected val fileUploader: FileUploader ) { fun getActivityById(id: Long): T = repository.findByIdOrNull(id) ?: throw NoSuchElementException(ErrorMessages.activityNotFound(id)) + fun > createActivity(dto: U, imageFolder: String): T { + repository.findBySlug(dto.slug)?.let { + throw IllegalArgumentException(ErrorMessages.slugAlreadyExists) + } + + dto.imageFile?.let { + val fileName = fileUploader.buildFileName(it, dto.title) + dto.image = fileUploader.uploadImage(imageFolder, fileName, it.bytes) + } + + val activity = dto.create() + + dto.teamMembersIds?.forEach { + val account = accountService.getAccountById(it) + activity.teamMembers.add(account) + } + + return repository.save(activity) + } + + fun > updateActivityById(activity: T, dto: U, imageFolder: String): T { + if (dto.slug != activity.slug) { + repository.findBySlug(dto.slug)?.let { + throw IllegalArgumentException(ErrorMessages.slugAlreadyExists) + } + } + + val imageFile = dto.imageFile + if (imageFile == null) { + dto.image = activity.image + } else { + val fileName = fileUploader.buildFileName(imageFile, dto.title) + dto.image = fileUploader.uploadImage(imageFolder, fileName, imageFile.bytes) + } + + val newActivity = dto.update(activity) + newActivity.apply { + teamMembers.clear() + dto.teamMembersIds?.forEach { + val account = accountService.getAccountById(it) + teamMembers.add(account) + } + } + return repository.save(newActivity) + } + fun addTeamMemberById(idActivity: Long, idAccount: Long): T { val activity = getActivityById(idActivity) val account = accountService.getAccountById(idAccount) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/ActivityService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/ActivityService.kt index 87a47c89..e2a94aad 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/ActivityService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/ActivityService.kt @@ -4,9 +4,11 @@ import org.springframework.stereotype.Service import pt.up.fe.ni.website.backend.model.Activity import pt.up.fe.ni.website.backend.repository.ActivityRepository import pt.up.fe.ni.website.backend.service.AccountService +import pt.up.fe.ni.website.backend.service.upload.FileUploader @Service class ActivityService( repository: ActivityRepository, - accountService: AccountService -) : AbstractActivityService(repository, accountService) + accountService: AccountService, + fileUploader: FileUploader +) : AbstractActivityService(repository, accountService, fileUploader) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/EventService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/EventService.kt index d31b9e66..53dfbe5a 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/EventService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/EventService.kt @@ -7,48 +7,34 @@ import pt.up.fe.ni.website.backend.model.Event import pt.up.fe.ni.website.backend.repository.EventRepository import pt.up.fe.ni.website.backend.service.AccountService import pt.up.fe.ni.website.backend.service.ErrorMessages +import pt.up.fe.ni.website.backend.service.upload.FileUploader @Service class EventService( override val repository: EventRepository, - accountService: AccountService -) : AbstractActivityService(repository, accountService) { - fun getAllEvents(): List = repository.findAll().toList() - - fun getEventBySlug(eventSlug: String): Event = - repository.findBySlug(eventSlug) ?: throw NoSuchElementException(ErrorMessages.eventNotFound(eventSlug)) - - fun createEvent(dto: EventDto): Event { - repository.findBySlug(dto.slug)?.let { - throw IllegalArgumentException(ErrorMessages.slugAlreadyExists) - } - - val event = dto.create() - - dto.teamMembersIds?.forEach { - val account = accountService.getAccountById(it) - event.teamMembers.add(account) - } + accountService: AccountService, + fileUploader: FileUploader +) : AbstractActivityService(repository, accountService, fileUploader) { - return repository.save(event) + companion object { + const val IMAGE_FOLDER = "events" } + fun getAllEvents(): List = repository.findAll().toList() + fun getEventsByCategory(category: String): List = repository.findAllByCategory(category) fun getEventById(eventId: Long): Event = repository.findByIdOrNull(eventId) ?: throw NoSuchElementException(ErrorMessages.eventNotFound(eventId)) + fun getEventBySlug(eventSlug: String): Event = + repository.findBySlug(eventSlug) ?: throw NoSuchElementException(ErrorMessages.eventNotFound(eventSlug)) + + fun createEvent(dto: EventDto) = createActivity(dto, IMAGE_FOLDER) + fun updateEventById(eventId: Long, dto: EventDto): Event { val event = getEventById(eventId) - val newEvent = dto.update(event) - newEvent.apply { - teamMembers.clear() - dto.teamMembersIds?.forEach { - val account = accountService.getAccountById(it) - teamMembers.add(account) - } - } - return repository.save(newEvent) + return updateActivityById(event, dto, IMAGE_FOLDER) } fun deleteEventById(eventId: Long) { diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/ProjectService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/ProjectService.kt index 538cd567..a2e98346 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/ProjectService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/ProjectService.kt @@ -7,54 +7,44 @@ import pt.up.fe.ni.website.backend.model.Project import pt.up.fe.ni.website.backend.repository.ProjectRepository import pt.up.fe.ni.website.backend.service.AccountService import pt.up.fe.ni.website.backend.service.ErrorMessages +import pt.up.fe.ni.website.backend.service.upload.FileUploader @Service class ProjectService( override val repository: ProjectRepository, - accountService: AccountService -) : AbstractActivityService(repository, accountService) { + accountService: AccountService, + fileUploader: FileUploader +) : AbstractActivityService(repository, accountService, fileUploader) { + + companion object { + const val IMAGE_FOLDER = "projects" + } fun getAllProjects(): List = repository.findAll().toList() - fun createProject(dto: ProjectDto): Project { - repository.findBySlug(dto.slug)?.let { - throw IllegalArgumentException(ErrorMessages.slugAlreadyExists) - } + fun getProjectById(id: Long): Project = repository.findByIdOrNull(id) + ?: throw NoSuchElementException(ErrorMessages.projectNotFound(id)) - val project = dto.create() + fun getProjectBySlug(projectSlug: String): Project = repository.findBySlug(projectSlug) + ?: throw NoSuchElementException(ErrorMessages.projectNotFound(projectSlug)) + fun createProject(dto: ProjectDto): Project { + val project = createActivity(dto, IMAGE_FOLDER) dto.hallOfFameIds?.forEach { val account = accountService.getAccountById(it) project.hallOfFame.add(account) } - - dto.teamMembersIds?.forEach { - val account = accountService.getAccountById(it) - project.teamMembers.add(account) - } - return repository.save(project) } - fun getProjectById(id: Long): Project = repository.findByIdOrNull(id) - ?: throw NoSuchElementException(ErrorMessages.projectNotFound(id)) - - fun getProjectBySlug(projectSlug: String): Project = repository.findBySlug(projectSlug) - ?: throw NoSuchElementException(ErrorMessages.projectNotFound(projectSlug)) - fun updateProjectById(id: Long, dto: ProjectDto): Project { val project = getProjectById(id) - - repository.findBySlug(dto.slug)?.let { - if (it.id != project.id) throw IllegalArgumentException(ErrorMessages.slugAlreadyExists) - } - - val newProject = dto.update(project) + val newProject = updateActivityById(project, dto, IMAGE_FOLDER) newProject.apply { - teamMembers.clear() - dto.teamMembersIds?.forEach { + hallOfFame.clear() + dto.hallOfFameIds?.forEach { val account = accountService.getAccountById(it) - teamMembers.add(account) + hallOfFame.add(account) } } return repository.save(newProject) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/upload/CloudinaryFileUploader.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/upload/CloudinaryFileUploader.kt index ee7f7081..64cf99e1 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/upload/CloudinaryFileUploader.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/upload/CloudinaryFileUploader.kt @@ -3,7 +3,7 @@ package pt.up.fe.ni.website.backend.service.upload import com.cloudinary.Cloudinary import com.cloudinary.Transformation -class CloudinaryFileUploader(private val basePath: String, private val cloudinary: Cloudinary) : FileUploader { +class CloudinaryFileUploader(private val basePath: String, private val cloudinary: Cloudinary) : FileUploader() { override fun uploadImage(folder: String, fileName: String, image: ByteArray): String { val path = "$basePath/$folder/$fileName" diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/upload/FileUploader.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/upload/FileUploader.kt index e5fb1f1c..665c5a48 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/upload/FileUploader.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/upload/FileUploader.kt @@ -1,5 +1,14 @@ package pt.up.fe.ni.website.backend.service.upload -interface FileUploader { - fun uploadImage(folder: String, fileName: String, image: ByteArray): String +import java.util.UUID +import org.springframework.web.multipart.MultipartFile +import pt.up.fe.ni.website.backend.utils.extensions.filenameExtension + +abstract class FileUploader { + abstract fun uploadImage(folder: String, fileName: String, image: ByteArray): String + + fun buildFileName(photoFile: MultipartFile, prefix: String = ""): String { + val limitedPrefix = prefix.take(100) // File name length has a limit of 256 characters + return "$limitedPrefix-${UUID.randomUUID()}.${photoFile.filenameExtension()}" + } } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/upload/StaticFileUploader.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/upload/StaticFileUploader.kt index 1bc34e5d..9db9039c 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/upload/StaticFileUploader.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/upload/StaticFileUploader.kt @@ -2,9 +2,10 @@ package pt.up.fe.ni.website.backend.service.upload import java.io.File -class StaticFileUploader(private val storePath: String, private val servePath: String) : FileUploader { +class StaticFileUploader(private val storePath: String, private val servePath: String) : FileUploader() { override fun uploadImage(folder: String, fileName: String, image: ByteArray): String { val file = File("$storePath/$folder/$fileName") + file.parentFile.mkdirs() file.createNewFile() file.writeBytes(image) diff --git a/src/main/resources/static/profile/.gitkeep b/src/main/resources/static/.gitkeep similarity index 100% rename from src/main/resources/static/profile/.gitkeep rename to src/main/resources/static/.gitkeep diff --git a/src/main/resources/validation_errors.properties b/src/main/resources/validation_errors.properties index bee292a1..5d5c6145 100644 --- a/src/main/resources/validation_errors.properties +++ b/src/main/resources/validation_errors.properties @@ -2,5 +2,5 @@ size.min = size must be greater or equal to {min} null_or_not_blank.error = must be null or not blank date_interval.error = endDate must be after startDate school_year.error = must be formatted as where yy=xx+1 -no_duplicate_roles.error= must not contain duplicate roles -files.invalid_image = invalid image type (png, jpg or jpeg) +no_duplicate_roles.error=must not contain duplicate roles +files.invalid_image=invalid image type (png, jpg, jpeg or webp) diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt index c51e6ccb..04ec2f12 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt @@ -5,7 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import java.util.Calendar import java.util.Date import java.util.UUID -import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeEach @@ -42,7 +42,6 @@ import pt.up.fe.ni.website.backend.utils.documentation.utils.MockMVCExtension.Co import pt.up.fe.ni.website.backend.utils.documentation.utils.MockMVCExtension.Companion.andDocumentCustomRequestSchemaErrorResponse import pt.up.fe.ni.website.backend.utils.documentation.utils.MockMVCExtension.Companion.andDocumentEmptyObjectResponse import pt.up.fe.ni.website.backend.utils.documentation.utils.MockMVCExtension.Companion.andDocumentErrorResponse -import pt.up.fe.ni.website.backend.utils.documentation.utils.ModelDocumentation import pt.up.fe.ni.website.backend.utils.documentation.utils.PayloadSchema import pt.up.fe.ni.website.backend.utils.mockmvc.multipartBuilder @@ -54,7 +53,8 @@ class AccountControllerTest @Autowired constructor( val encoder: PasswordEncoder, val uploadConfigProperties: UploadConfigProperties ) { - val documentation: ModelDocumentation = PayloadAccount() + val documentation = PayloadAccount() + val documentationNoPassword = PayloadAccount(includePassword = false) val testAccount = Account( "Test Account", @@ -66,7 +66,7 @@ class AccountControllerTest @Autowired constructor( "https://linkedin.com", "https://github.com", listOf( - CustomWebsite("https://test-website.com", "https://test-website.com/logo.png") + CustomWebsite("https://test-website.com", "https://test-website.com/logo.png", "test") ), mutableListOf() ) @@ -169,15 +169,23 @@ class AccountControllerTest @Autowired constructor( @NestedTest @DisplayName("POST /accounts/new") inner class CreateAccount { - @AfterEach - fun clearAccounts() { - repository.deleteAll() + private val uuid: UUID = UUID.randomUUID() + private val mockedSettings = Mockito.mockStatic(UUID::class.java) + + @BeforeAll + fun setupMocks() { + Mockito.`when`(UUID.randomUUID()).thenReturn(uuid) + } + + @AfterAll + fun cleanup() { + mockedSettings.close() } @Test fun `should create the account`() { mockMvc.multipartBuilder("/accounts/new") - .addPart("dto", testAccount.toJson()) + .addPart("account", testAccount.toJson()) .perform() .andExpectAll( status().isOk, @@ -190,7 +198,8 @@ class AccountControllerTest @Autowired constructor( jsonPath("$.github").value(testAccount.github), jsonPath("$.websites.length()").value(1), jsonPath("$.websites[0].url").value(testAccount.websites[0].url), - jsonPath("$.websites[0].iconPath").value(testAccount.websites[0].iconPath) + jsonPath("$.websites[0].iconPath").value(testAccount.websites[0].iconPath), + jsonPath("$.websites[0].label").value(testAccount.websites[0].label) ) } @@ -220,7 +229,7 @@ class AccountControllerTest @Autowired constructor( ) mockMvc.multipartBuilder("/accounts/new") - .addPart("dto", data) + .addPart("account", data) .perform() .andExpectAll( status().isOk, @@ -237,14 +246,10 @@ class AccountControllerTest @Autowired constructor( @Test fun `should create the account with valid image`() { - val uuid: UUID = UUID.randomUUID() - val mockedSettings = Mockito.mockStatic(UUID::class.java) - Mockito.`when`(UUID.randomUUID()).thenReturn(uuid) - val expectedPhotoPath = "${uploadConfigProperties.staticServe}/profile/${testAccount.email}-$uuid.jpeg" mockMvc.multipartBuilder("/accounts/new") - .addPart("dto", testAccount.toJson()) + .addPart("account", testAccount.toJson()) .addFile() .perform() .andExpectAll( @@ -261,50 +266,56 @@ class AccountControllerTest @Autowired constructor( jsonPath("$.websites[0].url").value(testAccount.websites[0].url), jsonPath("$.websites[0].iconPath").value(testAccount.websites[0].iconPath) ) - - mockedSettings.close() +// .andDocument( +// documentation, +// "Create new accounts", +// "This endpoint operation creates a new account.", +// documentRequestPayload = true +// ) } @Test fun `should fail to create account with invalid filename extension`() { - val uuid: UUID = UUID.randomUUID() - val mockedSettings = Mockito.mockStatic(UUID::class.java) - Mockito.`when`(UUID.randomUUID()).thenReturn(uuid) - mockMvc.multipartBuilder("/accounts/new") - .addPart("dto", testAccount.toJson()) - .addFile(filename = "photo.pdf", contentType = MediaType.APPLICATION_PDF_VALUE) + .addPart("account", testAccount.toJson()) + .addFile(filename = "photo.pdf") .perform() .andExpectAll( status().isBadRequest, content().contentType(MediaType.APPLICATION_JSON), jsonPath("$.errors.length()").value(1), - jsonPath("$.errors[0].message").value("invalid image type (png, jpg or jpeg)"), + jsonPath("$.errors[0].message").value("invalid image type (png, jpg, jpeg or webp)"), jsonPath("$.errors[0].param").value("createAccount.photo") ) - - mockedSettings.close() + .andDocumentErrorResponse(documentation, hasRequestPayload = true) } @Test fun `should fail to create account with invalid filename media type`() { - val uuid: UUID = UUID.randomUUID() - val mockedSettings = Mockito.mockStatic(UUID::class.java) - Mockito.`when`(UUID.randomUUID()).thenReturn(uuid) - mockMvc.multipartBuilder("/accounts/new") - .addPart("dto", testAccount.toJson()) + .addPart("account", testAccount.toJson()) .addFile(contentType = MediaType.APPLICATION_PDF_VALUE) .perform() .andExpectAll( status().isBadRequest, content().contentType(MediaType.APPLICATION_JSON), jsonPath("$.errors.length()").value(1), - jsonPath("$.errors[0].message").value("invalid image type (png, jpg or jpeg)"), + jsonPath("$.errors[0].message").value("invalid image type (png, jpg, jpeg or webp)"), jsonPath("$.errors[0].param").value("createAccount.photo") ) + } - mockedSettings.close() + @Test + fun `should fail when missing account part`() { + mockMvc.multipartBuilder("/accounts/new") + .perform() + .andExpectAll( + status().isBadRequest, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("required"), + jsonPath("$.errors[0].param").value("account") + ) } @NestedTest @@ -313,8 +324,9 @@ class AccountControllerTest @Autowired constructor( private val validationTester = ValidationTester( req = { params: Map -> mockMvc.multipartBuilder("/accounts/new") - .addPart("dto", objectMapper.writeValueAsString(params)) + .addPart("account", objectMapper.writeValueAsString(params)) .perform() + .andDocumentErrorResponse(documentation, hasRequestPayload = true) }, requiredFields = mapOf( "name" to testAccount.name, @@ -439,7 +451,7 @@ class AccountControllerTest @Autowired constructor( private val validationTester = ValidationTester( req = { params: Map -> val accountPart = MockPart( - "dto", + "account", objectMapper.writeValueAsString( mapOf( "name" to testAccount.name, @@ -495,29 +507,44 @@ class AccountControllerTest @Autowired constructor( } @Test - fun `should be bull or not blank`() { + fun `should be null or not blank`() { validationTester.parameterName = "websites[0].iconPath" validationTester.isNullOrNotBlank() } @Test - fun `should be URL`() { + fun `must be URL`() { validationTester.parameterName = "websites[0].iconPath" validationTester.isUrl() } } + + @NestedTest + @DisplayName("label") + inner class LabelValidation { + @BeforeAll + fun setParam() { + validationTester.param = "label" + } + + @Test + fun `should be null or not blank`() { + validationTester.parameterName = "websites[0].label" + validationTester.isNullOrNotBlank() + } + } } } @Test fun `should fail to create account with existing email`() { mockMvc.multipartBuilder("/accounts/new") - .addPart("dto", testAccount.toJson()) + .addPart("account", testAccount.toJson()) .perform() .andExpect(status().isOk) mockMvc.multipartBuilder("/accounts/new") - .addPart("dto", testAccount.toJson()) + .addPart("account", testAccount.toJson()) .perform() .andExpectAll( status().isUnprocessableEntity, @@ -673,45 +700,55 @@ class AccountControllerTest @Autowired constructor( "https://linkedin.com", "https://github.com", listOf( - CustomWebsite("https://test-website.com", "https://test-website.com/logo.png") + CustomWebsite("https://test-website.com", "https://test-website.com/logo.png", "test") ) ) + private val uuid: UUID = UUID.randomUUID() + private val mockedSettings = Mockito.mockStatic(UUID::class.java) + @BeforeEach fun addAccounts() { repository.save(testAccount) repository.save(newAccount) } - private val documentation = PayloadAccount(includePassword = false) + @BeforeAll + fun setupMocks() { + Mockito.`when`(UUID.randomUUID()).thenReturn(uuid) + } + + @AfterAll + fun cleanup() { + mockedSettings.close() + } + private val parameters = listOf(parameterWithName("id").description("ID of the account to update")) - @Test - fun `should update the account`() { - val newName = "Test Account 2" - val newEmail = "test_account2@test.com" - val newBio = "This is a test account altered" - val newBirthDate = TestUtils.createDate(2003, Calendar.JULY, 28) - val newLinkedin = "https://linkedin2.com" - val newGithub = "https://github2.com" - val newWebsites = listOf( - CustomWebsite("https://test-website2.com", "https://test-website.com/logo.png") - ) + private val newName = "Test Account 2" + private val newEmail = "test_account2@test.com" + private val newBio = "This is a test account altered" + private val newBirthDate = TestUtils.createDate(2003, Calendar.JULY, 28) + private val newLinkedin = "https://linkedin2.com" + private val newGithub = "https://github2.com" + private val newWebsites = listOf( + CustomWebsite("https://test-website2.com", "https://test-website.com/logo.png", "test") + ) - val data = objectMapper.writeValueAsString( - mapOf( - "name" to newName, - "email" to newEmail, - "bio" to newBio, - "birthDate" to newBirthDate, - "linkedin" to newLinkedin, - "github" to newGithub, - "websites" to newWebsites - ) - ) + private val data = mutableMapOf( + "name" to newName, + "email" to newEmail, + "bio" to newBio, + "birthDate" to newBirthDate, + "linkedin" to newLinkedin, + "github" to newGithub, + "websites" to newWebsites + ) + @Test + fun `should update the account`() { mockMvc.multipartBuilder("/accounts/${testAccount.id}") - .addPart("dto", data) + .addPart("account", objectMapper.writeValueAsString(data)) .asPutMethod() .perform() .andExpectAll( @@ -728,12 +765,12 @@ class AccountControllerTest @Autowired constructor( jsonPath("$.websites[0].iconPath").value(newWebsites[0].iconPath) ) // .andDocument( -// documentation, +// documentationNoPassword, // "Update accounts", -// "Update a previously created account, with the exception of its password, using its ID.", +// "Update a previously created account, except the password, using its ID (no image).", // urlParameters = parameters, // documentRequestPayload = true -// ) +// ) val updatedAccount = repository.findById(testAccount.id!!).get() Assertions.assertEquals(newName, updatedAccount.name) @@ -747,29 +784,10 @@ class AccountControllerTest @Autowired constructor( @Test fun `should update the account when email is unchanged`() { - val newName = "Test Account 2" - val newBio = "This is a test account with no altered email" - val newBirthDate = TestUtils.createDate(2003, Calendar.JULY, 28) - val newLinkedin = "https://linkedin2.com" - val newGithub = "https://github2.com" - val newWebsites = listOf( - CustomWebsite("https://test-website2.com", "https://test-website.com/logo.png") - ) - - val data = objectMapper.writeValueAsString( - mapOf( - "name" to newName, - "email" to testAccount.email, - "bio" to newBio, - "birthDate" to newBirthDate, - "linkedin" to newLinkedin, - "github" to newGithub, - "websites" to newWebsites - ) - ) + data["email"] = testAccount.email mockMvc.multipartBuilder("/accounts/${testAccount.id}") - .addPart("dto", data) + .addPart("account", objectMapper.writeValueAsString(data)) .asPutMethod() .perform() .andExpectAll( @@ -785,13 +803,6 @@ class AccountControllerTest @Autowired constructor( jsonPath("$.websites[0].url").value(newWebsites[0].url), jsonPath("$.websites[0].iconPath").value(newWebsites[0].iconPath) ) -// .andDocument( -// documentation, -// "Update accounts", -// "Update a previously created account, with the exception of its password, using its ID.", -// urlParameters = parameters, -// documentRequestPayload = true -// ) val updatedAccount = repository.findById(testAccount.id!!).get() Assertions.assertEquals(newName, updatedAccount.name) @@ -805,37 +816,11 @@ class AccountControllerTest @Autowired constructor( @Test fun `should update the account with valid image`() { - val uuid: UUID = UUID.randomUUID() - val mockedSettings = Mockito.mockStatic(UUID::class.java) - Mockito.`when`(UUID.randomUUID()).thenReturn(uuid) - - val newName = "Test Account 2" - val newEmail = "test_account2@test.com" - val newBio = "This is a test account altered" - val newBirthDate = TestUtils.createDate(2003, Calendar.JULY, 28) - val newLinkedin = "https://linkedin2.com" - val newGithub = "https://github2.com" - val newWebsites = listOf( - CustomWebsite("https://test-website2.com", "https://test-website.com/logo.png") - ) - val expectedPhotoPath = "${uploadConfigProperties.staticServe}/profile/$newEmail-$uuid.jpeg" - val data = objectMapper.writeValueAsString( - mapOf( - "name" to newName, - "email" to newEmail, - "bio" to newBio, - "birthDate" to newBirthDate, - "linkedin" to newLinkedin, - "github" to newGithub, - "websites" to newWebsites - ) - ) - mockMvc.multipartBuilder("/accounts/${testAccount.id}") .asPutMethod() - .addPart("dto", data) + .addPart("account", objectMapper.writeValueAsString(data)) .addFile() .perform() .andExpectAll( @@ -869,38 +854,46 @@ class AccountControllerTest @Autowired constructor( Assertions.assertEquals(newLinkedin, updatedAccount.linkedin) Assertions.assertEquals(newWebsites[0].url, updatedAccount.websites[0].url) Assertions.assertEquals(newWebsites[0].iconPath, updatedAccount.websites[0].iconPath) - - mockedSettings.close() } @Test - fun `should fail if the account does not exist`() { - val newName = "Test Account 2" - val newEmail = "test_account2@test.com" - val newBio = "This is a test account altered" - val newBirthDate = TestUtils.createDate(2003, Calendar.JULY, 28) - val newPhotoPath = "https://test-photo2.com" - val newLinkedin = "https://linkedin2.com" - val newGithub = "https://github2.com" - val newWebsites = listOf( - CustomWebsite("https://test-website2.com", "https://test-website.com/logo.png") - ) + fun `should fail to update account with invalid filename extension`() { + mockMvc.multipartBuilder("/accounts/${testAccount.id}") + .asPutMethod() + .addPart("account", objectMapper.writeValueAsString(data)) + .addFile(filename = "photo.pdf") + .perform() + .andExpectAll( + status().isBadRequest, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("invalid image type (png, jpg, jpeg or webp)"), + jsonPath("$.errors[0].param").value("updateAccountById.photo") + ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + } - val data = objectMapper.writeValueAsString( - mapOf( - "name" to newName, - "email" to newEmail, - "bio" to newBio, - "birthDate" to newBirthDate, - "photoPath" to newPhotoPath, - "linkedin" to newLinkedin, - "github" to newGithub, - "websites" to newWebsites + @Test + fun `should fail to update account with invalid filename media type`() { + mockMvc.multipartBuilder("/accounts/${testAccount.id}") + .asPutMethod() + .addPart("account", objectMapper.writeValueAsString(data)) + .addFile(contentType = MediaType.APPLICATION_PDF_VALUE) + .perform() + .andExpectAll( + status().isBadRequest, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("invalid image type (png, jpg, jpeg or webp)"), + jsonPath("$.errors[0].param").value("updateAccountById.photo") ) - ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + } + @Test + fun `should fail if the account does not exist`() { mockMvc.multipartBuilder("/accounts/${1234}") - .addPart("dto", data) + .addPart("account", objectMapper.writeValueAsString(data)) .asPutMethod() .perform() .andExpectAll( @@ -916,6 +909,208 @@ class AccountControllerTest @Autowired constructor( // ) } + @Test + fun `should fail when missing account part`() { + mockMvc.multipartBuilder("/accounts/${testAccount.id}") + .asPutMethod() + .perform() + .andExpectAll( + status().isBadRequest, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("required"), + jsonPath("$.errors[0].param").value("account") + ) + } + + @NestedTest + @DisplayName("Input Validation") + inner class InputValidation { + private val validationTester = ValidationTester( + req = { params: Map -> + mockMvc.multipartBuilder("/accounts/${testAccount.id}") + .addPart("account", objectMapper.writeValueAsString(params)) + .asPutMethod() + .perform() + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + }, + requiredFields = mapOf( + "name" to testAccount.name, + "email" to "new_email@email.com", + "websites" to emptyList() + ) + ) + + @NestedTest + @DisplayName("name") + inner class NameValidation { + @BeforeAll + fun setParam() { + validationTester.param = "name" + } + + @Test + fun `should be required`() = validationTester.isRequired() + + @Test + @DisplayName("size should be between ${Constants.Name.minSize} and ${Constants.Name.maxSize}()") + fun size() = validationTester.hasSizeBetween(Constants.Name.minSize, Constants.Name.maxSize) + } + + @NestedTest + @DisplayName("email") + inner class EmailValidation { + @BeforeAll + fun setParam() { + validationTester.param = "email" + } + + @Test + fun `should be required`() = validationTester.isRequired() + + @Test + fun `should not be empty`() = validationTester.isNotEmpty() + + @Test + fun `should be a valid email`() = validationTester.isEmail() + } + + @NestedTest + @DisplayName("bio") + inner class BioValidation { + @BeforeAll + fun setParam() { + validationTester.param = "bio" + } + + @Test + @DisplayName("size should be between ${Constants.Bio.minSize} and ${Constants.Bio.maxSize}()") + fun size() = + validationTester.hasSizeBetween(Constants.Bio.minSize, Constants.Bio.maxSize) + } + + @NestedTest + @DisplayName("birthDate") + inner class BirthDateValidation { + @BeforeAll + fun setParam() { + validationTester.param = "birthDate" + } + + @Test + fun `should be a valid date`() = validationTester.isDate() + + @Test + fun `should be in the past`() = validationTester.isPastDate() + } + + @NestedTest + @DisplayName("linkedin") + inner class LinkedinValidation { + @BeforeAll + fun setParam() { + validationTester.param = "linkedin" + } + + @Test + fun `should be null or not blank`() = validationTester.isNullOrNotBlank() + + @Test + fun `should be URL`() = validationTester.isUrl() + } + + @NestedTest + @DisplayName("github") + inner class GithubValidation { + @BeforeAll + fun setParam() { + validationTester.param = "github" + } + + @Test + fun `should be null or not blank`() = validationTester.isNullOrNotBlank() + + @Test + fun `should be URL`() = validationTester.isUrl() + } + + @NestedTest + @DisplayName("websites") + inner class WebsitesValidation { + private val validationTester = ValidationTester( + req = { params: Map -> + val accountPart = objectMapper.writeValueAsString( + mapOf( + "name" to testAccount.name, + "email" to "new_email@email.com", + "websites" to listOf(params) + ) + ) + + mockMvc.multipartBuilder("/accounts/${testAccount.id}") + .addPart( + "account", + accountPart + ) + .asPutMethod() + .perform() + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + }, + requiredFields = mapOf( + "url" to "https://www.google.com" + ) + ) + + @NestedTest + @DisplayName("url") + inner class UrlValidation { + @BeforeAll + fun setParam() { + validationTester.param = "url" + } + + @Test + fun `should be required`() { + validationTester.parameterName = "url" + validationTester.isRequired() + } + + @Test + fun `should not be empty`() { + validationTester.parameterName = "websites[0].url" + validationTester.isNotEmpty() + } + + @Test + fun `should be URL`() { + validationTester.parameterName = "websites[0].url" + validationTester.isUrl() + } + } + + @NestedTest + @DisplayName("iconPath") + inner class IconPathValidation { + @BeforeAll + fun setParam() { + validationTester.param = "iconPath" + } + + @Test + fun `should be null or not blank`() { + validationTester.parameterName = "websites[0].iconPath" + validationTester.isNullOrNotBlank() + } + + @Test + fun `must be URL`() { + validationTester.parameterName = "websites[0].iconPath" + validationTester.isUrl() + } + } + } + } + @Test fun `should fail if the new email is already taken`() { val newName = "Test Account 2" @@ -925,7 +1120,7 @@ class AccountControllerTest @Autowired constructor( val newLinkedin = "https://linkedin2.com" val newGithub = "https://github2.com" val newWebsites = listOf( - CustomWebsite("https://test-website2.com", "https://test-website.com/logo.png") + CustomWebsite("https://test-website2.com", "https://test-website.com/logo.png", "test") ) val data = objectMapper.writeValueAsString( @@ -942,7 +1137,7 @@ class AccountControllerTest @Autowired constructor( ) mockMvc.multipartBuilder("/accounts/${testAccount.id}") - .addPart("dto", data) + .addPart("account", data) .asPutMethod() .perform() .andExpectAll( diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AuthControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AuthControllerTest.kt index 71c6c02d..e046549f 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AuthControllerTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AuthControllerTest.kt @@ -53,7 +53,7 @@ class AuthControllerTest @Autowired constructor( "https://linkedin.com", "https://github.com", listOf( - CustomWebsite("https://test-website.com", "https://test-website.com/logo.png") + CustomWebsite("https://test-website.com", "https://test-website.com/logo.png", "test") ), mutableListOf() ) diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/EventControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/EventControllerTest.kt index ca0e12fb..f8e1e6c2 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/EventControllerTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/EventControllerTest.kt @@ -4,22 +4,24 @@ import com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName import com.fasterxml.jackson.databind.ObjectMapper import java.util.Calendar import java.util.Date +import java.util.UUID +import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.MediaType import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get -import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import pt.up.fe.ni.website.backend.config.upload.UploadConfigProperties import pt.up.fe.ni.website.backend.model.Account import pt.up.fe.ni.website.backend.model.CustomWebsite import pt.up.fe.ni.website.backend.model.Event @@ -36,13 +38,15 @@ import pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model.Payl import pt.up.fe.ni.website.backend.utils.documentation.utils.MockMVCExtension.Companion.andDocument import pt.up.fe.ni.website.backend.utils.documentation.utils.MockMVCExtension.Companion.andDocumentEmptyObjectResponse import pt.up.fe.ni.website.backend.utils.documentation.utils.MockMVCExtension.Companion.andDocumentErrorResponse +import pt.up.fe.ni.website.backend.utils.mockmvc.multipartBuilder @ControllerTest internal class EventControllerTest @Autowired constructor( val mockMvc: MockMvc, val objectMapper: ObjectMapper, val repository: EventRepository, - val accountRepository: AccountRepository + val accountRepository: AccountRepository, + val uploadConfigProperties: UploadConfigProperties ) { final val testAccount = Account( "Test Account", @@ -54,7 +58,7 @@ internal class EventControllerTest @Autowired constructor( "https://linkedin.com", "https://github.com", listOf( - CustomWebsite("https://test-website.com", "https://test-website.com/logo.png") + CustomWebsite("https://test-website.com", "https://test-website.com/logo.png", "test") ) ) @@ -64,14 +68,14 @@ internal class EventControllerTest @Autowired constructor( mutableListOf(testAccount), mutableListOf(), "great-event", + "cool-image.png", "https://docs.google.com/forms", DateInterval( TestUtils.createDate(2022, Calendar.JULY, 28), TestUtils.createDate(2022, Calendar.JULY, 30) ), "FEUP", - "Great Events", - "https://example.com/exampleThumbnail" + "Great Events" ) val documentation = PayloadEvent() @@ -87,14 +91,14 @@ internal class EventControllerTest @Autowired constructor( mutableListOf(), mutableListOf(), null, + "bad-image.png", null, DateInterval( TestUtils.createDate(2021, Calendar.OCTOBER, 27), null ), null, - null, - "https://example.com/exampleThumbnail2" + null ) ) @@ -147,7 +151,7 @@ internal class EventControllerTest @Autowired constructor( jsonPath("$.dateInterval.endDate").value(testEvent.dateInterval.endDate.toJson()), jsonPath("$.location").value(testEvent.location), jsonPath("$.category").value(testEvent.category), - jsonPath("$.thumbnailPath").value(testEvent.thumbnailPath), + jsonPath("$.image").value(testEvent.image), jsonPath("$.slug").value(testEvent.slug) ) @@ -202,7 +206,7 @@ internal class EventControllerTest @Autowired constructor( jsonPath("$.dateInterval.endDate").value(testEvent.dateInterval.endDate.toJson()), jsonPath("$.location").value(testEvent.location), jsonPath("$.category").value(testEvent.category), - jsonPath("$.thumbnailPath").value(testEvent.thumbnailPath), + jsonPath("$.image").value(testEvent.image), jsonPath("$.slug").value(testEvent.slug) ) .andDocument( @@ -237,14 +241,14 @@ internal class EventControllerTest @Autowired constructor( mutableListOf(testAccount), mutableListOf(), null, + "bad-image.png", null, DateInterval( TestUtils.createDate(2021, Calendar.OCTOBER, 27), null ), null, - null, - "https://example.com/exampleThumbnail2" + null ), Event( "Mid event", @@ -252,14 +256,14 @@ internal class EventControllerTest @Autowired constructor( mutableListOf(), mutableListOf(), null, + "mid-image.png", null, DateInterval( TestUtils.createDate(2022, Calendar.JANUARY, 15), null ), null, - "Other category", - "https://example.com/exampleThumbnail2" + "Other category" ), Event( "Cool event", @@ -267,14 +271,14 @@ internal class EventControllerTest @Autowired constructor( mutableListOf(testAccount), mutableListOf(), null, + "cool-image.png", null, DateInterval( TestUtils.createDate(2022, Calendar.SEPTEMBER, 11), null ), null, - "Great Events", - "https://example.com/exampleThumbnail2" + "Great Events" ) ) @@ -309,32 +313,44 @@ internal class EventControllerTest @Autowired constructor( @NestedTest @DisplayName("POST /events/new") inner class CreateEvent { + private val uuid: UUID = UUID.randomUUID() + private val mockedSettings = Mockito.mockStatic(UUID::class.java) + private val expectedImagePath = "${uploadConfigProperties.staticServe}/events/${testEvent.title}-$uuid.jpeg" + @BeforeEach fun addAccount() { accountRepository.save(testAccount) } + @BeforeAll + fun setupMocks() { + Mockito.`when`(UUID.randomUUID()).thenReturn(uuid) + } + + @AfterAll + fun cleanup() { + mockedSettings.close() + } + @Test fun `should create a new event`() { - mockMvc.perform( - post("/events/new") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "title" to testEvent.title, - "description" to testEvent.description, - "dateInterval" to testEvent.dateInterval, - "teamMembersIds" to mutableListOf(testAccount.id!!), - "registerUrl" to testEvent.registerUrl, - "location" to testEvent.location, - "category" to testEvent.category, - "thumbnailPath" to testEvent.thumbnailPath, - "slug" to testEvent.slug - ) - ) - ) + val eventPart = objectMapper.writeValueAsString( + mapOf( + "title" to testEvent.title, + "description" to testEvent.description, + "dateInterval" to testEvent.dateInterval, + "teamMembersIds" to mutableListOf(testAccount.id!!), + "registerUrl" to testEvent.registerUrl, + "location" to testEvent.location, + "category" to testEvent.category, + "slug" to testEvent.slug + ) ) + + mockMvc.multipartBuilder("/events/new") + .addPart("event", eventPart) + .addFile(name = "image") + .perform() .andExpectAll( status().isOk, content().contentType(MediaType.APPLICATION_JSON), @@ -347,15 +363,15 @@ internal class EventControllerTest @Autowired constructor( jsonPath("$.dateInterval.endDate").value(testEvent.dateInterval.endDate.toJson()), jsonPath("$.location").value(testEvent.location), jsonPath("$.category").value(testEvent.category), - jsonPath("$.thumbnailPath").value(testEvent.thumbnailPath), + jsonPath("$.image").value(expectedImagePath), jsonPath("$.slug").value(testEvent.slug) ) - .andDocument( - documentation, - "Create new events", - "This endpoint operation creates a new event.", - documentRequestPayload = true - ) +// .andDocument( +// documentation, +// "Create new events", +// "This endpoint operation creates a new event.", +// documentRequestPayload = true +// ) } @Test @@ -366,26 +382,26 @@ internal class EventControllerTest @Autowired constructor( mutableListOf(testAccount), mutableListOf(), testEvent.slug, + "duplicated-slug.png", "https://docs.google.com/forms", DateInterval( TestUtils.createDate(2022, Calendar.AUGUST, 28), TestUtils.createDate(2022, Calendar.AUGUST, 30) ), "FEUP", - "Great Events", - "https://example.com/exampleThumbnail" + "Great Events" ) - mockMvc.post("/events/new") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(testEvent) - }.andExpect { status { isOk() } } + mockMvc.multipartBuilder("/events/new") + .addPart("event", objectMapper.writeValueAsString(testEvent)) + .addFile(name = "image") + .perform() + .andExpect { status().isOk } - mockMvc.perform( - post("/events/new") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(duplicatedSlugEvent)) - ) + mockMvc.multipartBuilder("/events/new") + .addPart("event", objectMapper.writeValueAsString(duplicatedSlugEvent)) + .addFile(name = "image") + .perform() .andExpectAll( status().isUnprocessableEntity, content().contentType(MediaType.APPLICATION_JSON), @@ -395,23 +411,68 @@ internal class EventControllerTest @Autowired constructor( .andDocumentErrorResponse(documentation, hasRequestPayload = true) } + @Test + fun `should fail to create event with invalid filename extension`() { + mockMvc.multipartBuilder("/events/new") + .addPart("event", objectMapper.writeValueAsString(testEvent)) + .addFile(name = "image", filename = "image.pdf") + .perform() + .andExpectAll( + status().isBadRequest, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("invalid image type (png, jpg, jpeg or webp)"), + jsonPath("$.errors[0].param").value("createEvent.image") + ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + } + + @Test + fun `should fail to create event with invalid filename media type`() { + mockMvc.multipartBuilder("/events/new") + .addPart("event", objectMapper.writeValueAsString(testEvent)) + .addFile(name = "image", contentType = MediaType.APPLICATION_PDF_VALUE) + .perform() + .andExpectAll( + status().isBadRequest, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("invalid image type (png, jpg, jpeg or webp)"), + jsonPath("$.errors[0].param").value("createEvent.image") + ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + } + + @Test + fun `should fail when missing event part`() { + mockMvc.multipartBuilder("/events/new") + .addFile(name = "image") + .perform() + .andExpectAll( + status().isBadRequest, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("required"), + jsonPath("$.errors[0].param").value("event") + ) + } + @NestedTest @DisplayName("Input Validation") inner class InputValidation { private val validationTester = ValidationTester( req = { params: Map -> - mockMvc.perform( - post("/events/new") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(params)) - ) + mockMvc.multipartBuilder("/events/new") + .addPart("event", objectMapper.writeValueAsString(params)) + .addFile(name = "image") + .perform() .andDocumentErrorResponse(documentation, hasRequestPayload = true) }, requiredFields = mapOf( "title" to testEvent.title, "description" to testEvent.description, "dateInterval" to testEvent.dateInterval, - "thumbnailPath" to testEvent.thumbnailPath, + "image" to testEvent.image, "slug" to testEvent.slug ) ) @@ -517,24 +578,6 @@ internal class EventControllerTest @Autowired constructor( validationTester.hasSizeBetween(Constants.Category.minSize, Constants.Category.maxSize) } - @NestedTest - @DisplayName("thumbnailPath") - inner class ThumbnailPathValidation { - @BeforeAll - fun setParam() { - validationTester.param = "thumbnailPath" - } - - @Test - fun `should be required`() = validationTester.isRequired() - - @Test - fun `should be a URL`() = validationTester.isUrl() - - @Test - fun `should not be empty`() = validationTester.isNotEmpty() - } - @NestedTest @DisplayName("slug") inner class SlugValidation { @@ -611,7 +654,7 @@ internal class EventControllerTest @Autowired constructor( "https://linkedin.com", "https://github.com", listOf( - CustomWebsite("https://test-website.com", "https://test-website.com/logo.png") + CustomWebsite("https://test-website.com", "https://test-website.com/logo.png", "test") ) ) @@ -677,7 +720,7 @@ internal class EventControllerTest @Autowired constructor( } @NestedTest - @DisplayName("PUT /events/{projectId}/addTeamMember/{accountId}") + @DisplayName("PUT /events/{projectId}/removeTeamMember/{accountId}") inner class RemoveTeamMember { @BeforeEach @@ -724,70 +767,191 @@ internal class EventControllerTest @Autowired constructor( @NestedTest @DisplayName("PUT /events/{eventId}") inner class UpdateEvent { + private val testAccount2 = Account( + "Test Account2", + "test_account2@test.com", + "test_password", + "This is a test account2", + TestUtils.createDate(2001, Calendar.JULY, 28), + "https://test-photo.com", + "https://linkedin.com", + "https://github.com" + ) + + private val uuid: UUID = UUID.randomUUID() + private val mockedSettings = Mockito.mockStatic(UUID::class.java) + + private val newTitle = "New event title" + private val newDescription = "New event description" + private val newTeamMembers = mutableListOf() + private val newRegisterUrl = "https://example.com/newUrl" + private val newDateInterval = DateInterval( + TestUtils.createDate(2022, Calendar.DECEMBER, 1), + TestUtils.createDate(2022, Calendar.DECEMBER, 2) + ) + private val newLocation = "FLUP" + private val newCategory = "Greatest Events" + private val newSlug = "new-slug" + + private val parameters = listOf(parameterWithName("id").description("ID of the event to update")) + private lateinit var eventPart: MutableMap + @BeforeEach fun addToRepositories() { accountRepository.save(testAccount) + accountRepository.save(testAccount2) repository.save(testEvent) + + newTeamMembers.clear() + newTeamMembers.add(testAccount2.id!!) + eventPart = mutableMapOf( + "title" to newTitle, + "description" to newDescription, + "teamMembersIds" to newTeamMembers, + "registerUrl" to newRegisterUrl, + "dateInterval" to newDateInterval, + "location" to newLocation, + "category" to newCategory, + "slug" to newSlug + ) + } + + @BeforeAll + fun setupMocks() { + Mockito.`when`(UUID.randomUUID()).thenReturn(uuid) } - val parameters = listOf(parameterWithName("id").description("ID of the event to update")) + @AfterAll + fun cleanup() { + mockedSettings.close() + } @Test - fun `should update the event`() { - val newTitle = "New event title" - val newDescription = "New event description" - val newTeamMembers = mutableListOf() - val newRegisterUrl = "https://example.com/newUrl" - val newDateInterval = DateInterval( - TestUtils.createDate(2022, Calendar.DECEMBER, 1), - TestUtils.createDate(2022, Calendar.DECEMBER, 2) - ) - val newLocation = "FLUP" - val newCategory = "Greatest Events" - val newThumbnailPath = "https://thumbnails/new.png" - val newSlug = "new-slug" - - mockMvc.perform( - put("/events/{id}", testEvent.id) - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "title" to newTitle, - "description" to newDescription, - "teamMembersIds" to newTeamMembers, - "registerUrl" to newRegisterUrl, - "dateInterval" to newDateInterval, - "location" to newLocation, - "category" to newCategory, - "thumbnailPath" to newThumbnailPath, - "slug" to newSlug - ) - ) - ) - ) + fun `should update the event without image`() { + mockMvc.multipartBuilder("/events/${testEvent.id}") + .asPutMethod() + .addPart("event", objectMapper.writeValueAsString(eventPart)) + .perform() + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.title").value(newTitle), + jsonPath("$.description").value(newDescription), + jsonPath("$.teamMembers.length()").value(newTeamMembers.size), + jsonPath("$.teamMembers[0].id").value(testAccount2.id), + jsonPath("$.registerUrl").value(newRegisterUrl), + jsonPath("$.dateInterval.startDate").value(newDateInterval.startDate.toJson()), + jsonPath("$.dateInterval.endDate").value(newDateInterval.endDate.toJson()), + jsonPath("$.location").value(newLocation), + jsonPath("$.category").value(newCategory), + jsonPath("$.slug").value(newSlug), + jsonPath("$.image").value(testEvent.image) + ) +// .andDocument( +// documentation, +// "Update events", +// "Update a previously created event, using its ID.", +// urlParameters = parameters, +// documentRequestPayload = true +// ) + + val updatedEvent = repository.findById(testEvent.id!!).get() + assertEquals(newTitle, updatedEvent.title) + assertEquals(newDescription, updatedEvent.description) + assertEquals(newRegisterUrl, updatedEvent.registerUrl) + assertEquals(newDateInterval.startDate.toJson(), updatedEvent.dateInterval.startDate.toJson()) + assertEquals(newDateInterval.endDate.toJson(), updatedEvent.dateInterval.endDate.toJson()) + assertEquals(newLocation, updatedEvent.location) + assertEquals(newCategory, updatedEvent.category) + assertEquals(newSlug, updatedEvent.slug) + assertEquals(testEvent.image, updatedEvent.image) + assertEquals(newTeamMembers.size, updatedEvent.teamMembers.size) + assertEquals(testAccount2.id, updatedEvent.teamMembers[0].id) + } + + @Test + fun `should update the event with same slug`() { + eventPart["slug"] = testEvent.slug!! + mockMvc.multipartBuilder("/events/${testEvent.id}") + .asPutMethod() + .addPart("event", objectMapper.writeValueAsString(eventPart)) + .perform() .andExpectAll( status().isOk, content().contentType(MediaType.APPLICATION_JSON), jsonPath("$.title").value(newTitle), jsonPath("$.description").value(newDescription), - jsonPath("$.teamMembers.length()").value(0), + jsonPath("$.teamMembers.length()").value(newTeamMembers.size), jsonPath("$.registerUrl").value(newRegisterUrl), jsonPath("$.dateInterval.startDate").value(newDateInterval.startDate.toJson()), jsonPath("$.dateInterval.endDate").value(newDateInterval.endDate.toJson()), jsonPath("$.location").value(newLocation), jsonPath("$.category").value(newCategory), - jsonPath("$.thumbnailPath").value(newThumbnailPath), - jsonPath("$.slug").value(newSlug) + jsonPath("$.slug").value(testEvent.slug) + ) + } + + @Test + fun `should fail to update if the slug already exists`() { + val otherEvent = Event( + title = newTitle, + description = newDescription, + teamMembers = mutableListOf(), + registerUrl = newRegisterUrl, + dateInterval = DateInterval( + TestUtils.createDate(2022, Calendar.DECEMBER, 1), + TestUtils.createDate(2022, Calendar.DECEMBER, 2) + ), + location = newLocation, + category = newCategory, + image = "image.png", + slug = newSlug + ) + repository.save(otherEvent) + mockMvc.multipartBuilder("/events/${testEvent.id}") + .asPutMethod() + .addPart("event", objectMapper.writeValueAsString(eventPart)) + .perform() + .andExpectAll( + status().isUnprocessableEntity, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("slug already exists") ) - .andDocument( - documentation, - "Update events", - "Update a previously created event, using its ID.", - urlParameters = parameters, - documentRequestPayload = true + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + } + + @Test + fun `should update the event with image`() { + val expectedImagePath = "${uploadConfigProperties.staticServe}/events/$newTitle-$uuid.jpeg" + + mockMvc.multipartBuilder("/events/${testEvent.id}") + .asPutMethod() + .addPart("event", objectMapper.writeValueAsString(eventPart)) + .addFile("image", "new-image.jpeg", contentType = MediaType.IMAGE_JPEG_VALUE) + .perform() + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.title").value(newTitle), + jsonPath("$.description").value(newDescription), + jsonPath("$.teamMembers.length()").value(newTeamMembers.size), + jsonPath("$.registerUrl").value(newRegisterUrl), + jsonPath("$.dateInterval.startDate").value(newDateInterval.startDate.toJson()), + jsonPath("$.dateInterval.endDate").value(newDateInterval.endDate.toJson()), + jsonPath("$.location").value(newLocation), + jsonPath("$.category").value(newCategory), + jsonPath("$.slug").value(newSlug), + jsonPath("$.image").value(expectedImagePath) ) +// .andDocument( +// documentation, +// "Update events", +// "Update a previously created event and changes its image, using its ID.", +// urlParameters = parameters, +// documentRequestPayload = true +// ) val updatedEvent = repository.findById(testEvent.id!!).get() assertEquals(newTitle, updatedEvent.title) @@ -797,34 +961,80 @@ internal class EventControllerTest @Autowired constructor( assertEquals(newDateInterval.endDate.toJson(), updatedEvent.dateInterval.endDate.toJson()) assertEquals(newLocation, updatedEvent.location) assertEquals(newCategory, updatedEvent.category) - assertEquals(newThumbnailPath, updatedEvent.thumbnailPath) assertEquals(newSlug, updatedEvent.slug) + assertEquals(expectedImagePath, updatedEvent.image) } @Test fun `should fail if the event does not exist`() { - mockMvc.perform( - put("/events/{id}", 1234) - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "title" to "New Title", - "description" to "New Description", - "dateInterval" to DateInterval(TestUtils.createDate(2022, Calendar.DECEMBER, 1), null), - "thumbnailPath" to "http://test.com/thumbnail/1", - "associatedRoles" to testEvent.associatedRoles - ) - ) - ) + val eventPart = objectMapper.writeValueAsString( + mapOf( + "title" to "New Title", + "description" to "New Description", + "dateInterval" to DateInterval(TestUtils.createDate(2022, Calendar.DECEMBER, 1), null), + "associatedRoles" to testEvent.associatedRoles + ) ) + + mockMvc.multipartBuilder("/events/1234") + .asPutMethod() + .addPart("event", eventPart) + .perform() .andExpectAll( status().isNotFound, content().contentType(MediaType.APPLICATION_JSON), jsonPath("$.errors.length()").value(1), jsonPath("$.errors[0].message").value("event not found with id 1234") ) - .andDocumentErrorResponse(documentation, urlParameters = parameters, hasRequestPayload = true) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + } + + @Test + fun `should fail to update event with invalid filename extension`() { + mockMvc.multipartBuilder("/events/${testEvent.id}") + .asPutMethod() + .addPart("event", objectMapper.writeValueAsString(eventPart)) + .addFile(name = "image", filename = "image.pdf") + .perform() + .andExpectAll( + status().isBadRequest, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("invalid image type (png, jpg, jpeg or webp)"), + jsonPath("$.errors[0].param").value("updateEventById.image") + ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + } + + @Test + fun `should fail to update event with invalid filename media type`() { + mockMvc.multipartBuilder("/events/${testEvent.id}") + .asPutMethod() + .addPart("event", objectMapper.writeValueAsString(eventPart)) + .addFile(name = "image", contentType = MediaType.APPLICATION_PDF_VALUE) + .perform() + .andExpectAll( + status().isBadRequest, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("invalid image type (png, jpg, jpeg or webp)"), + jsonPath("$.errors[0].param").value("updateEventById.image") + ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + } + + @Test + fun `should fail when missing event part`() { + mockMvc.multipartBuilder("/events/${testEvent.id}") + .asPutMethod() + .perform() + .andExpectAll( + status().isBadRequest, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("required"), + jsonPath("$.errors[0].param").value("event") + ) } @NestedTest @@ -832,18 +1042,17 @@ internal class EventControllerTest @Autowired constructor( inner class InputValidation { private val validationTester = ValidationTester( req = { params: Map -> - mockMvc.perform( - put("/events/{id}", testEvent.id) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(params)) - ) - .andDocumentErrorResponse(documentation, urlParameters = parameters, hasRequestPayload = true) + mockMvc.multipartBuilder("/events/${testEvent.id}") + .asPutMethod() + .addPart("event", objectMapper.writeValueAsString(params)) + .perform() + .andDocumentErrorResponse(documentation, hasRequestPayload = true) }, requiredFields = mapOf( "title" to testEvent.title, "description" to testEvent.description, "dateInterval" to testEvent.dateInterval, - "thumbnailPath" to testEvent.thumbnailPath + "image" to testEvent.image ) ) @@ -950,21 +1159,6 @@ internal class EventControllerTest @Autowired constructor( fun size() = validationTester.hasSizeBetween(Constants.Category.minSize, Constants.Category.maxSize) } - - @NestedTest - @DisplayName("thumbnailPath") - inner class ThumbnailPathValidation { - @BeforeAll - fun setParam() { - validationTester.param = "thumbnailPath" - } - - @Test - fun `should be required`() = validationTester.isRequired() - - @Test - fun `should be a URL`() = validationTester.isUrl() - } } } diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/GenerationControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/GenerationControllerTest.kt index ff3c2555..df2b7c3f 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/GenerationControllerTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/GenerationControllerTest.kt @@ -1055,7 +1055,13 @@ class GenerationControllerTest @Autowired constructor( listOf(testAccount), listOf( buildTestPerActivityRole( - Project("NIJobs", "cool project") + Project( + "NIJobs", + "cool project", + image = "cool-image.png", + targetAudience = "students", + github = "https://github.com/NIAEFEUP/nijobs-be" + ) ) ) ), @@ -1077,7 +1083,7 @@ class GenerationControllerTest @Autowired constructor( dateInterval = DateInterval(TestUtils.createDate(2023, 9, 10)), location = null, category = null, - thumbnailPath = "https://www.google.com" + image = "cool-image.png" ) ) ) diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ProjectControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ProjectControllerTest.kt index 3334bd22..132ff20c 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ProjectControllerTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ProjectControllerTest.kt @@ -4,26 +4,30 @@ import com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName import com.fasterxml.jackson.databind.ObjectMapper import java.util.Calendar import java.util.Date +import java.util.UUID +import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.MediaType import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get -import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import pt.up.fe.ni.website.backend.config.upload.UploadConfigProperties import pt.up.fe.ni.website.backend.model.Account import pt.up.fe.ni.website.backend.model.CustomWebsite import pt.up.fe.ni.website.backend.model.Project -import pt.up.fe.ni.website.backend.model.constants.ActivityConstants as Constants +import pt.up.fe.ni.website.backend.model.TimelineEvent +import pt.up.fe.ni.website.backend.model.constants.ActivityConstants +import pt.up.fe.ni.website.backend.model.constants.ProjectConstants as Constants import pt.up.fe.ni.website.backend.repository.AccountRepository import pt.up.fe.ni.website.backend.repository.ProjectRepository import pt.up.fe.ni.website.backend.utils.TestUtils @@ -35,13 +39,15 @@ import pt.up.fe.ni.website.backend.utils.documentation.utils.MockMVCExtension.Co import pt.up.fe.ni.website.backend.utils.documentation.utils.MockMVCExtension.Companion.andDocumentEmptyObjectResponse import pt.up.fe.ni.website.backend.utils.documentation.utils.MockMVCExtension.Companion.andDocumentErrorResponse import pt.up.fe.ni.website.backend.utils.documentation.utils.ModelDocumentation +import pt.up.fe.ni.website.backend.utils.mockmvc.multipartBuilder @ControllerTest internal class ProjectControllerTest @Autowired constructor( val mockMvc: MockMvc, val objectMapper: ObjectMapper, val repository: ProjectRepository, - val accountRepository: AccountRepository + val accountRepository: AccountRepository, + val uploadConfigProperties: UploadConfigProperties ) { final val testAccount = Account( @@ -54,7 +60,7 @@ internal class ProjectControllerTest @Autowired constructor( "https://linkedin.com", "https://github.com", listOf( - CustomWebsite("https://test-website.com", "https://test-website.com/logo.png") + CustomWebsite("https://test-website.com", "https://test-website.com/logo.png", "Test") ) ) @@ -68,19 +74,31 @@ internal class ProjectControllerTest @Autowired constructor( "https://linkedin.com", "https://github.com", listOf( - CustomWebsite("https://test-website.com", "https://test-website.com/logo.png") + CustomWebsite("https://test-website.com", "https://test-website.com/logo.png", "Test") ) ) val testProject = Project( "Awesome project", "this is a test project", - mutableListOf(testAccount2), mutableListOf(testAccount), mutableListOf(), "awesome-project", + "cool-image.png", false, - listOf("Java", "Kotlin", "Spring") + listOf("Java", "Kotlin", "Spring"), + "Nice one", + "students", + "https://github.com/NIAEFEUP/website-niaefeup-backend", + listOf( + CustomWebsite("https://test-website.com", "https://test-website.com/logo.png", "Test") + ), + mutableListOf(testAccount2), + listOf( + TimelineEvent(TestUtils.createDate(2020, 7, 28), "This is a new event"), + TimelineEvent(TestUtils.createDate(2001, 2, 12), "This is an old event"), + TimelineEvent(TestUtils.createDate(2010, 2, 12), "This is a middle event") + ) ) val documentation: ModelDocumentation = PayloadProject() @@ -95,10 +113,13 @@ internal class ProjectControllerTest @Autowired constructor( "Job platform for students", mutableListOf(), mutableListOf(), - mutableListOf(), null, + "cool-image.png", false, - listOf("ExpressJS", "React") + listOf("ExpressJS", "React"), + "Nice one", + "students", + "https://github.com/NIAEFEUP/nijobs-fe" ) ) @@ -147,9 +168,19 @@ internal class ProjectControllerTest @Autowired constructor( content().contentType(MediaType.APPLICATION_JSON), jsonPath("$.title").value(testProject.title), jsonPath("$.description").value(testProject.description), + jsonPath("$.teamMembers.length()").value(1), + jsonPath("$.teamMembers[0].email").value(testAccount.email), + jsonPath("$.teamMembers[0].name").value(testAccount.name), jsonPath("$.technologies.length()").value(testProject.technologies.size), jsonPath("$.technologies[0]").value(testProject.technologies[0]), - jsonPath("$.slug").value(testProject.slug) + jsonPath("$.slug").value(testProject.slug), + jsonPath("$.slogan").value(testProject.slogan), + jsonPath("$.targetAudience").value(testProject.targetAudience), + jsonPath("$.github").value(testProject.github), + jsonPath("$.image").value(testProject.image), + jsonPath("$.links.length()").value(testProject.links.size), + jsonPath("$.links[0].url").value(testProject.links[0].url), + jsonPath("$.timeline.length()").value(testProject.timeline.size) ) .andDocument( documentation, @@ -160,6 +191,19 @@ internal class ProjectControllerTest @Autowired constructor( ) } + @Test + fun `should return the timeline ordered by date`() { + mockMvc.perform(get("/projects/{id}", testProject.id)) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.timeline.length()").value(testProject.timeline.size), + jsonPath("$.timeline[0].description").value("This is an old event"), + jsonPath("$.timeline[1].description").value("This is a middle event"), + jsonPath("$.timeline[2].description").value("This is a new event") + ) + } + @Test fun `should fail if the project does not exist`() { mockMvc.perform(get("/projects/{id}", 1234)) @@ -232,31 +276,49 @@ internal class ProjectControllerTest @Autowired constructor( @NestedTest @DisplayName("POST /projects/new") inner class CreateProject { + private val uuid: UUID = UUID.randomUUID() + private val mockedSettings = Mockito.mockStatic(UUID::class.java) + private val expectedImagePath = "${uploadConfigProperties.staticServe}/projects/${testProject.title}-$uuid.jpeg" + @BeforeEach fun addToRepositories() { accountRepository.save(testAccount) accountRepository.save(testAccount2) } + @BeforeAll + fun setupMocks() { + Mockito.`when`(UUID.randomUUID()).thenReturn(uuid) + } + + @AfterAll + fun cleanup() { + mockedSettings.close() + } + @Test fun `should create a new project`() { - mockMvc.perform( - post("/projects/new") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "title" to testProject.title, - "description" to testProject.description, - "hallOfFameIds" to mutableListOf(testAccount2.id!!), - "teamMembersIds" to mutableListOf(testAccount.id!!), - "isArchived" to testProject.isArchived, - "technologies" to testProject.technologies, - "slug" to testProject.slug - ) - ) - ) + val projectPart = objectMapper.writeValueAsString( + mapOf( + "title" to testProject.title, + "description" to testProject.description, + "teamMembersIds" to mutableListOf(testAccount.id!!), + "hallOfFameIds" to mutableListOf(testAccount2.id!!), + "isArchived" to testProject.isArchived, + "technologies" to testProject.technologies, + "slug" to testProject.slug, + "targetAudience" to testProject.targetAudience, + "github" to testProject.github, + "links" to testProject.links, + "timeline" to testProject.timeline, + "slogan" to testProject.slogan + ) ) + + mockMvc.multipartBuilder("/projects/new") + .addPart("project", projectPart) + .addFile(name = "image") + .perform() .andExpectAll( status().isOk, content().contentType(MediaType.APPLICATION_JSON), @@ -270,14 +332,22 @@ internal class ProjectControllerTest @Autowired constructor( jsonPath("$.teamMembers[0].name").value(testAccount.name), jsonPath("$.technologies.length()").value(testProject.technologies.size), jsonPath("$.technologies[0]").value(testProject.technologies[0]), - jsonPath("$.slug").value(testProject.slug) - ) - .andDocument( - documentation, - "Create new projects", - "This endpoint operation creates a new project.", - documentRequestPayload = true + jsonPath("$.slug").value(testProject.slug), + jsonPath("$.slogan").value(testProject.slogan), + jsonPath("$.targetAudience").value(testProject.targetAudience), + jsonPath("$.github").value(testProject.github), + jsonPath("$.image").value(expectedImagePath), + jsonPath("$.links.length()").value(testProject.links.size), + jsonPath("$.links[0].url").value(testProject.links[0].url), + jsonPath("$.timeline.length()").value(testProject.timeline.size), + jsonPath("$.timeline[0].description").value(testProject.timeline[0].description) ) +// .andDocument( +// documentation, +// "Create new projects", +// "This endpoint operation creates a new project.", +// documentRequestPayload = true +// ) } @Test @@ -286,23 +356,28 @@ internal class ProjectControllerTest @Autowired constructor( "Duplicated Slug", "this is a test project with a duplicated slug", mutableListOf(), - mutableListOf(testAccount), mutableListOf(), testProject.slug, + "cool-project.png", false, - listOf("Java", "Kotlin", "Spring") + listOf("Java", "Kotlin", "Spring"), + "Nice project", + "students", + "https://github.com/NIAEFEUP/website-niaefeup-backend", + mutableListOf(), + mutableListOf(testAccount) ) - mockMvc.post("/projects/new") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(testProject) - }.andExpect { status { isOk() } } + mockMvc.multipartBuilder("/projects/new") + .addPart("project", objectMapper.writeValueAsString(testProject)) + .addFile(name = "image") + .perform() + .andExpect { status().isOk } - mockMvc.perform( - post("/projects/new") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(duplicatedSlugProject)) - ) + mockMvc.multipartBuilder("/projects/new") + .addPart("project", objectMapper.writeValueAsString(duplicatedSlugProject)) + .addFile(name = "image") + .perform() .andExpectAll( status().isUnprocessableEntity, content().contentType(MediaType.APPLICATION_JSON), @@ -312,21 +387,67 @@ internal class ProjectControllerTest @Autowired constructor( .andDocumentErrorResponse(documentation, hasRequestPayload = true) } + @Test + fun `should fail to create project with invalid filename extension`() { + mockMvc.multipartBuilder("/projects/new") + .addPart("project", objectMapper.writeValueAsString(testProject)) + .addFile(name = "image", filename = "image.pdf") + .perform() + .andExpectAll( + status().isBadRequest, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("invalid image type (png, jpg, jpeg or webp)"), + jsonPath("$.errors[0].param").value("createProject.image") + ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + } + + @Test + fun `should fail to create project with invalid filename media type`() { + mockMvc.multipartBuilder("/projects/new") + .addPart("project", objectMapper.writeValueAsString(testProject)) + .addFile(name = "image", contentType = MediaType.APPLICATION_PDF_VALUE) + .perform() + .andExpectAll( + status().isBadRequest, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("invalid image type (png, jpg, jpeg or webp)"), + jsonPath("$.errors[0].param").value("createProject.image") + ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + } + + @Test + fun `should fail when missing project part`() { + mockMvc.multipartBuilder("/projects/new") + .addFile(name = "image") + .perform() + .andExpectAll( + status().isBadRequest, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("required"), + jsonPath("$.errors[0].param").value("project") + ) + } + @NestedTest @DisplayName("Input Validation") inner class InputValidation { private val validationTester = ValidationTester( req = { params: Map -> - mockMvc.perform( - post("/projects/new") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(params)) - ) + mockMvc.multipartBuilder("/projects/new") + .addPart("project", objectMapper.writeValueAsString(params)) + .addFile(name = "image") + .perform() .andDocumentErrorResponse(documentation, hasRequestPayload = true) }, requiredFields = mapOf( "title" to testProject.title, - "description" to testProject.description + "description" to testProject.description, + "targetAudience" to testProject.targetAudience ) ) @@ -342,8 +463,11 @@ internal class ProjectControllerTest @Autowired constructor( fun `should be required`() = validationTester.isRequired() @Test - @DisplayName("size should be between ${Constants.Title.minSize} and ${Constants.Title.maxSize}()") - fun size() = validationTester.hasSizeBetween(Constants.Title.minSize, Constants.Title.maxSize) + @DisplayName( + "size should be between ${ActivityConstants.Title.minSize} and ${ActivityConstants.Title.maxSize}()" + ) + fun size() = + validationTester.hasSizeBetween(ActivityConstants.Title.minSize, ActivityConstants.Title.maxSize) } @NestedTest @@ -359,11 +483,14 @@ internal class ProjectControllerTest @Autowired constructor( @Test @DisplayName( - "size should be between ${Constants.Description.minSize} " + - "and ${Constants.Description.maxSize}()" + "size should be between ${ActivityConstants.Description.minSize} " + + "and ${ActivityConstants.Description.maxSize}()" ) fun size() = - validationTester.hasSizeBetween(Constants.Description.minSize, Constants.Description.maxSize) + validationTester.hasSizeBetween( + ActivityConstants.Description.minSize, + ActivityConstants.Description.maxSize + ) } @NestedTest @@ -375,8 +502,57 @@ internal class ProjectControllerTest @Autowired constructor( } @Test - @DisplayName("size should be between ${Constants.Slug.minSize} and ${Constants.Slug.maxSize}()") - fun size() = validationTester.hasSizeBetween(Constants.Slug.minSize, Constants.Slug.maxSize) + @DisplayName( + "size should be between ${ActivityConstants.Slug.minSize} and ${ActivityConstants.Slug.maxSize}()" + ) + fun size() = + validationTester.hasSizeBetween(ActivityConstants.Slug.minSize, ActivityConstants.Slug.maxSize) + } + + @NestedTest + @DisplayName("slogan") + inner class SloganValidation { + @BeforeAll + fun setParam() { + validationTester.param = "slogan" + } + + @Test + @DisplayName("size should be between ${Constants.Slogan.minSize} and ${Constants.Slogan.maxSize}()") + fun size() = + validationTester.hasSizeBetween(Constants.Slogan.minSize, Constants.Slogan.maxSize) + } + + @NestedTest + @DisplayName("targetAudience") + inner class TargetAudienceValidation { + @BeforeAll + fun setParam() { + validationTester.param = "targetAudience" + } + + @Test + @DisplayName( + "size should be between ${Constants.TargetAudience.minSize} and " + + "${Constants.TargetAudience.maxSize}()" + ) + fun size() = + validationTester.hasSizeBetween(Constants.TargetAudience.minSize, Constants.TargetAudience.maxSize) + } + + @NestedTest + @DisplayName("github") + inner class GithubValidation { + @BeforeAll + fun setParam() { + validationTester.param = "github" + } + + @Test + fun `should be null or not blank`() = validationTester.isNullOrNotBlank() + + @Test + fun `should be URL`() = validationTester.isUrl() } } } @@ -430,115 +606,181 @@ internal class ProjectControllerTest @Autowired constructor( @NestedTest @DisplayName("PUT /projects/{projectId}") inner class UpdateProject { + private val uuid: UUID = UUID.randomUUID() + private val mockedSettings = Mockito.mockStatic(UUID::class.java) + + private val newTitle = "New Title" + private val newDescription = "New description of the project" + private val newTeamMembers = mutableListOf() + private val newHallOfFame = mutableListOf() + private val newIsArchived = true + private val newSlug = "new-slug" + private val newSlogan = "new slogan" + private val newTargetAudience = "new target audience" + private val newGithub = "https://github.com/NIAEFEUP/nijobs-be" + private val newLinks = mutableListOf() + private val newTimeline = mutableListOf() + + val parameters = listOf(parameterWithName("id").description("ID of the project to update")) + private lateinit var projectPart: MutableMap @BeforeEach fun addToRepositories() { + projectPart = mutableMapOf( + "title" to newTitle, + "description" to newDescription, + "teamMembersIds" to newTeamMembers, + "hallOfFameIds" to newHallOfFame, + "isArchived" to newIsArchived, + "slug" to newSlug, + "slogan" to newSlogan, + "targetAudience" to newTargetAudience, + "github" to newGithub, + "links" to newLinks, + "timeline" to newTimeline + ) + accountRepository.save(testAccount) accountRepository.save(testAccount2) repository.save(testProject) } - val parameters = listOf(parameterWithName("id").description("ID of the project to update")) + @BeforeAll + fun setupMocks() { + Mockito.`when`(UUID.randomUUID()).thenReturn(uuid) + } - @Test - fun `should update the project without the slug`() { - val newTitle = "New Title" - val newDescription = "New description of the project" - val newTeamMembers = mutableListOf() - val newIsArchived = true + @AfterAll + fun cleanup() { + mockedSettings.close() + } - mockMvc.perform( - put("/projects/{id}", testProject.id) - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "title" to newTitle, - "description" to newDescription, - "teamMembersIds" to newTeamMembers, - "isArchived" to newIsArchived - ) - ) - ) - ) + @Test + fun `should update the project without image`() { + mockMvc.multipartBuilder("/projects/${testProject.id}") + .asPutMethod() + .addPart("project", objectMapper.writeValueAsString(projectPart)) + .perform() .andExpectAll( status().isOk, content().contentType(MediaType.APPLICATION_JSON), jsonPath("$.title").value(newTitle), jsonPath("$.description").value(newDescription), - jsonPath("$.teamMembers.length()").value(0), - jsonPath("$.isArchived").value(newIsArchived) - ) - .andDocument( - documentation, - "Update projects", - "Update a previously created project, using its ID.", - urlParameters = parameters, - documentRequestPayload = true + jsonPath("$.teamMembers.length()").value(newTeamMembers.size), + jsonPath("$.hallOfFame.length()").value(newHallOfFame.size), + jsonPath("$.isArchived").value(newIsArchived), + jsonPath("$.slug").value(newSlug), + jsonPath("$.slogan").value(newSlogan), + jsonPath("$.targetAudience").value(newTargetAudience), + jsonPath("$.github").value(newGithub), + jsonPath("$.links.length()").value(newLinks.size), + jsonPath("$.timeline.length()").value(newTimeline.size), + jsonPath("$.image").value(testProject.image) ) +// .andDocument( +// documentation, +// "Update projects", +// "Update a previously created project, using its ID.", +// urlParameters = parameters, +// documentRequestPayload = true +// ) val updatedProject = repository.findById(testProject.id!!).get() assertEquals(newTitle, updatedProject.title) assertEquals(newDescription, updatedProject.description) assertEquals(newIsArchived, updatedProject.isArchived) + assertEquals(newSlug, updatedProject.slug) + assertEquals(newSlogan, updatedProject.slogan) + assertEquals(newTargetAudience, updatedProject.targetAudience) + assertEquals(newGithub, updatedProject.github) + assertEquals(newLinks, updatedProject.links) + assertEquals(newTimeline, updatedProject.timeline) + assertEquals(testProject.image, updatedProject.image) } @Test - fun `should update the project with the slug`() { - val newTitle = "New Title" - val newDescription = "New description of the project" - val newIsArchived = true - val newSlug = "new-title" + fun `should update the project with different hall of fame members`() { + projectPart["hallOfFameIds"] = listOf(testAccount.id!!) - mockMvc.perform( - put("/projects/{id}", testProject.id) - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "title" to newTitle, - "description" to newDescription, - "isArchived" to newIsArchived, - "slug" to newSlug - ) - ) - ) - ) + mockMvc.multipartBuilder("/projects/${testProject.id}") + .asPutMethod() + .addPart("project", objectMapper.writeValueAsString(projectPart)) + .perform() .andExpectAll( status().isOk, content().contentType(MediaType.APPLICATION_JSON), - jsonPath("$.title").value(newTitle), - jsonPath("$.description").value(newDescription), - jsonPath("$.isArchived").value(newIsArchived), - jsonPath("$.slug").value(newSlug) + jsonPath("$.hallOfFame.length()").value(1), + jsonPath("$.hallOfFame[0].id").value(testAccount.id!!) ) - .andDocument( - documentation, - urlParameters = parameters, - documentRequestPayload = true +// .andDocument( +// documentation, +// "Update projects", +// "Update a previously created project, using its ID.", +// urlParameters = parameters, +// documentRequestPayload = true +// ) + + val updatedProject = repository.findById(testProject.id!!).get() + assertEquals(1, updatedProject.hallOfFame.size) + assertEquals(testAccount.id, updatedProject.hallOfFame[0].id) + } + + @Test + fun `should update the project with different team members`() { + projectPart["teamMembersIds"] = listOf(testAccount2.id!!) + + mockMvc.multipartBuilder("/projects/${testProject.id}") + .asPutMethod() + .addPart("project", objectMapper.writeValueAsString(projectPart)) + .perform() + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.teamMembers.length()").value(1), + jsonPath("$.teamMembers[0].id").value(testAccount2.id!!) ) +// .andDocument( +// documentation, +// "Update projects", +// "Update a previously created project, using its ID.", +// urlParameters = parameters, +// documentRequestPayload = true +// ) val updatedProject = repository.findById(testProject.id!!).get() - assertEquals(newTitle, updatedProject.title) - assertEquals(newDescription, updatedProject.description) - assertEquals(newIsArchived, updatedProject.isArchived) - assertEquals(newSlug, updatedProject.slug) + assertEquals(1, updatedProject.teamMembers.size) + assertEquals(testAccount2.id, updatedProject.teamMembers[0].id) + } + + @Test + fun `should update the project with the same slug`() { + projectPart["slug"] = testProject.slug!! + mockMvc.multipartBuilder("/projects/${testProject.id}") + .asPutMethod() + .addPart("project", objectMapper.writeValueAsString(projectPart)) + .perform() + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.title").value(newTitle), + jsonPath("$.description").value(newDescription), + jsonPath("$.teamMembers.length()").value(newTeamMembers.size), + jsonPath("$.isArchived").value(newIsArchived), + jsonPath("$.slug").value(testProject.slug), + jsonPath("$.slogan").value(newSlogan), + jsonPath("$.targetAudience").value(newTargetAudience), + jsonPath("$.github").value(newGithub), + jsonPath("$.links.length()").value(newLinks.size), + jsonPath("$.timeline.length()").value(newTimeline.size) + ) } @Test fun `should fail if the project does not exist`() { - mockMvc.perform( - put("/projects/{id}", 1234) - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "title" to "New Title", - "description" to "New description of the project" - ) - ) - ) - ) + mockMvc.multipartBuilder("/projects/1234") + .asPutMethod() + .addPart("project", objectMapper.writeValueAsString(projectPart)) + .perform() .andExpectAll( status().isNotFound, content().contentType(MediaType.APPLICATION_JSON), @@ -547,58 +789,129 @@ internal class ProjectControllerTest @Autowired constructor( ) .andDocumentErrorResponse( documentation, - urlParameters = parameters, hasRequestPayload = true ) } @Test fun `should fail if the slug already exists`() { - val newTitle = "New Title" - val newDescription = "New description of the project" - val newIsArchived = true - val newSlug = "new-title" - - mockMvc.post("/projects/new") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString( - Project( - "Duplicated Slug", - "this is a test project with a duplicated slug", - mutableListOf(), - mutableListOf(testAccount), - mutableListOf(), - newSlug, - false, - listOf("Java", "Kotlin", "Spring") - ) - ) - }.andExpect { status { isOk() } } - - mockMvc.perform( - put("/projects/{id}", testProject.id) - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "title" to newTitle, - "description" to newDescription, - "isArchived" to newIsArchived, - "slug" to newSlug - ) - ) - ) + val otherProject = Project( + title = newTitle, + description = newDescription, + teamMembers = mutableListOf(), + image = "image.png", + slug = newSlug, + slogan = newSlogan, + targetAudience = newTargetAudience, + github = "https://github.com/NIAEFEUP/website-niaefeup-frontend", + links = mutableListOf(), + timeline = mutableListOf() ) + repository.save(otherProject) + + mockMvc.multipartBuilder("/projects/${testProject.id}") + .asPutMethod() + .addPart("project", objectMapper.writeValueAsString(projectPart)) + .perform() .andExpectAll( status().isUnprocessableEntity, content().contentType(MediaType.APPLICATION_JSON), jsonPath("$.errors.length()").value(1), jsonPath("$.errors[0].message").value("slug already exists") ) - .andDocumentErrorResponse( - documentation, - urlParameters = parameters, - hasRequestPayload = true + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + } + + @Test + fun `should update the project with image`() { + val expectedImagePath = "${uploadConfigProperties.staticServe}/projects/$newTitle-$uuid.jpeg" + + mockMvc.multipartBuilder("/projects/${testProject.id}") + .asPutMethod() + .addPart("project", objectMapper.writeValueAsString(projectPart)) + .addFile("image", "new-image.jpeg", contentType = MediaType.IMAGE_JPEG_VALUE) + .perform() + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.title").value(newTitle), + jsonPath("$.description").value(newDescription), + jsonPath("$.teamMembers.length()").value(newTeamMembers.size), + jsonPath("$.isArchived").value(newIsArchived), + jsonPath("$.slug").value(newSlug), + jsonPath("$.slogan").value(newSlogan), + jsonPath("$.targetAudience").value(newTargetAudience), + jsonPath("$.github").value(newGithub), + jsonPath("$.links.length()").value(newLinks.size), + jsonPath("$.timeline.length()").value(newTimeline.size), + jsonPath("$.image").value(expectedImagePath) + ) +// .andDocument( +// documentation, +// "Update projects", +// "Update a previously created project, using its ID.", +// urlParameters = parameters, +// documentRequestPayload = true +// ) + + val updatedProject = repository.findById(testProject.id!!).get() + assertEquals(newTitle, updatedProject.title) + assertEquals(newDescription, updatedProject.description) + assertEquals(newIsArchived, updatedProject.isArchived) + assertEquals(newSlug, updatedProject.slug) + assertEquals(newSlogan, updatedProject.slogan) + assertEquals(newTargetAudience, updatedProject.targetAudience) + assertEquals(newGithub, updatedProject.github) + assertEquals(newLinks, updatedProject.links) + assertEquals(newTimeline, updatedProject.timeline) + assertEquals(expectedImagePath, updatedProject.image) + } + + @Test + fun `should fail to update project with invalid filename extension`() { + mockMvc.multipartBuilder("/projects/${testProject.id}") + .asPutMethod() + .addPart("project", objectMapper.writeValueAsString(projectPart)) + .addFile(name = "image", filename = "image.pdf") + .perform() + .andExpectAll( + status().isBadRequest, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("invalid image type (png, jpg, jpeg or webp)"), + jsonPath("$.errors[0].param").value("updateProjectById.image") + ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + } + + @Test + fun `should fail to update project with invalid filename media type`() { + mockMvc.multipartBuilder("/projects/${testProject.id}") + .asPutMethod() + .addPart("project", objectMapper.writeValueAsString(projectPart)) + .addFile(name = "image", contentType = MediaType.APPLICATION_PDF_VALUE) + .perform() + .andExpectAll( + status().isBadRequest, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("invalid image type (png, jpg, jpeg or webp)"), + jsonPath("$.errors[0].param").value("updateProjectById.image") + ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + } + + @Test + fun `should fail when missing project part`() { + mockMvc.multipartBuilder("/projects/${testProject.id}") + .asPutMethod() + .perform() + .andExpectAll( + status().isBadRequest, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("required"), + jsonPath("$.errors[0].param").value("project") ) } @@ -607,20 +920,16 @@ internal class ProjectControllerTest @Autowired constructor( inner class InputValidation { private val validationTester = ValidationTester( req = { params: Map -> - mockMvc.perform( - put("/projects/{id}", testProject.id) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(params)) - ) - .andDocumentErrorResponse( - documentation, - urlParameters = parameters, - hasRequestPayload = true - ) + mockMvc.multipartBuilder("/projects/${testProject.id}") + .asPutMethod() + .addPart("project", objectMapper.writeValueAsString(params)) + .perform() + .andDocumentErrorResponse(documentation, hasRequestPayload = true) }, requiredFields = mapOf( "title" to testProject.title, - "description" to testProject.description + "description" to testProject.description, + "targetAudience" to testProject.targetAudience ) ) @@ -636,8 +945,11 @@ internal class ProjectControllerTest @Autowired constructor( fun `should be required`() = validationTester.isRequired() @Test - @DisplayName("size should be between ${Constants.Title.minSize} and ${Constants.Title.maxSize}()") - fun size() = validationTester.hasSizeBetween(Constants.Title.minSize, Constants.Title.maxSize) + @DisplayName( + "size should be between ${ActivityConstants.Title.minSize} and ${ActivityConstants.Title.maxSize}()" + ) + fun size() = + validationTester.hasSizeBetween(ActivityConstants.Title.minSize, ActivityConstants.Title.maxSize) } @NestedTest @@ -653,11 +965,14 @@ internal class ProjectControllerTest @Autowired constructor( @Test @DisplayName( - "size should be between ${Constants.Description.minSize}" + - " and ${Constants.Description.maxSize}()" + "size should be between ${ActivityConstants.Description.minSize}" + + " and ${ActivityConstants.Description.maxSize}()" ) fun size() = - validationTester.hasSizeBetween(Constants.Description.minSize, Constants.Description.maxSize) + validationTester.hasSizeBetween( + ActivityConstants.Description.minSize, + ActivityConstants.Description.maxSize + ) } @NestedTest @@ -669,8 +984,212 @@ internal class ProjectControllerTest @Autowired constructor( } @Test - @DisplayName("size should be between ${Constants.Slug.minSize} and ${Constants.Slug.maxSize}()") - fun size() = validationTester.hasSizeBetween(Constants.Slug.minSize, Constants.Slug.maxSize) + @DisplayName( + "size should be between ${ActivityConstants.Slug.minSize} and ${ActivityConstants.Slug.maxSize}()" + ) + fun size() = + validationTester.hasSizeBetween(ActivityConstants.Slug.minSize, ActivityConstants.Slug.maxSize) + } + + @NestedTest + @DisplayName("slogan") + inner class SloganValidation { + @BeforeAll + fun setParam() { + validationTester.param = "slogan" + } + + @Test + @DisplayName("size should be between ${Constants.Slogan.minSize} and ${Constants.Slogan.maxSize}()") + fun size() = + validationTester.hasSizeBetween(Constants.Slogan.minSize, Constants.Slogan.maxSize) + } + + @NestedTest + @DisplayName("targetAudience") + inner class TargetAudienceValidation { + @BeforeAll + fun setParam() { + validationTester.param = "targetAudience" + } + + @Test + @DisplayName( + "size should be between ${Constants.TargetAudience.minSize} and " + + "${Constants.TargetAudience.maxSize}()" + ) + fun size() = + validationTester.hasSizeBetween(Constants.TargetAudience.minSize, Constants.TargetAudience.maxSize) + } + + @NestedTest + @DisplayName("github") + inner class GithubValidation { + @BeforeAll + fun setParam() { + validationTester.param = "github" + } + + @Test + fun `should be null or not blank`() = validationTester.isNullOrNotBlank() + + @Test + fun `should be URL`() = validationTester.isUrl() + } + + @NestedTest + @DisplayName("links") + inner class LinksValidation { + private val validationTester = ValidationTester( + req = { params: Map -> + val projectPart = objectMapper.writeValueAsString( + mapOf( + "title" to testProject.title, + "description" to testProject.description, + "targetAudience" to testProject.targetAudience, + "links" to listOf(params) + ) + ) + + mockMvc.multipartBuilder("/projects/${testProject.id}") + .addPart( + "project", + projectPart + ) + .asPutMethod() + .perform() + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + }, + requiredFields = mapOf( + "url" to "https://www.google.com" + ) + ) + + @NestedTest + @DisplayName("url") + inner class UrlValidation { + @BeforeAll + fun setParam() { + validationTester.param = "url" + } + + @Test + fun `should be required`() { + validationTester.parameterName = "url" + validationTester.isRequired() + } + + @Test + fun `should not be empty`() { + validationTester.parameterName = "links[0].url" + validationTester.isNotEmpty() + } + + @Test + fun `should be URL`() { + validationTester.parameterName = "links[0].url" + validationTester.isUrl() + } + } + + @NestedTest + @DisplayName("iconPath") + inner class IconPathValidation { + @BeforeAll + fun setParam() { + validationTester.param = "iconPath" + } + + @Test + fun `should be null or not blank`() { + validationTester.parameterName = "links[0].iconPath" + validationTester.isNullOrNotBlank() + } + + @Test + fun `must be URL`() { + validationTester.parameterName = "links[0].iconPath" + validationTester.isUrl() + } + } + + @NestedTest + @DisplayName("label") + inner class LabelValidation { + @BeforeAll + fun setParam() { + validationTester.param = "label" + } + + @Test + fun `should be null or not blank`() { + validationTester.parameterName = "links[0].label" + validationTester.isNullOrNotBlank() + } + } + } + + @NestedTest + @DisplayName("timeline") + inner class TimelineValidation { + private val validationTester = ValidationTester( + req = { params: Map -> + val projectPart = objectMapper.writeValueAsString( + mapOf( + "title" to testProject.title, + "description" to testProject.description, + "targetAudience" to testProject.targetAudience, + "timeline" to listOf(params) + ) + ) + + mockMvc.multipartBuilder("/projects/${testProject.id}") + .addPart( + "project", + projectPart + ) + .asPutMethod() + .perform() + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + }, + requiredFields = mapOf( + "date" to "22-07-2021", + "description" to "test description" + ) + ) + + @NestedTest + @DisplayName("date") + inner class DateValidation { + @BeforeAll + fun setParam() { + validationTester.param = "date" + } + + @Test + fun `should be required`() = validationTester.isRequired() + } + + @NestedTest + @DisplayName("description") + inner class DescriptionValidation { + @BeforeAll + fun setParam() { + validationTester.param = "description" + } + + @Test + fun `should be required`() { + validationTester.parameterName = "description" + validationTester.isRequired() + } + + @Test + fun `should not be empty`() { + validationTester.parameterName = "timeline[0].description" + validationTester.isNotEmpty() + } + } } } } @@ -725,10 +1244,13 @@ internal class ProjectControllerTest @Autowired constructor( "very cool project", mutableListOf(), mutableListOf(), - mutableListOf(), null, + "cool-image.png", true, - listOf("React", "TailwindCSS") + listOf("React", "TailwindCSS"), + "Nice one", + "students", + "https://github.com/NIAEFEUP/website-niaefeup-frontend" ) @BeforeEach @@ -780,7 +1302,7 @@ internal class ProjectControllerTest @Autowired constructor( "https://linkedin.com", "https://github.com", listOf( - CustomWebsite("https://test-website.com", "https://test-website.com/logo.png") + CustomWebsite("https://test-website.com", "https://test-website.com/logo.png", "test") ) ) @@ -913,7 +1435,7 @@ internal class ProjectControllerTest @Autowired constructor( "https://linkedin.com", "https://github.com", listOf( - CustomWebsite("https://test-website.com", "https://test-website.com/logo.png") + CustomWebsite("https://test-website.com", "https://test-website.com/logo.png", "Test Website") ) ) @@ -1001,7 +1523,7 @@ internal class ProjectControllerTest @Autowired constructor( ) @Test - fun `should remove a account from project's hall of fame `() { + fun `should remove an account from project's hall of fame`() { mockMvc.perform( put("/projects/{idProject}/removeHallOfFameMember/{idAccount}", testProject.id, testAccount2.id) ) diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/model/AccountTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/model/AccountTest.kt index 4a91859a..dd2967d7 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/model/AccountTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/model/AccountTest.kt @@ -31,7 +31,10 @@ class AccountTest { val websiteProject = Project( "NI Website", - "NI's website is where everything about NI is shown to the public" + "NI's website is where everything about NI is shown to the public", + image = "cool-image.jpg", + targetAudience = "Everyone" + ) val managerAccount = Account( diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadAccount.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadAccount.kt index a1925c8e..54efa167 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadAccount.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadAccount.kt @@ -39,6 +39,12 @@ class PayloadAccount(includePassword: Boolean = true) : ModelDocumentation( JsonFieldType.STRING, optional = true ), + DocumentedJSONField( + "websites[].label", + "Label for the website", + JsonFieldType.STRING, + optional = true + ), DocumentedJSONField( "roles[]", "Array with the roles of the account", diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadActivity.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadActivity.kt index 60b95791..b46da124 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadActivity.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadActivity.kt @@ -34,8 +34,59 @@ class PayloadActivity { optional = true ), DocumentedJSONField( - "thumbnailPath", - "Path to the thumbnail", + "image", + "Path to the image", + JsonFieldType.STRING + ), + DocumentedJSONField( + "targetAudience", + "Information about the target audience", + JsonFieldType.STRING + ), + DocumentedJSONField( + "slogan", + "Slogan of the activity", + JsonFieldType.STRING + ), + DocumentedJSONField( + "github", + "Handle/link to the activity's GitHub repository", + JsonFieldType.STRING + ), + DocumentedJSONField( + "links", + "Array of links associated with the activity", + JsonFieldType.ARRAY, + optional = true + ), + DocumentedJSONField("links[].url", "URL to the link", JsonFieldType.STRING, optional = true), + DocumentedJSONField( + "links[].iconPath", + "URL to the link's icon", + JsonFieldType.STRING, + optional = true + ), + DocumentedJSONField( + "links[].label", + "Label for the link", + JsonFieldType.STRING, + optional = true + ), + DocumentedJSONField( + "timeline", + "Array of events defining the activity's timeline", + JsonFieldType.ARRAY, + optional = true + ), + DocumentedJSONField( + "timeline[].date", + "Date of the event", + JsonFieldType.STRING, + optional = true + ), + DocumentedJSONField( + "timeline[].description", + "Description of the event", JsonFieldType.STRING, optional = true ) diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadEvent.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadEvent.kt index 95dbf13f..e952163d 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadEvent.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadEvent.kt @@ -12,7 +12,7 @@ class PayloadEvent : ModelDocumentation( mutableListOf( DocumentedJSONField("title", "Event title", JsonFieldType.STRING), DocumentedJSONField("description", "Event description", JsonFieldType.STRING), - DocumentedJSONField("thumbnailPath", "Thumbnail of the event", JsonFieldType.STRING), + DocumentedJSONField("image", "Thumbnail image of the event", JsonFieldType.STRING), DocumentedJSONField("registerUrl", "Link to the event registration", JsonFieldType.STRING, optional = true), DocumentedJSONField("location", "Location for the event", JsonFieldType.STRING, optional = true), DocumentedJSONField("dateInterval", "Date interval of the event", JsonFieldType.OBJECT, optional = true), diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadProject.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadProject.kt index 4aacfd04..3a6204a5 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadProject.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadProject.kt @@ -26,6 +26,28 @@ class PayloadProject : ModelDocumentation( JsonFieldType.STRING, optional = true ), + DocumentedJSONField( + "image", + "Path to the image", + JsonFieldType.STRING + ), + DocumentedJSONField( + "targetAudience", + "Information about the target audience", + JsonFieldType.STRING + ), + DocumentedJSONField( + "github", + "Handle/link to the project's GitHub repository", + JsonFieldType.STRING, + optional = true + ), + DocumentedJSONField( + "slogan", + "Slogan of the project", + JsonFieldType.STRING, + optional = true + ), DocumentedJSONField("id", "Project ID", JsonFieldType.NUMBER, isInRequest = false), DocumentedJSONField( "hallOfFame", @@ -59,6 +81,51 @@ class PayloadProject : ModelDocumentation( JsonFieldType.NUMBER, optional = true, isInResponse = false + ), + DocumentedJSONField( + "links", + "Array of links associated with the project", + JsonFieldType.ARRAY, + optional = true + ), + DocumentedJSONField("links[].id", "ID of the link", JsonFieldType.NUMBER, optional = true, isInRequest = false), + DocumentedJSONField("links[].url", "URL to the link", JsonFieldType.STRING, optional = true), + DocumentedJSONField( + "links[].iconPath", + "URL to the link's icon", + JsonFieldType.STRING, + optional = true + ), + DocumentedJSONField( + "links[].label", + "Label for the link", + JsonFieldType.STRING, + optional = true + ), + DocumentedJSONField( + "timeline", + "Array of events defining the project's timeline", + JsonFieldType.ARRAY, + optional = true + ), + DocumentedJSONField( + "timeline[].id", + "ID of the event", + JsonFieldType.NUMBER, + optional = true, + isInRequest = false + ), + DocumentedJSONField( + "timeline[].date", + "Date of the event", + JsonFieldType.STRING, + optional = true + ), + DocumentedJSONField( + "timeline[].description", + "Description of the event", + JsonFieldType.STRING, + optional = true ) ).addFieldsBeneathPath( "teamMembers[]",