From a83578f4573a84e8f15d3d38b9eda83edec28332 Mon Sep 17 00:00:00 2001 From: Bruno Rosendo Date: Mon, 7 Nov 2022 12:46:54 +0000 Subject: [PATCH 01/12] Changed jackson's default date format --- src/main/kotlin/pt/up/fe/ni/website/backend/model/Post.kt | 2 ++ src/main/resources/application.properties | 1 + 2 files changed, 3 insertions(+) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Post.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Post.kt index 14116e5cf..64730c185 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Post.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Post.kt @@ -1,5 +1,6 @@ package pt.up.fe.ni.website.backend.model +import com.fasterxml.jackson.annotation.JsonFormat import com.fasterxml.jackson.annotation.JsonProperty import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.LastModifiedDate @@ -32,6 +33,7 @@ class Post( var publishDate: Date? = null, @LastModifiedDate + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") var lastUpdatedAt: Date? = null, @Id @GeneratedValue diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index ad802ca2b..656e870cc 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -17,3 +17,4 @@ server.error.whitelabel.enabled=false # Jackson spring.jackson.default-property-inclusion=non_null spring.jackson.deserialization.fail-on-null-creator-properties=true +spring.jackson.date-format=dd-MM-yyyy From d92dac6b8f298dc073ae6d72131afb09a787f81f Mon Sep 17 00:00:00 2001 From: Bruno Rosendo Date: Tue, 8 Nov 2022 02:56:17 +0000 Subject: [PATCH 02/12] Added handling for illegal argument and mismatched input exceptions --- .../website/backend/controller/ErrorController.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 4cde86665..1a6c7903a 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 @@ -1,6 +1,7 @@ package pt.up.fe.ni.website.backend.controller import com.fasterxml.jackson.databind.exc.InvalidFormatException +import com.fasterxml.jackson.databind.exc.MismatchedInputException import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException import org.springframework.boot.web.servlet.error.ErrorController import org.springframework.http.HttpStatus @@ -62,6 +63,13 @@ class ErrorController : ErrorController { param = cause.parameter.name ) } + + is MismatchedInputException -> { + return wrapSimpleError( + "must be ${cause.targetType.simpleName.lowercase()}", + param = cause.path.joinToString(".") { it.fieldName } + ) + } } return wrapSimpleError(e.message ?: "invalid request body") @@ -73,6 +81,12 @@ class ErrorController : ErrorController { return wrapSimpleError(e.message ?: "element not found") } + @ExceptionHandler(IllegalArgumentException::class) + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) + fun illegalArgument(e: IllegalArgumentException): CustomError { + return wrapSimpleError(e.message ?: "invalid argument") + } + @ExceptionHandler(Exception::class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) fun unexpectedError(e: Exception): CustomError { From 6cd85141be93e7edb752c0fe19bcf5e1f58c54bc Mon Sep 17 00:00:00 2001 From: Bruno Rosendo Date: Tue, 8 Nov 2022 02:59:00 +0000 Subject: [PATCH 03/12] Add account model and endpoints --- README.md | 2 + .../annotations/validation/NullOrNotBlank.kt | 23 ++++++++ .../backend/controller/AccountController.kt | 23 ++++++++ .../up/fe/ni/website/backend/model/Account.kt | 56 +++++++++++++++++++ .../ni/website/backend/model/CustomWebsite.kt | 22 ++++++++ .../pt/up/fe/ni/website/backend/model/Post.kt | 2 + .../model/constants/AccountConstants.kt | 13 +++++ .../website/backend/model/dto/AccountDto.kt | 15 +++++ .../backend/model/dto/CustomWebsiteDto.kt | 8 +++ .../backend/repository/AccountRepository.kt | 8 +++ .../website/backend/service/AccountService.kt | 24 ++++++++ 11 files changed, 196 insertions(+) create mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/annotations/validation/NullOrNotBlank.kt create mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/controller/AccountController.kt create mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt create mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/model/CustomWebsite.kt create mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/AccountConstants.kt create mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/AccountDto.kt create mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/CustomWebsiteDto.kt create mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/repository/AccountRepository.kt create mode 100644 src/main/kotlin/pt/up/fe/ni/website/backend/service/AccountService.kt diff --git a/README.md b/README.md index 2c40e8b8d..ef7f7ac83 100644 --- a/README.md +++ b/README.md @@ -75,4 +75,6 @@ gradle test - `dto/` - Data Transfer Objects for creating and modifying entities - `repository/` - Data access layer methods (Spring Data repositories) - `service/` - Business logic for the controllers + - `annotations/` - Custom annotations used in the project + - `validation/` - Custom validations used across the different models - `src/test/` - Self explanatory: unit tests, functional (end-to-end) tests, etc. diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/annotations/validation/NullOrNotBlank.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/annotations/validation/NullOrNotBlank.kt new file mode 100644 index 000000000..ce93a1f21 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/annotations/validation/NullOrNotBlank.kt @@ -0,0 +1,23 @@ +package pt.up.fe.ni.website.backend.annotations.validation + +import javax.validation.Constraint +import javax.validation.ConstraintValidator +import javax.validation.ConstraintValidatorContext +import javax.validation.Payload +import kotlin.reflect.KClass + +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +@Constraint(validatedBy = [NullOrNotBlankValidator::class]) +@MustBeDocumented +annotation class NullOrNotBlank( + val message: String = "must be null or not blank", + val groups: Array> = [], + val payload: Array> = [] +) + +class NullOrNotBlankValidator : ConstraintValidator { + override fun isValid(value: String?, context: ConstraintValidatorContext): Boolean { + return value == null || value.isNotBlank() + } +} 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 new file mode 100644 index 000000000..d9d4768a4 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AccountController.kt @@ -0,0 +1,23 @@ +package pt.up.fe.ni.website.backend.controller + +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.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import pt.up.fe.ni.website.backend.model.dto.AccountDto +import pt.up.fe.ni.website.backend.service.AccountService + +@RestController +@RequestMapping("/accounts") +class AccountController(private val service: AccountService) { + @GetMapping + fun getAllAccounts() = service.getAllAccounts() + + @GetMapping("/{id}") + fun getAccountById(@PathVariable id: Long) = service.getAccountById(id) + + @PostMapping("/new") + fun createAccount(@RequestBody dto: AccountDto) = service.createAccount(dto) +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt new file mode 100644 index 000000000..f8ee67531 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt @@ -0,0 +1,56 @@ +package pt.up.fe.ni.website.backend.model + +import com.fasterxml.jackson.annotation.JsonProperty +import org.hibernate.validator.constraints.URL +import pt.up.fe.ni.website.backend.annotations.validation.NullOrNotBlank +import java.util.Date +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.GeneratedValue +import javax.persistence.Id +import javax.persistence.OneToMany +import javax.persistence.Temporal +import javax.persistence.TemporalType +import javax.validation.constraints.Email +import javax.validation.constraints.NotEmpty +import javax.validation.constraints.Past +import javax.validation.constraints.Size +import pt.up.fe.ni.website.backend.model.constants.AccountConstants as Constants + +@Entity +class Account( + @JsonProperty(required = true) + @field:Size(min = Constants.Name.minSize, max = Constants.Name.maxSize) + var name: String, + + @JsonProperty(required = true) + @Column(unique = true) + @field:NotEmpty + @field:Email + var email: String, + + @field:Size(min = Constants.Name.minSize, max = Constants.Name.maxSize) + var bio: String?, + + @Temporal(TemporalType.DATE) + @field:Past + var birthDate: Date?, + + @field:NullOrNotBlank + @field:URL + var photoPath: String?, + + @field:NullOrNotBlank + @field:URL + var linkedin: String?, + + @field:NullOrNotBlank + @field:URL + var github: String?, + + @OneToMany // TODO doesn't seem to be working + val websites: List, + + @Id @GeneratedValue + 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 new file mode 100644 index 000000000..52215b511 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/CustomWebsite.kt @@ -0,0 +1,22 @@ +package pt.up.fe.ni.website.backend.model + +import com.fasterxml.jackson.annotation.JsonProperty +import org.hibernate.validator.constraints.URL +import pt.up.fe.ni.website.backend.annotations.validation.NullOrNotBlank +import javax.persistence.Entity +import javax.persistence.GeneratedValue +import javax.persistence.Id + +@Entity +class CustomWebsite( + @JsonProperty(required = true) + @field:URL + val url: String, + + @field:NullOrNotBlank + @field:URL + val iconPath: String?, + + @Id @GeneratedValue + val id: Long? = null +) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Post.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Post.kt index 64730c185..c45c394c4 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Post.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Post.kt @@ -2,6 +2,7 @@ package pt.up.fe.ni.website.backend.model import com.fasterxml.jackson.annotation.JsonFormat import com.fasterxml.jackson.annotation.JsonProperty +import org.hibernate.validator.constraints.URL import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.jpa.domain.support.AuditingEntityListener @@ -27,6 +28,7 @@ class Post( @JsonProperty(required = true) @field:NotEmpty + @field:URL var thumbnailPath: String, @CreatedDate diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/AccountConstants.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/AccountConstants.kt new file mode 100644 index 000000000..f6b3995d3 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/AccountConstants.kt @@ -0,0 +1,13 @@ +package pt.up.fe.ni.website.backend.model.constants + +object AccountConstants { + object Name { + const val minSize = 2 + const val maxSize = 100 + } + + object Bio { + const val minSize = 5 + const val maxSize = 500 + } +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/AccountDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/AccountDto.kt new file mode 100644 index 000000000..0d65b6ded --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/AccountDto.kt @@ -0,0 +1,15 @@ +package pt.up.fe.ni.website.backend.model.dto + +import pt.up.fe.ni.website.backend.model.Account +import java.util.Date + +class AccountDto( + val name: String, + val email: String, + val bio: String?, + val birthDate: Date?, + val photoPath: String?, + val linkedin: String?, + val github: String?, + val websites: List +) : Dto() diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/CustomWebsiteDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/CustomWebsiteDto.kt new file mode 100644 index 000000000..3ff4b2d7f --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/dto/CustomWebsiteDto.kt @@ -0,0 +1,8 @@ +package pt.up.fe.ni.website.backend.model.dto + +import pt.up.fe.ni.website.backend.model.CustomWebsite + +class CustomWebsiteDto( + val url: String, + val iconPath: String? +) : Dto() diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/repository/AccountRepository.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/repository/AccountRepository.kt new file mode 100644 index 000000000..748936262 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/repository/AccountRepository.kt @@ -0,0 +1,8 @@ +package pt.up.fe.ni.website.backend.repository + +import org.springframework.data.repository.CrudRepository +import pt.up.fe.ni.website.backend.model.Account + +interface AccountRepository : CrudRepository { + fun findByEmail(email: String): Account? +} 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 new file mode 100644 index 000000000..2974e27b5 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AccountService.kt @@ -0,0 +1,24 @@ +package pt.up.fe.ni.website.backend.service + +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import pt.up.fe.ni.website.backend.model.Account +import pt.up.fe.ni.website.backend.model.dto.AccountDto +import pt.up.fe.ni.website.backend.repository.AccountRepository + +@Service +class AccountService(private val repository: AccountRepository) { + fun getAllAccounts(): List = repository.findAll().toList() + + fun createAccount(dto: AccountDto): Account { + repository.findByEmail(dto.email)?.let { + throw IllegalArgumentException("email already exists") + } + + val account = dto.create() + return repository.save(account) + } + + fun getAccountById(id: Long): Account = repository.findByIdOrNull(id) + ?: throw NoSuchElementException("account not found with id $id") +} From f375df275ad194ac3a57529bfa83cc7bd9a29e78 Mon Sep 17 00:00:00 2001 From: Bruno Rosendo Date: Tue, 8 Nov 2022 16:19:46 +0000 Subject: [PATCH 04/12] Fixed user websites relationship --- .../kotlin/pt/up/fe/ni/website/backend/model/Account.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt index f8ee67531..1b66a4ad2 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt @@ -4,10 +4,13 @@ import com.fasterxml.jackson.annotation.JsonProperty import org.hibernate.validator.constraints.URL import pt.up.fe.ni.website.backend.annotations.validation.NullOrNotBlank import java.util.Date +import javax.persistence.CascadeType import javax.persistence.Column import javax.persistence.Entity +import javax.persistence.FetchType import javax.persistence.GeneratedValue import javax.persistence.Id +import javax.persistence.JoinColumn import javax.persistence.OneToMany import javax.persistence.Temporal import javax.persistence.TemporalType @@ -48,7 +51,8 @@ class Account( @field:URL var github: String?, - @OneToMany // TODO doesn't seem to be working + @JoinColumn + @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER) val websites: List, @Id @GeneratedValue From f94810bbd371d611078eac93ddd4895518907044 Mon Sep 17 00:00:00 2001 From: Bruno Rosendo Date: Wed, 16 Nov 2022 00:42:44 +0000 Subject: [PATCH 05/12] Fixed tests to reflect changes on dates and URLs --- .../pt/up/fe/ni/website/backend/model/Post.kt | 2 +- .../backend/controller/EventControllerTest.kt | 2 +- .../backend/controller/PostControllerTest.kt | 37 +++++++++++-------- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Post.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Post.kt index c45c394c4..fd56f2afe 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Post.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Post.kt @@ -35,7 +35,7 @@ class Post( var publishDate: Date? = null, @LastModifiedDate - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @JsonFormat(pattern = "dd-MM-yyyy HH:mm:ss") var lastUpdatedAt: Date? = null, @Id @GeneratedValue 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 a239f3a11..164e6f93d 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 @@ -79,7 +79,7 @@ internal class EventControllerTest @Autowired constructor( content { contentType(MediaType.APPLICATION_JSON) } jsonPath("$.title") { value(testEvent.title) } jsonPath("$.description") { value(testEvent.description) } - jsonPath("$.date") { value(containsString("2022-07-28T")) } + jsonPath("$.date") { value(containsString("28-07-2022")) } } } diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt index cb8173e0c..fe8566f1b 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt @@ -22,6 +22,7 @@ import org.springframework.test.web.servlet.post import org.springframework.test.web.servlet.put import pt.up.fe.ni.website.backend.model.Post import pt.up.fe.ni.website.backend.repository.PostRepository +import java.text.SimpleDateFormat import java.util.Date import pt.up.fe.ni.website.backend.model.constants.PostConstants as Constants @@ -36,7 +37,7 @@ internal class PostControllerTest @Autowired constructor( val testPost = Post( "New test released", "this is a test post", - "thumbnails/test.png" + "https://thumbnails/test.png" ) @Nested @@ -47,7 +48,7 @@ internal class PostControllerTest @Autowired constructor( Post( "NIAEFEUP gets a new president", "New president promised to buy new chairs", - "thumbnails/pres.png" + "https://thumbnails/pres.png" ) ) @@ -76,15 +77,17 @@ internal class PostControllerTest @Autowired constructor( @Test fun `should return the post`() { - mockMvc.get("/posts/${testPost.id}").andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.title") { value(testPost.title) } - jsonPath("$.body") { value(testPost.body) } - jsonPath("$.thumbnailPath") { value(testPost.thumbnailPath) } - jsonPath("$.publishDate") { value(testPost.publishDate.toJson()) } - jsonPath("$.lastUpdatedAt") { value(testPost.lastUpdatedAt.toJson()) } - } + mockMvc.get("/posts/${testPost.id}") + .andDo { print() } + .andExpect { + status { isOk() } + content { contentType(MediaType.APPLICATION_JSON) } + jsonPath("$.title") { value(testPost.title) } + jsonPath("$.body") { value(testPost.body) } + jsonPath("$.thumbnailPath") { value(testPost.thumbnailPath) } + jsonPath("$.publishDate") { value(testPost.publishDate.toJson()) } + jsonPath("$.lastUpdatedAt") { value(testPost.lastUpdatedAt.toJson(true)) } + } } @Test @@ -223,6 +226,7 @@ internal class PostControllerTest @Autowired constructor( inner class UpdatePost { @BeforeEach fun addPost() { + testPost.lastUpdatedAt = Date(0) repository.save(testPost) } @@ -230,7 +234,7 @@ internal class PostControllerTest @Autowired constructor( fun `should update the post`() { val newTitle = "New Title" val newBody = "New Body of the post" - val newThumbnailPath = "thumbnails/new.png" + val newThumbnailPath = "https://thumbnails/new.png" mockMvc.put("/posts/${testPost.id}") { contentType = MediaType.APPLICATION_JSON @@ -249,7 +253,7 @@ internal class PostControllerTest @Autowired constructor( jsonPath("$.body") { value(newBody) } jsonPath("$.thumbnailPath") { value(newThumbnailPath) } jsonPath("$.publishDate") { value(testPost.publishDate.toJson()) } - jsonPath("$.lastUpdatedAt") { value(not(testPost.lastUpdatedAt.toJson())) } + jsonPath("$.lastUpdatedAt") { exists() } } val updatedPost = repository.findById(testPost.id!!).get() @@ -349,8 +353,11 @@ internal class PostControllerTest @Autowired constructor( } } - fun Date?.toJson(): String { - val quotedDate = objectMapper.writeValueAsString(this) + fun Date?.toJson(includeHour: Boolean = false): String { + val dateMapper = objectMapper.copy() + if (includeHour) dateMapper.dateFormat = SimpleDateFormat("dd-MM-yyyy HH:mm:ss") + + val quotedDate = dateMapper.writeValueAsString(this) // objectMapper adds quotes to the date, so remove them return quotedDate.substring(1, quotedDate.length - 1) } From 9fa3312be3a2f776e6222081eda16bb441263d1a Mon Sep 17 00:00:00 2001 From: Bruno Rosendo Date: Wed, 16 Nov 2022 00:52:36 +0000 Subject: [PATCH 06/12] Unit tests for NullOrNotBlankValidator --- .../annotations/validation/NullOrNotBlank.kt | 2 +- .../validation/NullOrNotBlankTest.kt | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/test/kotlin/pt/up/fe/ni/website/backend/annotations/validation/NullOrNotBlankTest.kt diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/annotations/validation/NullOrNotBlank.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/annotations/validation/NullOrNotBlank.kt index ce93a1f21..d40246882 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/annotations/validation/NullOrNotBlank.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/annotations/validation/NullOrNotBlank.kt @@ -17,7 +17,7 @@ annotation class NullOrNotBlank( ) class NullOrNotBlankValidator : ConstraintValidator { - override fun isValid(value: String?, context: ConstraintValidatorContext): Boolean { + override fun isValid(value: String?, context: ConstraintValidatorContext?): Boolean { return value == null || value.isNotBlank() } } diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/annotations/validation/NullOrNotBlankTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/annotations/validation/NullOrNotBlankTest.kt new file mode 100644 index 000000000..21657fc85 --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/annotations/validation/NullOrNotBlankTest.kt @@ -0,0 +1,33 @@ +package pt.up.fe.ni.website.backend.annotations.validation + +import org.junit.jupiter.api.Test + +internal class NullOrNotBlankTest { + @Test + fun `should succeed when null`() { + val validator = NullOrNotBlankValidator() + validator.initialize(NullOrNotBlank()) + assert(validator.isValid(null, null)) + } + + @Test + fun `should succeed when not blank`() { + val validator = NullOrNotBlankValidator() + validator.initialize(NullOrNotBlank()) + assert(validator.isValid("not blank", null)) + } + + @Test + fun `should fail when empty`() { + val validator = NullOrNotBlankValidator() + validator.initialize(NullOrNotBlank()) + assert(!validator.isValid("", null)) + } + + @Test + fun `should fail when blank`() { + val validator = NullOrNotBlankValidator() + validator.initialize(NullOrNotBlank()) + assert(!validator.isValid(" ", null)) + } +} From befa5fd3c163757e856ce95b5b1fe8f6d806875b Mon Sep 17 00:00:00 2001 From: Bruno Rosendo Date: Wed, 16 Nov 2022 01:39:38 +0000 Subject: [PATCH 07/12] Fixed birthdate with wrong format --- src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt index 1b66a4ad2..e06bfa664 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt @@ -12,8 +12,6 @@ import javax.persistence.GeneratedValue import javax.persistence.Id import javax.persistence.JoinColumn import javax.persistence.OneToMany -import javax.persistence.Temporal -import javax.persistence.TemporalType import javax.validation.constraints.Email import javax.validation.constraints.NotEmpty import javax.validation.constraints.Past @@ -35,7 +33,6 @@ class Account( @field:Size(min = Constants.Name.minSize, max = Constants.Name.maxSize) var bio: String?, - @Temporal(TemporalType.DATE) @field:Past var birthDate: Date?, From 8e18ce16319171ddf761a5c40ab846b20726e2ef Mon Sep 17 00:00:00 2001 From: Bruno Rosendo Date: Wed, 16 Nov 2022 01:40:26 +0000 Subject: [PATCH 08/12] Removed unnecessary prints in tests --- .../up/fe/ni/website/backend/controller/EventControllerTest.kt | 1 - .../up/fe/ni/website/backend/controller/PostControllerTest.kt | 2 -- .../fe/ni/website/backend/controller/ProjectControllerTest.kt | 1 - 3 files changed, 4 deletions(-) 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 164e6f93d..f4e1d204e 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 @@ -73,7 +73,6 @@ internal class EventControllerTest @Autowired constructor( contentType = MediaType.APPLICATION_JSON content = objectMapper.writeValueAsString(testEvent) } - .andDo { print() } .andExpect { status { isOk() } content { contentType(MediaType.APPLICATION_JSON) } diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt index fe8566f1b..f2f2496e8 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt @@ -78,7 +78,6 @@ internal class PostControllerTest @Autowired constructor( @Test fun `should return the post`() { mockMvc.get("/posts/${testPost.id}") - .andDo { print() } .andExpect { status { isOk() } content { contentType(MediaType.APPLICATION_JSON) } @@ -110,7 +109,6 @@ internal class PostControllerTest @Autowired constructor( contentType = MediaType.APPLICATION_JSON content = objectMapper.writeValueAsString(testPost) } - .andDo { print() } .andExpect { status { isOk() } content { contentType(MediaType.APPLICATION_JSON) } 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 f46a0c438..0843d3a02 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 @@ -99,7 +99,6 @@ internal class ProjectControllerTest @Autowired constructor( contentType = MediaType.APPLICATION_JSON content = objectMapper.writeValueAsString(testProject) } - .andDo { print() } .andExpect { status { isOk() } content { contentType(MediaType.APPLICATION_JSON) } From 503626f612958e9d30086fb3b5f14a129f0a4133 Mon Sep 17 00:00:00 2001 From: Bruno Rosendo Date: Wed, 16 Nov 2022 12:21:37 +0000 Subject: [PATCH 09/12] Improving test DB consistency by setting dirty state after test class --- .../ni/website/backend/controller/EventControllerTest.kt | 6 ++++-- .../ni/website/backend/controller/PostControllerTest.kt | 9 ++++++--- .../website/backend/controller/ProjectControllerTest.kt | 8 ++++++-- 3 files changed, 16 insertions(+), 7 deletions(-) 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 f4e1d204e..da5ca3051 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 @@ -3,7 +3,6 @@ package pt.up.fe.ni.website.backend.controller import com.fasterxml.jackson.databind.ObjectMapper import org.hamcrest.CoreMatchers.containsString import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @@ -13,6 +12,7 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabas import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.http.MediaType +import org.springframework.test.annotation.DirtiesContext import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.post @@ -25,6 +25,7 @@ import pt.up.fe.ni.website.backend.model.constants.EventConstants as Constants @SpringBootTest @AutoConfigureMockMvc @AutoConfigureTestDatabase +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) internal class EventControllerTest @Autowired constructor( val mockMvc: MockMvc, val objectMapper: ObjectMapper, @@ -38,6 +39,7 @@ internal class EventControllerTest @Autowired constructor( @Nested @DisplayName("GET /events") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) inner class GetAllEvents { private val testEvents = listOf( testEvent, @@ -48,7 +50,7 @@ internal class EventControllerTest @Autowired constructor( ) ) - @BeforeEach + @BeforeAll fun addEvents() { for (event in testEvents) repository.save(event) } diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt index f2f2496e8..397f3afa2 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt @@ -15,6 +15,7 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabas import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.http.MediaType +import org.springframework.test.annotation.DirtiesContext import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.delete import org.springframework.test.web.servlet.get @@ -29,6 +30,7 @@ import pt.up.fe.ni.website.backend.model.constants.PostConstants as Constants @SpringBootTest @AutoConfigureMockMvc @AutoConfigureTestDatabase +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) internal class PostControllerTest @Autowired constructor( val mockMvc: MockMvc, val objectMapper: ObjectMapper, @@ -42,6 +44,7 @@ internal class PostControllerTest @Autowired constructor( @Nested @DisplayName("GET /posts") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) inner class GetAllPosts { private val testPosts = listOf( testPost, @@ -52,7 +55,7 @@ internal class PostControllerTest @Autowired constructor( ) ) - @BeforeEach + @BeforeAll fun addPosts() { for (post in testPosts) repository.save(post) } @@ -69,8 +72,9 @@ internal class PostControllerTest @Autowired constructor( @Nested @DisplayName("GET /posts/{postId}") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) inner class GetPost { - @BeforeEach + @BeforeAll fun addPost() { repository.save(testPost) } @@ -224,7 +228,6 @@ internal class PostControllerTest @Autowired constructor( inner class UpdatePost { @BeforeEach fun addPost() { - testPost.lastUpdatedAt = Date(0) repository.save(testPost) } 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 0843d3a02..7c7d123df 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 @@ -13,6 +13,7 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabas import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.http.MediaType +import org.springframework.test.annotation.DirtiesContext import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.delete import org.springframework.test.web.servlet.get @@ -25,6 +26,7 @@ import pt.up.fe.ni.website.backend.model.constants.ProjectConstants as Constants @SpringBootTest @AutoConfigureMockMvc @AutoConfigureTestDatabase +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) internal class ProjectControllerTest @Autowired constructor( val mockMvc: MockMvc, val objectMapper: ObjectMapper, @@ -37,6 +39,7 @@ internal class ProjectControllerTest @Autowired constructor( @Nested @DisplayName("GET /projects") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) inner class GetAllProjects { private val testProjects = listOf( testProject, @@ -46,7 +49,7 @@ internal class ProjectControllerTest @Autowired constructor( ) ) - @BeforeEach + @BeforeAll fun addProjects() { for (project in testProjects) repository.save(project) } @@ -63,8 +66,9 @@ internal class ProjectControllerTest @Autowired constructor( @Nested @DisplayName("GET /projects/{projectId}") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) inner class GetProject { - @BeforeEach + @BeforeAll fun addProject() { repository.save(testProject) } From 826f83c556e1ec64be4fb5e07f7198840c34e99a Mon Sep 17 00:00:00 2001 From: Bruno Rosendo Date: Wed, 16 Nov 2022 15:39:09 +0000 Subject: [PATCH 10/12] Created tests for account endpoints --- .../up/fe/ni/website/backend/model/Account.kt | 2 +- .../controller/AccountControllerTest.kt | 308 ++++++++++++++++++ .../backend/controller/EventControllerTest.kt | 2 +- .../backend/controller/PostControllerTest.kt | 4 +- .../controller/ProjectControllerTest.kt | 4 +- .../backend/controller/ValidationTester.kt | 51 ++- 6 files changed, 363 insertions(+), 8 deletions(-) create mode 100644 src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt index e06bfa664..cb37a0b73 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt @@ -30,7 +30,7 @@ class Account( @field:Email var email: String, - @field:Size(min = Constants.Name.minSize, max = Constants.Name.maxSize) + @field:Size(min = Constants.Bio.minSize, max = Constants.Bio.maxSize) var bio: String?, @field:Past 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 new file mode 100644 index 000000000..2dafe15b1 --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/AccountControllerTest.kt @@ -0,0 +1,308 @@ +package pt.up.fe.ni.website.backend.controller + +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post +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.repository.AccountRepository +import pt.up.fe.ni.website.backend.utils.TestUtils +import java.util.Calendar +import java.util.Date +import pt.up.fe.ni.website.backend.model.constants.AccountConstants as Constants + +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureTestDatabase +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AccountControllerTest @Autowired constructor( + val mockMvc: MockMvc, + val objectMapper: ObjectMapper, + val repository: AccountRepository +) { + val testAccount = Account( + "Test Account", + "test_account@test.com", + "This is a test account", + TestUtils.createDate(2001, Calendar.JULY, 28), + "https://test-photo.com", + "https://linkedin.com", + "https://github.com", + listOf( + CustomWebsite("https://test-website.com", "https://test-website.com/logo.png") + ) + ) + + @Nested + @DisplayName("GET /accounts") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetAllAccounts { + private val testAccounts = listOf( + testAccount, + Account( + "Test Account 2", + "test_account2@test.com", + null, + null, + null, + null, + null, + emptyList() + ) + ) + + @BeforeAll + fun addAccounts() { + for (account in testAccounts) repository.save(account) + } + + @Test + fun `should return all accounts`() { + mockMvc.get("/accounts") + .andExpect { + status { isOk() } + content { contentType(MediaType.APPLICATION_JSON) } + content { json(objectMapper.writeValueAsString(testAccounts)) } + } + } + } + + @Nested + @DisplayName("GET /accounts/{id}") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetAccount { + @BeforeAll + fun addAccount() { + repository.save(testAccount) + } + + @Test + fun `should return the account`() { + mockMvc.get("/accounts/${testAccount.id}") + .andExpect { + status { isOk() } + content { contentType(MediaType.APPLICATION_JSON) } + jsonPath("$.name") { value(testAccount.name) } + jsonPath("$.email") { value(testAccount.email) } + jsonPath("$.bio") { value(testAccount.bio) } + jsonPath("$.birthDate") { value(testAccount.birthDate.toJson()) } + jsonPath("$.photoPath") { value(testAccount.photoPath) } + jsonPath("$.linkedin") { value(testAccount.linkedin) } + 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) } + } + } + + @Test + fun `should fail if the account does not exist`() { + mockMvc.get("/accounts/1234").andExpect { + status { isNotFound() } + content { contentType(MediaType.APPLICATION_JSON) } + jsonPath("$.errors.length()") { value(1) } + jsonPath("$.errors[0].message") { value("account not found with id 1234") } + } + } + } + + @Nested + @DisplayName("POST /accounts/new") + inner class CreateAccount { + @AfterEach + fun clearAccounts() { + repository.deleteAll() + } + + @Test + fun `should create the account`() { + mockMvc.post("/accounts/new") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(testAccount) + }.andExpect { + status { isOk() } + content { contentType(MediaType.APPLICATION_JSON) } + jsonPath("$.name") { value(testAccount.name) } + jsonPath("$.email") { value(testAccount.email) } + jsonPath("$.bio") { value(testAccount.bio) } + jsonPath("$.birthDate") { value(testAccount.birthDate.toJson()) } + jsonPath("$.photoPath") { value(testAccount.photoPath) } + jsonPath("$.linkedin") { value(testAccount.linkedin) } + 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) } + } + } + + @Nested + @DisplayName("Input Validation") + inner class InputValidation { + private val validationTester = ValidationTester( + req = { params: Map -> + mockMvc.post("/accounts/new") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(params) + } + }, + requiredFields = mapOf( + "name" to testAccount.name, + "email" to testAccount.email, + "websites" to emptyList() + ) + ) + + @Nested + @DisplayName("name") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + 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) + } + + @Nested + @DisplayName("email") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + 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() + } + + @Nested + @DisplayName("bio") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + 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) + } + + @Nested + @DisplayName("birthDate") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + 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() + } + + @Nested + @DisplayName("photoPath") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class PhotoPathValidation { + @BeforeAll + fun setParam() { + validationTester.param = "photoPath" + } + + @Test + fun `should be null or not blank`() = validationTester.isNullOrNotBlank() + + @Test + fun `should be URL`() = validationTester.isUrl() + } + + @Nested + @DisplayName("linkedin") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + 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() + } + + @Nested + @DisplayName("github") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + 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() + } + } + + @Test + fun `should fail to create account with existing email`() { + mockMvc.post("/accounts/new") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(testAccount) + }.andExpect { status { isOk() } } + + mockMvc.post("/accounts/new") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(testAccount) + }.andExpect { + status { isUnprocessableEntity() } + content { contentType(MediaType.APPLICATION_JSON) } + jsonPath("$.errors.length()") { value(1) } + jsonPath("$.errors[0].message") { value("email already exists") } + } + } + } + + fun Date?.toJson(): String { + val quotedDate = objectMapper.writeValueAsString(this) + // objectMapper adds quotes to the date, so remove them + return quotedDate.substring(1, quotedDate.length - 1) + } +} 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 da5ca3051..6b2784de8 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 @@ -88,7 +88,7 @@ internal class EventControllerTest @Autowired constructor( @DisplayName("Input Validation") inner class InputValidation { private val validationTester = ValidationTester( - req = { params: Map -> + req = { params: Map -> mockMvc.post("/events/new") { contentType = MediaType.APPLICATION_JSON content = objectMapper.writeValueAsString(params) diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt index 397f3afa2..1d28f9324 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/PostControllerTest.kt @@ -128,7 +128,7 @@ internal class PostControllerTest @Autowired constructor( @DisplayName("Input Validation") inner class InputValidation { private val validationTester = ValidationTester( - req = { params: Map -> + req = { params: Map -> mockMvc.post("/posts/new") { contentType = MediaType.APPLICATION_JSON content = objectMapper.writeValueAsString(params) @@ -289,7 +289,7 @@ internal class PostControllerTest @Autowired constructor( @DisplayName("Input Validation") inner class InputValidation { private val validationTester = ValidationTester( - req = { params: Map -> + req = { params: Map -> mockMvc.put("/posts/${testPost.id}") { contentType = MediaType.APPLICATION_JSON content = objectMapper.writeValueAsString(params) 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 7c7d123df..af15489bb 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 @@ -115,7 +115,7 @@ internal class ProjectControllerTest @Autowired constructor( @DisplayName("Input Validation") inner class InputValidation { private val validationTester = ValidationTester( - req = { params: Map -> + req = { params: Map -> mockMvc.post("/projects/new") { contentType = MediaType.APPLICATION_JSON content = objectMapper.writeValueAsString(params) @@ -251,7 +251,7 @@ internal class ProjectControllerTest @Autowired constructor( @DisplayName("Input Validation") inner class InputValidation { private val validationTester = ValidationTester( - req = { params: Map -> + req = { params: Map -> mockMvc.put("/projects/${testProject.id}") { contentType = MediaType.APPLICATION_JSON content = objectMapper.writeValueAsString(params) diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ValidationTester.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ValidationTester.kt index 2d7b5dd51..e9d0ced1e 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ValidationTester.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ValidationTester.kt @@ -4,8 +4,8 @@ import org.springframework.http.MediaType import org.springframework.test.web.servlet.ResultActionsDsl class ValidationTester( - private val req: (Map) -> ResultActionsDsl, - private val requiredFields: Map = mapOf() + private val req: (Map) -> ResultActionsDsl, + private val requiredFields: Map = mapOf() ) { lateinit var param: String @@ -32,6 +32,30 @@ class ValidationTester( } } + fun isNullOrNotBlank() { + val params = requiredFields.toMutableMap() + params[param] = "" + req(params) + .expectValidationError() + .andExpect { + jsonPath("$.errors[0].message") { value("must be null or not blank") } + jsonPath("$.errors[0].param") { value(param) } + jsonPath("$.errors[0].value") { value("") } + } + } + + fun isUrl() { + val params = requiredFields.toMutableMap() + params[param] = "invalid.com" + req(params) + .expectValidationError() + .andExpect { + jsonPath("$.errors[0].message") { value("must be a valid URL") } + jsonPath("$.errors[0].param") { value(param) } + jsonPath("$.errors[0].value") { value("invalid.com") } + } + } + fun hasSizeBetween(min: Int, max: Int) { val params = requiredFields.toMutableMap() val smallValue = "a".repeat(min - 1) @@ -85,6 +109,29 @@ class ValidationTester( } } + fun isPastDate() { + val params = requiredFields.toMutableMap() + params[param] = "01-01-3000" // TODO: use a date in the future instead of hard coded + req(params) + .expectValidationError() + .andExpect { + jsonPath("$.errors[0].message") { value("must be a past date") } + jsonPath("$.errors[0].value") { value("01-01-3000") } + } + } + + fun isEmail() { + val params = requiredFields.toMutableMap() + params[param] = "not-and-email" + req(params) + .expectValidationError() + .andExpect { + jsonPath("$.errors[0].message") { value("must be a well-formed email address") } + jsonPath("$.errors[0].value") { value("not-and-email") } + jsonPath("$.errors[0].param") { value(param) } + } + } + private fun ResultActionsDsl.expectValidationError(): ResultActionsDsl { andExpect { status { isBadRequest() } From b90a37366a01387f218256c44de1cc57bacc5ffc Mon Sep 17 00:00:00 2001 From: Bruno Rosendo Date: Wed, 16 Nov 2022 18:51:55 +0000 Subject: [PATCH 11/12] Added validation for account's custom websites --- build.gradle.kts | 2 +- .../up/fe/ni/website/backend/model/Account.kt | 3 +- .../ni/website/backend/model/CustomWebsite.kt | 2 + .../controller/AccountControllerTest.kt | 68 +++++++++++++++++++ .../backend/controller/ValidationTester.kt | 22 +++--- 5 files changed, 87 insertions(+), 10 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 2f6f8819b..f23722fd8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,7 +31,7 @@ dependencies { tasks.withType { kotlinOptions { - freeCompilerArgs = listOf("-Xjsr305=strict") + freeCompilerArgs = listOf("-Xjsr305=strict", "-Xemit-jvm-type-annotations") jvmTarget = "17" } } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt index cb37a0b73..5833e958f 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Account.kt @@ -12,6 +12,7 @@ import javax.persistence.GeneratedValue import javax.persistence.Id import javax.persistence.JoinColumn import javax.persistence.OneToMany +import javax.validation.Valid import javax.validation.constraints.Email import javax.validation.constraints.NotEmpty import javax.validation.constraints.Past @@ -50,7 +51,7 @@ class Account( @JoinColumn @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER) - val websites: List, + val websites: List<@Valid CustomWebsite>, @Id @GeneratedValue 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 52215b511..7a8b73241 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 @@ -6,10 +6,12 @@ import pt.up.fe.ni.website.backend.annotations.validation.NullOrNotBlank import javax.persistence.Entity import javax.persistence.GeneratedValue import javax.persistence.Id +import javax.validation.constraints.NotEmpty @Entity class CustomWebsite( @JsonProperty(required = true) + @field:NotEmpty @field:URL val url: String, 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 2dafe15b1..8eaff2bec 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 @@ -3,6 +3,7 @@ package pt.up.fe.ni.website.backend.controller import com.fasterxml.jackson.databind.ObjectMapper import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @@ -279,6 +280,73 @@ class AccountControllerTest @Autowired constructor( @Test fun `should be URL`() = validationTester.isUrl() } + + @Nested + @DisplayName("websites") + inner class WebsitesValidation { + private val validationTester = ValidationTester( + req = { params: Map -> + mockMvc.post("/accounts/new") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString( + mapOf( + "name" to testAccount.name, + "email" to testAccount.email, + "websites" to listOf(params) + ) + ) + } + }, + requiredFields = mapOf( + "url" to "https://www.google.com" + ) + ) + + @Nested + @DisplayName("url") + inner class UrlValidation { + @BeforeEach + fun setParam() { + validationTester.param = "url" + } + + @Test + fun `should be required`() = 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() + } + } + + @Nested + @DisplayName("iconPath") + inner class IconPathValidation { + @BeforeEach + fun setParam() { + validationTester.param = "iconPath" + } + + @Test + fun `should be bull or not blank`() { + validationTester.parameterName = "websites[0].iconPath" + validationTester.isNullOrNotBlank() + } + + @Test + fun `should be URL`() { + validationTester.parameterName = "websites[0].iconPath" + validationTester.isUrl() + } + } + } } @Test diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ValidationTester.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ValidationTester.kt index e9d0ced1e..686c33bf5 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ValidationTester.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ValidationTester.kt @@ -8,15 +8,21 @@ class ValidationTester( private val requiredFields: Map = mapOf() ) { lateinit var param: String + var parameterName: String? = null + + private fun getParamName(): String { + return parameterName ?: param + } fun isRequired() { val params = requiredFields.toMutableMap() params.remove(param) req(params) + .andDo { print() } .expectValidationError() .andExpect { jsonPath("$.errors[0].message") { value("required") } - jsonPath("$.errors[0].param") { value(param) } + jsonPath("$.errors[0].param") { value(getParamName()) } } } @@ -27,7 +33,7 @@ class ValidationTester( .expectValidationError() .andExpect { jsonPath("$.errors[0].message") { value("must not be empty") } - jsonPath("$.errors[0].param") { value(param) } + jsonPath("$.errors[0].param") { value(getParamName()) } jsonPath("$.errors[0].value") { value("") } } } @@ -39,7 +45,7 @@ class ValidationTester( .expectValidationError() .andExpect { jsonPath("$.errors[0].message") { value("must be null or not blank") } - jsonPath("$.errors[0].param") { value(param) } + jsonPath("$.errors[0].param") { value(getParamName()) } jsonPath("$.errors[0].value") { value("") } } } @@ -51,7 +57,7 @@ class ValidationTester( .expectValidationError() .andExpect { jsonPath("$.errors[0].message") { value("must be a valid URL") } - jsonPath("$.errors[0].param") { value(param) } + jsonPath("$.errors[0].param") { value(getParamName()) } jsonPath("$.errors[0].value") { value("invalid.com") } } } @@ -66,7 +72,7 @@ class ValidationTester( jsonPath("$.errors[0].message") { value("size must be between $min and $max") } - jsonPath("$.errors[0].param") { value(param) } + jsonPath("$.errors[0].param") { value(getParamName()) } jsonPath("$.errors[0].value") { value(smallValue) } } @@ -78,7 +84,7 @@ class ValidationTester( jsonPath("$.errors[0].message") { value("size must be between $min and $max") } - jsonPath("$.errors[0].param") { value(param) } + jsonPath("$.errors[0].param") { value(getParamName()) } jsonPath("$.errors[0].value") { value(bigValue) } } } @@ -93,7 +99,7 @@ class ValidationTester( jsonPath("$.errors[0].message") { value("size must be greater or equal to $min") } - jsonPath("$.errors[0].param") { value(param) } + jsonPath("$.errors[0].param") { value(getParamName()) } jsonPath("$.errors[0].value") { value(smallValue) } } } @@ -128,7 +134,7 @@ class ValidationTester( .andExpect { jsonPath("$.errors[0].message") { value("must be a well-formed email address") } jsonPath("$.errors[0].value") { value("not-and-email") } - jsonPath("$.errors[0].param") { value(param) } + jsonPath("$.errors[0].param") { value(getParamName()) } } } From 823edf97d3f459b2c728852aaee937526e41392d Mon Sep 17 00:00:00 2001 From: Bruno Rosendo Date: Wed, 16 Nov 2022 18:54:30 +0000 Subject: [PATCH 12/12] Fixed typo in isEmail() validator --- .../up/fe/ni/website/backend/controller/ValidationTester.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ValidationTester.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ValidationTester.kt index 686c33bf5..2cfd90c16 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ValidationTester.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/ValidationTester.kt @@ -128,12 +128,12 @@ class ValidationTester( fun isEmail() { val params = requiredFields.toMutableMap() - params[param] = "not-and-email" + params[param] = "not-an-email" req(params) .expectValidationError() .andExpect { jsonPath("$.errors[0].message") { value("must be a well-formed email address") } - jsonPath("$.errors[0].value") { value("not-and-email") } + jsonPath("$.errors[0].value") { value("not-an-email") } jsonPath("$.errors[0].param") { value(getParamName()) } } }