diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..d810c78e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +max_line_length = 120 + +[*.{kt,kts}] +ktlint_code_style = android +ktlint_version = 0.48.2 +ij_kotlin_imports_layout=* diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index bf6d05c1..c6521ad5 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -5,7 +5,7 @@ Closes #[issue number] [Description of the changes proposed in the pull request] # Review checklist -- [ ] Properly documents API changes in `docs/openapi.yml` +- [ ] Properly documents API changes in the corresponding controller test - [ ] Contains enough appropriate tests - [ ] Behavior is as expected - [ ] Clean, well structured code diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b1547cd..5f087282 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: - master - develop pull_request: - branches: + branches: - master - develop @@ -42,6 +42,7 @@ jobs: with: name: lint-reports path: build/reports/ktlint + if: always() test: name: 'Test' @@ -75,3 +76,57 @@ jobs: path: | build/reports/tests/test build/reports/jacoco/test + if: always() + + + docs: + name: 'Docs' + needs: test + runs-on: ubuntu-latest + permissions: + pull-requests: write + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ vars.NETLIFY_SITE_ID }} + steps: + - name: Fetch sources + uses: actions/checkout@v3 + + - name: Setup Java + uses: actions/setup-java@v3 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} + + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 + with: + gradle-version: ${{ env.GRADLE_VERSION }} + + - name: Generate documentation + run: ./gradlew generateDocs + + - name: Generate redoc file + run: npx redoc-cli build docs/openapi3.json -o docs/index.html + + - name: Deploy to netlify (main) + if: github.ref == 'refs/heads/main' + run: | + npx netlify-cli deploy --dir=docs --prod + + - name: Deploy to netlify (develop) + if: github.ref == 'refs/heads/develop' + run: | + npx netlify-cli deploy --dir=docs --alias develop + + - name: Deploy to netlify (preview) + if: github.event_name == 'pull_request' + run: | + npx netlify-cli deploy --json --dir=docs > deployment_data + echo 'deploy_url='$(grep -oP '(?<="deploy_url": ")[^"]*' deployment_data) >> $GITHUB_ENV + + - uses: mshick/add-pr-comment@v2 + with: + message: | + Check the documentation preview: ${{ env.deploy_url }} + allow-repeats: true diff --git a/.gitignore b/.gitignore index c2065bc2..b763203f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,12 @@ HELP.md + +src/main/resources/application-dev.properties .gradle build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +docs/ ### STS ### .apt_generated diff --git a/README.md b/README.md index ca80a274..2cac6936 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# Website NIAEFEUP - BackEnd -[![codecov](https://codecov.io/gh/NIAEFEUP/website-niaefeup-backend/branch/develop/graph/badge.svg?token=4OPGXYESGP)](https://codecov.io/gh/NIAEFEUP/website-niaefeup-backend) +# Website NIAEFEUP - BackEnd +[![codecov](https://codecov.io/gh/NIAEFEUP/website-niaefeup-backend/branch/develop/graph/badge.svg?token=4OPGXYESGP)](https://codecov.io/gh/NIAEFEUP/website-niaefeup-backend) The online platform for NIAEFEUP. ## Development setup @@ -21,7 +21,7 @@ For automatic restart to fire up every time a source file changes, make sure tha Run the following command in your shell: ```bash -gradle bootRun +./gradlew bootRun ``` ### Linting @@ -35,7 +35,13 @@ Although IntelliJ does not provide linting suggestions for Kotlin out of the box You can fire up the analysis yourself by running in your shell: ```bash -gradle ktlintCheck +./gradlew ktlintCheck +``` + +You can fix the lint automatically by running in your shell: + +```bash +./gradlew ktlintFormat ``` #### With a git hook @@ -43,13 +49,13 @@ gradle ktlintCheck You can setup a local precommit [git hook](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) for lint analysis running a Gradle task provided by the used linting plugin: ```bash -gradle addKtlintCheckGitPreCommitHook +./gradlew addKtlintCheckGitPreCommitHook ``` Or even an auto-format hook, if that is your thing: ```bash -gradle addKtlintFormatGitPreCommitHook +./gradlew addKtlintFormatGitPreCommitHook ``` ### Testing @@ -63,9 +69,31 @@ Run the test suite as usual, selecting the respective task for running. Run the following command in your shell: ```bash -gradle test +./gradlew test +``` + + +### API Documentation +API documentation is generated through the use of the [Spring REST Docs API specification Integration (aka restdocs-api-spec)](https://github.com/ePages-de/restdocs-api-spec), a [Spring Rest Docs](https://spring.io/projects/spring-restdocs) extension that builds an [OpenAPI specification](https://www.openapis.org/) or a [Postman collection](https://learning.postman.com/docs/sending-requests/intro-to-collections/) from its description, included in the controller tests. To see examples of how to document the API, hop to one of the controller tests and read the [API documentation wiki page](https://github.com/NIAEFEUP/website-niaefeup-backend/wiki/API-documentation). + +Find the current version of the API documentation [here](https://develop--niaefeup-backend-docs.netlify.app/). + +The Postman collection is also available [here](https://develop--niaefeup-backend-docs.netlify.app/postman-collection.json). + +##### With IntelliJ +Run the `generateDocs` gradle task to generate the OpenAPI specification or the Postman collection. + +##### With the command line +Run the following command in your shell: + +```bash +./gradlew generateDocs ``` +###### Results +Find the OpenAPI specification and Postman collection under `docs/` after running the task. + + ## Project Details ### Project Structure diff --git a/build.gradle.kts b/build.gradle.kts index 2caeac66..6869e81e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,12 +1,16 @@ +import org.gradle.api.tasks.testing.logging.TestExceptionFormat +import org.gradle.api.tasks.testing.logging.TestLogEvent import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { id("org.springframework.boot") version "3.0.0" id("io.spring.dependency-management") version "1.1.0" - kotlin("jvm") version "1.7.21" - kotlin("plugin.spring") version "1.7.21" - kotlin("plugin.jpa") version "1.7.21" - id("org.jlleitschuh.gradle.ktlint") version "11.0.0" + kotlin("jvm") version "1.8.10" + kotlin("plugin.spring") version "1.8.10" + kotlin("plugin.jpa") version "1.8.10" + id("org.jlleitschuh.gradle.ktlint") version "11.2.0" + id("com.epages.restdocs-api-spec") version "0.17.1" + jacoco } @@ -29,11 +33,21 @@ dependencies { implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") + implementation("ch.qos.logback:logback-core:1.4.5") + implementation("org.slf4j:slf4j-api:2.0.6") + implementation("com.cloudinary:cloudinary:1.0.14") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") implementation("org.springframework.boot:spring-boot-starter-validation:3.0.0") developmentOnly("org.springframework.boot:spring-boot-devtools") runtimeOnly("com.h2database:h2") testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("ch.qos.logback:logback-classic:1.4.5") + testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc:3.0.0") + testImplementation("com.epages:restdocs-api-spec-mockmvc:0.17.1") + testImplementation("org.springframework.boot:spring-boot-starter-test") { + exclude(group = "org.mockito2", module = "mockito-core") + } + testImplementation("org.mockito:mockito-inline:5.0.0") } tasks.withType { @@ -45,6 +59,29 @@ tasks.withType { tasks.withType { useJUnitPlatform() + testLogging { + events = mutableSetOf( + TestLogEvent.FAILED, + TestLogEvent.SKIPPED + ) + exceptionFormat = TestExceptionFormat.FULL + showExceptions = true + showCauses = true + showStackTraces = true + + debug { + events = mutableSetOf( + TestLogEvent.FAILED, + TestLogEvent.PASSED, + TestLogEvent.SKIPPED, + TestLogEvent.STANDARD_OUT, + TestLogEvent.STANDARD_ERROR + ) + exceptionFormat = TestExceptionFormat.FULL + } + info.events = debug.events + info.exceptionFormat = debug.exceptionFormat + } } tasks.test { @@ -59,3 +96,61 @@ tasks.jacocoTestReport { html.required.set(true) } } + +// Rest Docs API Spec tasks configuration +val apiSpecTitle = "NIAEFEUP Website - Backend API specification" +val apiSpecDescription = + """This specification documents the available endpoints and possible operations on the website's backend. + |For each of the operations, its purpose, security, requests and possible responses are documented. + | + |Postman collection also available here. + """.trimMargin() + +configure { + setServer("http://localhost:8080") + title = apiSpecTitle + description = apiSpecDescription + version = "${project.version}" + format = "json" + tagDescriptionsPropertiesFile = + "src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/tag-descriptions.yaml" +} + +configure { + title = apiSpecTitle + version = "${project.version}" + baseUrl = "https://localhost:8080" +} + +tasks.register("generateDocs") { + dependsOn(tasks.named("openapi3")) + dependsOn(tasks.named("postman")) + dependsOn(tasks.named("fixExamples")) + + from("${project.buildDir}/api-spec/openapi3.json") + into(File("docs")) + + from("${project.buildDir}/api-spec/postman-collection.json") + into(File("docs")) +} + +tasks.register("fixExamples") { + dependsOn(tasks.named("openapi3")) + doLast { + val objectMapper = com.fasterxml.jackson.databind.ObjectMapper() + + val spec = objectMapper.readTree(File("${project.buildDir}/api-spec/openapi3.json")) + (spec as com.fasterxml.jackson.databind.node.ObjectNode) + .findValues("examples").forEach { examples -> + examples.forEach { example -> + (example as com.fasterxml.jackson.databind.node.ObjectNode) + .replace("value", objectMapper.readTree(example.get("value").asText())) + } + } + + objectMapper.writer().withDefaultPrettyPrinter().writeValue( + File("${project.buildDir}/api-spec/openapi3.json"), + spec + ) + } +} diff --git a/docs/openapi.yml b/docs/openapi.yml deleted file mode 100644 index a7a787f6..00000000 --- a/docs/openapi.yml +++ /dev/null @@ -1,34 +0,0 @@ -openapi: "3.0.2" - -info: - title: Website NIAEFEUP - BackEnd - version: "1.0" - -tags: - - name: "Projects" - - name: "Events" - - name: "Users" - - name: "Posts" - - name: "Misc" - -security: - - cookieAuth: [] - -paths: - /: - get: - summary: "Health check" - description: "Check if the service is up" - tags: - - "Misc" - responses: - "200": - description: "Service is up" - content: - application/json: - schema: - type: "object" - properties: - online: - type: "string" - example: "true" \ No newline at end of file diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt index de7708aa..5c1bbe1c 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt @@ -5,9 +5,10 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.runApplication import org.springframework.data.jpa.repository.config.EnableJpaAuditing import pt.up.fe.ni.website.backend.config.auth.AuthConfigProperties +import pt.up.fe.ni.website.backend.config.upload.UploadConfigProperties @SpringBootApplication -@EnableConfigurationProperties(AuthConfigProperties::class) +@EnableConfigurationProperties(AuthConfigProperties::class, UploadConfigProperties::class) @EnableJpaAuditing class BackendApplication diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/config/Logging.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/config/Logging.kt new file mode 100644 index 00000000..bd807200 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/Logging.kt @@ -0,0 +1,8 @@ +package pt.up.fe.ni.website.backend.config + +import org.slf4j.Logger +import org.slf4j.LoggerFactory.getLogger + +interface Logging { + val logger: Logger get() = getLogger(this::class.java) +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfig.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfig.kt index b9d59cd7..e07b3276 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfig.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfig.kt @@ -50,7 +50,9 @@ class AuthConfig( @Bean fun jwtEncoder(): JwtEncoder { - val jwt = RSAKey.Builder(authConfigProperties::publicKey.get()).privateKey(authConfigProperties::privateKey.get()).build() + val jwt = RSAKey.Builder(authConfigProperties::publicKey.get()).privateKey( + authConfigProperties::privateKey.get() + ).build() return NimbusJwtEncoder(ImmutableJWKSet(JWKSet(jwt))) } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfigProperties.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfigProperties.kt index eb76c213..228aba48 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfigProperties.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/auth/AuthConfigProperties.kt @@ -1,8 +1,8 @@ package pt.up.fe.ni.website.backend.config.auth -import org.springframework.boot.context.properties.ConfigurationProperties import java.security.interfaces.RSAPrivateKey import java.security.interfaces.RSAPublicKey +import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties(prefix = "auth") data class AuthConfigProperties( diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/config/upload/UploadConfig.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/config/upload/UploadConfig.kt new file mode 100644 index 00000000..6ae2b10c --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/upload/UploadConfig.kt @@ -0,0 +1,28 @@ +package pt.up.fe.ni.website.backend.config.upload + +import com.cloudinary.Cloudinary +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.util.ResourceUtils +import pt.up.fe.ni.website.backend.service.upload.CloudinaryFileUploader +import pt.up.fe.ni.website.backend.service.upload.FileUploader +import pt.up.fe.ni.website.backend.service.upload.StaticFileUploader + +@Configuration +class UploadConfig( + private val uploadConfigProperties: UploadConfigProperties +) { + @Bean + fun fileUploader(): FileUploader { + return when (uploadConfigProperties.provider) { + "cloudinary" -> CloudinaryFileUploader( + uploadConfigProperties.cloudinaryBasePath ?: "/", + Cloudinary(uploadConfigProperties.cloudinaryUrl ?: throw Error("Cloudinary URL not provided")) + ) + else -> StaticFileUploader( + uploadConfigProperties.staticPath?.let { ResourceUtils.getFile(it).absolutePath } ?: "", + uploadConfigProperties.staticServe ?: "localhost:8080" + ) + } + } +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/config/upload/UploadConfigProperties.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/config/upload/UploadConfigProperties.kt new file mode 100644 index 00000000..156105f4 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/upload/UploadConfigProperties.kt @@ -0,0 +1,12 @@ +package pt.up.fe.ni.website.backend.config.upload + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "upload") +class UploadConfigProperties( + val provider: String?, + val cloudinaryUrl: String?, + val cloudinaryBasePath: String?, + val staticPath: String?, + val staticServe: String? +) 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 48d0a00b..34cbd1c9 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 @@ -1,18 +1,28 @@ 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 pt.up.fe.ni.website.backend.dto.auth.PassRecoveryDto -import pt.up.fe.ni.website.backend.dto.entity.AccountDto +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.service.AccountService +import pt.up.fe.ni.website.backend.utils.validation.ValidImage @RestController @RequestMapping("/accounts") +@Validated class AccountController(private val service: AccountService) { @GetMapping fun getAllAccounts() = service.getAllAccounts() @@ -20,10 +30,42 @@ class AccountController(private val service: AccountService) { @GetMapping("/{id}") fun getAccountById(@PathVariable id: Long) = service.getAccountById(id) - @PostMapping("/new") - fun createAccount(@RequestBody dto: AccountDto) = service.createAccount(dto) - @PutMapping("/recoverPassword/{recoveryToken}") fun recoverPassword(@RequestBody dto: PassRecoveryDto, @PathVariable recoveryToken: String) = service.recoverPassword(recoveryToken, dto) + + @PostMapping("/changePassword/{id}") + fun changePassword(@PathVariable id: Long, @RequestBody dto: ChangePasswordDto): Map { + service.changePassword(id, dto) + return emptyMap() + } + + @PutMapping("/{id}") + fun updateAccountById( + @PathVariable id: Long, + @RequestPart dto: UpdateAccountDto, + @RequestParam + @ValidImage + photo: MultipartFile? + ): Account { + dto.photoFile = photo + return service.updateAccountById(id, dto) + } + + @DeleteMapping("/{id}") + fun deleteAccountById(@PathVariable id: Long): Map { + service.deleteAccountById(id) + 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) + } } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt index 8eb7cc6a..00bbceb3 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/AuthController.kt @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import pt.up.fe.ni.website.backend.dto.auth.LoginDto import pt.up.fe.ni.website.backend.dto.auth.TokenDto +import pt.up.fe.ni.website.backend.model.Account import pt.up.fe.ni.website.backend.service.AuthService @RestController @@ -37,8 +38,8 @@ class AuthController(val authService: AuthService) { @GetMapping @PreAuthorize("hasRole('MEMBER')") - fun checkAuthentication(): Map { + fun checkAuthentication(): Map { val account = authService.getAuthenticatedAccount() - return mapOf("authenticated_user" to account.email) + return mapOf("authenticated_user" to account) } } 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 9bfbe5e4..1546a9d1 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,5 +1,6 @@ package pt.up.fe.ni.website.backend.controller +import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.exc.InvalidFormatException import com.fasterxml.jackson.databind.exc.MismatchedInputException import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException @@ -9,11 +10,14 @@ import org.springframework.http.HttpStatus import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.security.access.AccessDeniedException import org.springframework.security.core.AuthenticationException +import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RequestMapping 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 pt.up.fe.ni.website.backend.config.Logging data class SimpleError( val message: String, @@ -25,7 +29,7 @@ data class CustomError(val errors: List) @RestController @RestControllerAdvice -class ErrorController : ErrorController { +class ErrorController(private val objectMapper: ObjectMapper) : ErrorController, Logging { @RequestMapping("/**") @ResponseStatus(HttpStatus.NOT_FOUND) @@ -40,7 +44,23 @@ class ErrorController : ErrorController { SimpleError( violation.message, violation.propertyPath.toString(), - violation.invalidValue + violation.invalidValue.takeIf { it.isSerializable() } + ) + ) + } + return CustomError(errors) + } + + @ExceptionHandler(MethodArgumentNotValidException::class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + fun invalidArguments(e: MethodArgumentNotValidException): CustomError { + val errors = mutableListOf() + e.bindingResult.fieldErrors.forEach { error -> + errors.add( + SimpleError( + error.defaultMessage ?: "invalid", + error.field, + error.rejectedValue?.takeIf { it.isSerializable() } ) ) } @@ -89,10 +109,16 @@ class ErrorController : ErrorController { return wrapSimpleError(e.message ?: "invalid argument") } + @ExceptionHandler(MaxUploadSizeExceededException::class) + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) + fun invalidFileSize(e: MaxUploadSizeExceededException): CustomError { + return wrapSimpleError(e.message ?: "maximum upload size exceeded") + } + @ExceptionHandler(Exception::class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) fun unexpectedError(e: Exception): CustomError { - System.err.println(e) + logger.error(e.message) return wrapSimpleError("unexpected error: " + e.message) } @@ -111,4 +137,11 @@ class ErrorController : ErrorController { fun wrapSimpleError(msg: String, param: String? = null, value: Any? = null) = CustomError( mutableListOf(SimpleError(msg, param, value)) ) + + fun Any.isSerializable() = try { + objectMapper.writeValueAsString(this) + true + } catch (err: Exception) { + false + } } 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 a24012ab..9890bf16 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 @@ -9,7 +9,7 @@ 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.dto.entity.EventDto -import pt.up.fe.ni.website.backend.service.EventService +import pt.up.fe.ni.website.backend.service.activity.EventService @RestController @RequestMapping("/events") @@ -17,17 +17,23 @@ class EventController(private val service: EventService) { @GetMapping fun getAllEvents() = service.getAllEvents() - @GetMapping("/{id}") + @GetMapping("/{id:\\d+}") fun getEventById(@PathVariable id: Long) = service.getEventById(id) @GetMapping("/category/{category}") fun getEventsByCategory(@PathVariable category: String) = service.getEventsByCategory(category) + @GetMapping("/{eventSlug}**") + fun getEvent(@PathVariable eventSlug: String) = service.getEventBySlug(eventSlug) + @PostMapping("/new") fun createEvent(@RequestBody dto: EventDto) = service.createEvent(dto) @DeleteMapping("/{id}") - fun deleteEventById(@PathVariable id: Long) = service.deleteEventById(id) + fun deleteEventById(@PathVariable id: Long): Map { + service.deleteEventById(id) + return emptyMap() + } @PutMapping("/{id}") fun updateEventById( diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/GenerationController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/GenerationController.kt new file mode 100644 index 00000000..d95984d8 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/GenerationController.kt @@ -0,0 +1,61 @@ +package pt.up.fe.ni.website.backend.controller + +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +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.dto.entity.GenerationDto +import pt.up.fe.ni.website.backend.dto.generations.UpdateGenerationDto +import pt.up.fe.ni.website.backend.service.GenerationService + +@RestController +@RequestMapping("/generations") +class GenerationController(private val service: GenerationService) { + @GetMapping + fun getAllGenerations() = service.getAllGenerations() + + @GetMapping("/{id:\\d+}") + fun getGenerationById(@PathVariable id: Long) = service.getGenerationById(id) + + @GetMapping("/{year:\\d{2}-\\d{2}}") + fun getGenerationByYear(@PathVariable year: String) = service.getGenerationByYear(year) + + @GetMapping("/latest") + fun getLatestGeneration() = service.getLatestGeneration() + + @PostMapping("/new") + fun createNewGeneration( + @RequestBody dto: GenerationDto + ) = service.createNewGeneration(dto) + + @PatchMapping("/{id:\\d+}") + fun updateGenerationById( + @PathVariable id: Long, + @RequestBody @Valid + dto: UpdateGenerationDto + ) = service.updateGenerationById(id, dto) + + @PatchMapping("/{year:\\d{2}-\\d{2}}") + fun updateGenerationByYear( + @PathVariable year: String, + @RequestBody @Valid + dto: UpdateGenerationDto + ) = service.updateGenerationByYear(year, dto) + + @DeleteMapping("/{id:\\d+}") + fun deleteGenerationById(@PathVariable id: Long): Map { + service.deleteGenerationById(id) + return emptyMap() + } + + @DeleteMapping("/{year:\\d{2}-\\d{2}}") + fun deleteGenerationByYear(@PathVariable year: String): Map { + service.deleteGenerationByYear(year) + return emptyMap() + } +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/PostController.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/PostController.kt index dc510ef9..efb1dbef 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/controller/PostController.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/controller/PostController.kt @@ -34,5 +34,8 @@ class PostController(private val service: PostService) { ) = service.updatePostById(postId, dto) @DeleteMapping("/{postId}") - fun deletePost(@PathVariable postId: Long) = service.deletePostById(postId) + fun deletePost(@PathVariable postId: Long): Map { + service.deletePostById(postId) + return emptyMap() + } } 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 cd4c24bc..571ba9c1 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 @@ -9,7 +9,7 @@ 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.dto.entity.ProjectDto -import pt.up.fe.ni.website.backend.service.ProjectService +import pt.up.fe.ni.website.backend.service.activity.ProjectService @RestController @RequestMapping("/projects") @@ -18,17 +18,23 @@ class ProjectController(private val service: ProjectService) { @GetMapping fun getAllProjects() = service.getAllProjects() - @GetMapping("/{id}") + @GetMapping("/{id:\\d+}") fun getProjectById(@PathVariable id: Long) = service.getProjectById(id) + @GetMapping("/{projectSlug}**") + fun getProjectBySlug(@PathVariable projectSlug: String) = service.getProjectBySlug(projectSlug) + @PostMapping("/new") fun createNewProject(@RequestBody dto: ProjectDto) = service.createProject(dto) @DeleteMapping("/{id}") - fun deleteProjectById(@PathVariable id: Long) = service.deleteProjectById(id) + fun deleteProjectById(@PathVariable id: Long): Map { + service.deleteProjectById(id) + return emptyMap() + } @PutMapping("/{id}") - fun updatePostById( + fun updateProjectById( @PathVariable id: Long, @RequestBody dto: ProjectDto ) = service.updateProjectById(id, dto) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/auth/ChangePasswordDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/auth/ChangePasswordDto.kt new file mode 100644 index 00000000..706e8c0f --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/auth/ChangePasswordDto.kt @@ -0,0 +1,6 @@ +package pt.up.fe.ni.website.backend.dto.auth + +data class ChangePasswordDto( + val oldPassword: String, + val newPassword: String +) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/AccountDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/AccountDto.kt deleted file mode 100644 index b8244859..00000000 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/AccountDto.kt +++ /dev/null @@ -1,16 +0,0 @@ -package pt.up.fe.ni.website.backend.dto.entity - -import pt.up.fe.ni.website.backend.model.Account -import java.util.Date - -class AccountDto( - val email: String, - val password: String, - val name: String, - val bio: String?, - val birthDate: Date?, - val photoPath: String?, - val linkedin: String?, - val github: String?, - val websites: List? -) : 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 0adbd234..0f6d77cc 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 @@ -5,10 +5,10 @@ import com.fasterxml.jackson.databind.ObjectMapper import jakarta.persistence.Entity import jakarta.validation.ConstraintViolationException import jakarta.validation.Validator -import pt.up.fe.ni.website.backend.config.ApplicationContextUtils import kotlin.reflect.KClass import kotlin.reflect.full.hasAnnotation import kotlin.reflect.jvm.jvmErasure +import pt.up.fe.ni.website.backend.config.ApplicationContextUtils abstract class EntityDto { 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 6311ae95..cf073aa0 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 @@ -11,5 +11,6 @@ class EventDto( val dateInterval: DateInterval, val location: String?, val category: String?, - val thumbnailPath: String + val thumbnailPath: String, + val slug: String? ) : EntityDto() diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/GenerationDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/GenerationDto.kt new file mode 100644 index 00000000..cbc6f835 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/GenerationDto.kt @@ -0,0 +1,8 @@ +package pt.up.fe.ni.website.backend.dto.entity + +import pt.up.fe.ni.website.backend.model.Generation + +class GenerationDto( + var schoolYear: String?, + val roles: List +) : EntityDto() diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/PerActivityRoleDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/PerActivityRoleDto.kt new file mode 100644 index 00000000..55cb8754 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/PerActivityRoleDto.kt @@ -0,0 +1,11 @@ +package pt.up.fe.ni.website.backend.dto.entity + +import com.fasterxml.jackson.annotation.JsonProperty +import pt.up.fe.ni.website.backend.model.PerActivityRole + +class PerActivityRoleDto( + @JsonProperty(required = true) + val activityId: Long?, + + val permissions: List +) : EntityDto() 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 b79497fa..0f03ce28 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 @@ -7,5 +7,6 @@ class ProjectDto( val description: String, val teamMembersIds: List?, val isArchived: Boolean = false, - val technologies: List = emptyList() + val technologies: List = emptyList(), + val slug: String? ) : EntityDto() diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/RoleDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/RoleDto.kt new file mode 100644 index 00000000..dd36aa28 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/RoleDto.kt @@ -0,0 +1,15 @@ +package pt.up.fe.ni.website.backend.dto.entity + +import com.fasterxml.jackson.annotation.JsonProperty +import pt.up.fe.ni.website.backend.model.Role + +class RoleDto( + val name: String, + val permissions: List, + + @JsonProperty(required = true) + val isSection: Boolean?, + + val accountIds: List = emptyList(), + val associatedActivities: List +) : EntityDto() diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/account/CreateAccountDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/account/CreateAccountDto.kt new file mode 100644 index 00000000..ee4dcd0a --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/account/CreateAccountDto.kt @@ -0,0 +1,21 @@ +package pt.up.fe.ni.website.backend.dto.entity.account + +import com.fasterxml.jackson.annotation.JsonIgnore +import java.util.Date +import org.springframework.web.multipart.MultipartFile +import pt.up.fe.ni.website.backend.dto.entity.CustomWebsiteDto +import pt.up.fe.ni.website.backend.dto.entity.EntityDto +import pt.up.fe.ni.website.backend.model.Account + +class CreateAccountDto( + val email: String, + val password: String, + val name: String, + val bio: String?, + val birthDate: Date?, + @JsonIgnore + var photoFile: MultipartFile?, + val linkedin: String?, + val github: String?, + val websites: List? +) : EntityDto() diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/account/UpdateAccountDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/account/UpdateAccountDto.kt new file mode 100644 index 00000000..6953df1b --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/entity/account/UpdateAccountDto.kt @@ -0,0 +1,20 @@ +package pt.up.fe.ni.website.backend.dto.entity.account + +import com.fasterxml.jackson.annotation.JsonIgnore +import java.util.Date +import org.springframework.web.multipart.MultipartFile +import pt.up.fe.ni.website.backend.dto.entity.CustomWebsiteDto +import pt.up.fe.ni.website.backend.dto.entity.EntityDto +import pt.up.fe.ni.website.backend.model.Account + +class UpdateAccountDto( + val email: String, + val name: String, + val bio: String?, + val birthDate: Date?, + @JsonIgnore + var photoFile: MultipartFile?, + val linkedin: String?, + val github: String?, + val websites: List? +) : EntityDto() diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/generations/GetGenerationDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/generations/GetGenerationDto.kt new file mode 100644 index 00000000..720b577f --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/generations/GetGenerationDto.kt @@ -0,0 +1,41 @@ +package pt.up.fe.ni.website.backend.dto.generations + +import com.fasterxml.jackson.annotation.JsonUnwrapped +import pt.up.fe.ni.website.backend.model.Account +import pt.up.fe.ni.website.backend.model.Generation + +typealias GetGenerationDto = List + +data class GenerationUserDto( + @JsonUnwrapped + val account: Account, + val roles: List +) + +data class GenerationSectionDto( + val section: String, + val accounts: List +) + +fun buildGetGenerationDto(generation: Generation): GetGenerationDto { + val usedAccounts = mutableSetOf() + val sections = generation.roles + .filter { it.isSection && it.accounts.isNotEmpty() } + .map { role -> + GenerationSectionDto( + section = role.name, + accounts = role.accounts + .filter { !usedAccounts.contains(it) } + .map { account -> + usedAccounts.add(account) + GenerationUserDto( + account, + roles = account.roles + .filter { it.generation == generation && !it.isSection } + .map { it.name } + ) + } + ) + } + return sections +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/dto/generations/UpdateGenerationDto.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/generations/UpdateGenerationDto.kt new file mode 100644 index 00000000..c16ac5e1 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/dto/generations/UpdateGenerationDto.kt @@ -0,0 +1,8 @@ +package pt.up.fe.ni.website.backend.dto.generations + +import pt.up.fe.ni.website.backend.utils.validation.SchoolYear + +data class UpdateGenerationDto( + @SchoolYear + val schoolYear: String +) 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 27486334..fca588f6 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 @@ -1,5 +1,6 @@ package pt.up.fe.ni.website.backend.model +import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonProperty import jakarta.persistence.CascadeType import jakarta.persistence.Column @@ -8,18 +9,20 @@ import jakarta.persistence.FetchType import jakarta.persistence.GeneratedValue import jakarta.persistence.Id import jakarta.persistence.JoinColumn +import jakarta.persistence.JoinTable import jakarta.persistence.ManyToMany import jakarta.persistence.OneToMany +import jakarta.persistence.OrderColumn import jakarta.validation.Valid import jakarta.validation.constraints.Email import jakarta.validation.constraints.NotEmpty import jakarta.validation.constraints.Past import jakarta.validation.constraints.Size -import org.hibernate.validator.constraints.URL -import pt.up.fe.ni.website.backend.annotations.validation.NullOrNotBlank -import pt.up.fe.ni.website.backend.model.permissions.Permissions import java.util.Date +import org.hibernate.validator.constraints.URL import pt.up.fe.ni.website.backend.model.constants.AccountConstants as Constants +import pt.up.fe.ni.website.backend.model.permissions.Permissions +import pt.up.fe.ni.website.backend.utils.validation.NullOrNotBlank @Entity class Account( @@ -43,8 +46,7 @@ class Account( var birthDate: Date?, @field:NullOrNotBlank - @field:URL - var photoPath: String?, + var photo: String?, @field:NullOrNotBlank @field:URL @@ -58,9 +60,11 @@ class Account( @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER) val websites: List<@Valid CustomWebsite> = emptyList(), - @JoinColumn - @ManyToMany(fetch = FetchType.EAGER) - var roles: List<@Valid Role> = emptyList(), + @ManyToMany + @JoinTable + @OrderColumn + @JsonIgnore // TODO: Decide if we want to return roles (or IDs) by default + val roles: MutableList<@Valid Role> = mutableListOf(), @Id @GeneratedValue val id: Long? = null 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 9b9a977c..d08fe54e 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 @@ -1,7 +1,9 @@ package pt.up.fe.ni.website.backend.model +import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonProperty import jakarta.persistence.CascadeType +import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.FetchType import jakarta.persistence.GeneratedValue @@ -10,6 +12,7 @@ import jakarta.persistence.Inheritance import jakarta.persistence.InheritanceType import jakarta.persistence.JoinColumn import jakarta.persistence.OneToMany +import jakarta.persistence.OrderColumn import jakarta.validation.Valid import jakarta.validation.constraints.Size import pt.up.fe.ni.website.backend.model.constants.ActivityConstants as Constants @@ -17,23 +20,28 @@ import pt.up.fe.ni.website.backend.model.constants.ActivityConstants as Constant @Entity @Inheritance(strategy = InheritanceType.JOINED) abstract class Activity( - @JsonProperty(required = true) @field:Size(min = Constants.Title.minSize, max = Constants.Title.maxSize) - open val title: String, + var title: String, @JsonProperty(required = true) @field:Size(min = Constants.Description.minSize, max = Constants.Description.maxSize) - open val description: String, + var description: String, @JoinColumn @OneToMany(fetch = FetchType.EAGER) - open val teamMembers: MutableList, + val teamMembers: MutableList, + + @OneToMany(cascade = [CascadeType.ALL], mappedBy = "activity") + @OrderColumn + @JsonIgnore // TODO: Decide if we want to return perRoles (or IDs) by default + val associatedRoles: MutableList<@Valid PerActivityRole> = mutableListOf(), - @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER) - open var associatedRoles: List<@Valid PerActivityRole>, + @Column(unique = true) + @field:Size(min = Constants.Slug.minSize, max = Constants.Slug.maxSize) + val slug: String? = null, @Id @GeneratedValue - open val id: Long? = null + 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 6c6a2860..6ba6a57a 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,7 +6,7 @@ import jakarta.persistence.GeneratedValue import jakarta.persistence.Id import jakarta.validation.constraints.NotEmpty import org.hibernate.validator.constraints.URL -import pt.up.fe.ni.website.backend.annotations.validation.NullOrNotBlank +import pt.up.fe.ni.website.backend.utils.validation.NullOrNotBlank @Entity class CustomWebsite( 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 192f7a50..f348a46e 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 @@ -7,15 +7,17 @@ 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.annotations.validation.NullOrNotBlank -import pt.up.fe.ni.website.backend.model.embeddable.DateInterval import pt.up.fe.ni.website.backend.model.constants.EventConstants as Constants +import pt.up.fe.ni.website.backend.model.embeddable.DateInterval +import pt.up.fe.ni.website.backend.utils.validation.NullOrNotBlank @Entity class Event( title: String, description: String, teamMembers: MutableList = mutableListOf(), + associatedRoles: MutableList = mutableListOf(), + slug: String? = null, @field:NullOrNotBlank @field:URL @@ -23,7 +25,7 @@ class Event( @Embedded @field:Valid - val dateInterval: DateInterval, + var dateInterval: DateInterval, @field:Size(min = Constants.Location.minSize, max = Constants.Location.maxSize) val location: String?, @@ -36,7 +38,5 @@ class Event( @field:URL val thumbnailPath: String, - associatedRoles: List = emptyList(), id: Long? = null - -) : Activity(title, description, teamMembers, associatedRoles, id) +) : Activity(title, description, teamMembers, associatedRoles, slug, id) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Generation.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Generation.kt new file mode 100644 index 00000000..bdf5232e --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Generation.kt @@ -0,0 +1,32 @@ +package pt.up.fe.ni.website.backend.model + +import com.fasterxml.jackson.annotation.JsonManagedReference +import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.persistence.CascadeType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.Id +import jakarta.persistence.OneToMany +import jakarta.persistence.OrderColumn +import jakarta.validation.Valid +import pt.up.fe.ni.website.backend.utils.validation.NoDuplicateRoles +import pt.up.fe.ni.website.backend.utils.validation.SchoolYear + +@Entity +class Generation( + @JsonProperty(required = true) + @Column(unique = true) + @field:SchoolYear + var schoolYear: String, + + @Id @GeneratedValue + val id: Long? = null +) { + @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, mappedBy = "generation") + @OrderColumn + @JsonManagedReference + @field:NoDuplicateRoles + val roles: MutableList<@Valid Role> = mutableListOf() +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/PerActivityRole.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/PerActivityRole.kt index 8413de89..3a7fa684 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/PerActivityRole.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/PerActivityRole.kt @@ -1,5 +1,6 @@ package pt.up.fe.ni.website.backend.model +import com.fasterxml.jackson.annotation.JsonBackReference import jakarta.persistence.Convert import jakarta.persistence.Entity import jakarta.persistence.GeneratedValue @@ -11,17 +12,18 @@ import pt.up.fe.ni.website.backend.model.permissions.PermissionsConverter @Entity class PerActivityRole( - @JoinColumn - @ManyToOne - var role: Role, - - @JoinColumn - @ManyToOne - var activity: Activity, - @field:Convert(converter = PermissionsConverter::class) var permissions: Permissions, @Id @GeneratedValue - var id: Long? = null -) + val id: Long? = null +) { + @JoinColumn + @ManyToOne // TODO: Perhaps change to sending only ID + lateinit var activity: Activity + + @JoinColumn + @ManyToOne + @JsonBackReference + lateinit var role: Role +} 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 cdb65f03..58be5866 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 @@ -9,11 +9,11 @@ import jakarta.persistence.GeneratedValue import jakarta.persistence.Id import jakarta.validation.constraints.NotEmpty import jakarta.validation.constraints.Size +import java.util.Date 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 -import java.util.Date import pt.up.fe.ni.website.backend.model.constants.PostConstants as Constants @Entity 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 de93b7e3..78003fbb 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 @@ -7,9 +7,11 @@ class Project( title: String, description: String, teamMembers: MutableList = mutableListOf(), + associatedRoles: MutableList = mutableListOf(), + slug: String? = null, + var isArchived: Boolean = false, val technologies: List = emptyList(), - associatedRoles: List = emptyList(), - id: Long? = null -) : Activity(title, description, teamMembers, associatedRoles, id) + id: Long? = null +) : Activity(title, description, teamMembers, associatedRoles, slug, id) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Role.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Role.kt index c9fc370e..62bc6932 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/Role.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/Role.kt @@ -1,22 +1,28 @@ package pt.up.fe.ni.website.backend.model +import com.fasterxml.jackson.annotation.JsonBackReference +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonManagedReference import com.fasterxml.jackson.annotation.JsonProperty -import jakarta.persistence.Column +import jakarta.persistence.CascadeType import jakarta.persistence.Convert import jakarta.persistence.Entity +import jakarta.persistence.FetchType import jakarta.persistence.GeneratedValue import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToMany +import jakarta.persistence.ManyToOne import jakarta.persistence.OneToMany import jakarta.validation.Valid +import org.hibernate.annotations.OnDelete +import org.hibernate.annotations.OnDeleteAction import pt.up.fe.ni.website.backend.model.permissions.Permissions import pt.up.fe.ni.website.backend.model.permissions.PermissionsConverter @Entity class Role( @JsonProperty(required = true) - @Column(unique = true) var name: String, @JsonProperty(required = true) @@ -26,15 +32,22 @@ class Role( @JsonProperty(required = true) var isSection: Boolean, - @JoinColumn - @ManyToMany - var accounts: List<@Valid Account> = emptyList(), - - @JoinColumn - @OneToMany - var associatedActivities: List<@Valid PerActivityRole> = emptyList(), + @ManyToMany(mappedBy = "roles") + @JsonIgnore // TODO: Decide if we want to return accounts (or IDs) by default + @OnDelete(action = OnDeleteAction.CASCADE) // Remove relationship, since this is the non-owner side + val accounts: MutableList<@Valid Account> = mutableListOf(), @JsonProperty(required = true) - @Id @GeneratedValue + @Id + @GeneratedValue val id: Long? = null -) +) { + @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, mappedBy = "role") + @JsonManagedReference + val associatedActivities: MutableList<@Valid PerActivityRole> = mutableListOf() + + @JoinColumn + @ManyToOne(fetch = FetchType.LAZY) + @JsonBackReference + lateinit var generation: Generation +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/ActivityConstants.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/ActivityConstants.kt index 8ab6cb7c..05d24f17 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/ActivityConstants.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/ActivityConstants.kt @@ -10,4 +10,9 @@ object ActivityConstants { const val minSize = 10 const val maxSize = 10000 } + + object Slug { + const val minSize = 2 + const val maxSize = 500 + } } 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 new file mode 100644 index 00000000..a1e557dd --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/constants/UploadConstants.kt @@ -0,0 +1,17 @@ +package pt.up.fe.ni.website.backend.model.constants + +object UploadConstants { + object SupportedTypes { + val contentTypes = listOf( + "image/png", + "image/jpg", + "image/jpeg" + ) + + val fileExtensions = listOf( + "png", + "jpg", + "jpeg" + ) + } +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/model/embeddable/DateInterval.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/model/embeddable/DateInterval.kt index ab0deecd..40cd249b 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/model/embeddable/DateInterval.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/model/embeddable/DateInterval.kt @@ -2,13 +2,13 @@ package pt.up.fe.ni.website.backend.model.embeddable import com.fasterxml.jackson.annotation.JsonProperty import jakarta.persistence.Embeddable -import pt.up.fe.ni.website.backend.annotations.validation.ValidDateInterval import java.util.Date +import pt.up.fe.ni.website.backend.utils.validation.ValidDateInterval @ValidDateInterval @Embeddable class DateInterval( @JsonProperty(required = true) val startDate: Date, - val endDate: Date? + val endDate: Date? = null ) 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 index 74893626..3d009f12 100644 --- 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 @@ -1,8 +1,10 @@ package pt.up.fe.ni.website.backend.repository import org.springframework.data.repository.CrudRepository +import org.springframework.stereotype.Repository import pt.up.fe.ni.website.backend.model.Account +@Repository interface AccountRepository : CrudRepository { fun findByEmail(email: String): Account? } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/repository/ActivityRepository.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/repository/ActivityRepository.kt index b29b2095..370f356c 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/repository/ActivityRepository.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/repository/ActivityRepository.kt @@ -5,4 +5,6 @@ import org.springframework.stereotype.Repository import pt.up.fe.ni.website.backend.model.Activity @Repository -interface ActivityRepository : CrudRepository +interface ActivityRepository : CrudRepository { + fun findBySlug(slug: String?): T? +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/repository/EventRepository.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/repository/EventRepository.kt index 5e0e94fb..22c8966d 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/repository/EventRepository.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/repository/EventRepository.kt @@ -5,6 +5,5 @@ import pt.up.fe.ni.website.backend.model.Event @Repository interface EventRepository : ActivityRepository { - fun findAllByCategory(category: String): List } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/repository/GenerationRepository.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/repository/GenerationRepository.kt new file mode 100644 index 00000000..e4eaed76 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/repository/GenerationRepository.kt @@ -0,0 +1,16 @@ +package pt.up.fe.ni.website.backend.repository + +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.CrudRepository +import org.springframework.stereotype.Repository +import pt.up.fe.ni.website.backend.model.Generation + +@Repository +interface GenerationRepository : CrudRepository { + fun findBySchoolYear(schoolYear: String): Generation? + + fun findFirstByOrderBySchoolYearDesc(): Generation? + + @Query("SELECT schoolYear FROM Generation ORDER BY schoolYear DESC") + fun findAllSchoolYearOrdered(): List +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/repository/PostRepository.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/repository/PostRepository.kt index fc245450..6bbb9138 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/repository/PostRepository.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/repository/PostRepository.kt @@ -1,8 +1,10 @@ package pt.up.fe.ni.website.backend.repository import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository import pt.up.fe.ni.website.backend.model.Post +@Repository interface PostRepository : JpaRepository { fun findBySlug(slug: String?): Post? } 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 7180bdd8..fd8ea298 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,39 +1,72 @@ 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.security.oauth2.jwt.JwtDecoder import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException import org.springframework.stereotype.Service import pt.up.fe.ni.website.backend.dto.auth.PassRecoveryDto -import pt.up.fe.ni.website.backend.dto.entity.AccountDto import pt.up.fe.ni.website.backend.model.Account import pt.up.fe.ni.website.backend.repository.AccountRepository import java.time.Instant +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.service.upload.FileUploader +import pt.up.fe.ni.website.backend.utils.extensions.filenameExtension @Service class AccountService( private val repository: AccountRepository, private val encoder: PasswordEncoder, - private val jwtDecoder: JwtDecoder + private val jwtDecoder: JwtDecoder, + private val fileUploader: FileUploader ) { fun getAllAccounts(): List = repository.findAll().toList() - fun createAccount(dto: AccountDto): Account { + fun createAccount(dto: CreateAccountDto): Account { repository.findByEmail(dto.email)?.let { throw IllegalArgumentException(ErrorMessages.emailAlreadyExists) } val account = dto.create() account.password = encoder.encode(dto.password) + + dto.photoFile?.let { + val fileName = photoFilename(dto.email, it) + 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)) fun doesAccountExist(id: Long): Boolean = repository.findByIdOrNull(id) != null + fun updateAccountById(id: Long, dto: UpdateAccountDto): Account { + val account = getAccountById(id) + + repository.findByEmail(dto.email)?.let { + throw IllegalArgumentException(ErrorMessages.emailAlreadyExists) + } + + dto.photoFile?.let { + val fileName = photoFilename(dto.email, it) + account.photo = fileUploader.uploadImage("profile", fileName, it.bytes) + } + + val newAccount = dto.update(account) + + return repository.save(newAccount) + } + fun getAccountByEmail(email: String): Account = repository.findByEmail(email) ?: throw NoSuchElementException(ErrorMessages.emailNotFound(email)) @@ -52,4 +85,20 @@ class AccountService( account.password = encoder.encode(dto.password) return repository.save(account) } + + fun changePassword(id: Long, dto: ChangePasswordDto) { + val account = getAccountById(id) + if (!encoder.matches(dto.oldPassword, account.password)) { + throw IllegalArgumentException(ErrorMessages.invalidCredentials) + } + account.password = encoder.encode(dto.newPassword) + repository.save(account) + } + + fun deleteAccountById(id: Long) { + if (!repository.existsById(id)) { + throw NoSuchElementException(ErrorMessages.accountNotFound(id)) + } + repository.deleteById(id) + } } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt index 6e9168f0..36df7f9d 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/AuthService.kt @@ -1,5 +1,8 @@ package pt.up.fe.ni.website.backend.service +import java.time.Duration +import java.time.Instant +import java.util.stream.Collectors import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority @@ -13,9 +16,6 @@ import org.springframework.security.oauth2.server.resource.InvalidBearerTokenExc import org.springframework.stereotype.Service import pt.up.fe.ni.website.backend.config.auth.AuthConfigProperties import pt.up.fe.ni.website.backend.model.Account -import java.time.Duration -import java.time.Instant -import java.util.stream.Collectors @Service class AuthService( diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/ErrorMessages.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/ErrorMessages.kt index 15f86cc5..cd55189d 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/ErrorMessages.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/ErrorMessages.kt @@ -15,17 +15,31 @@ object ErrorMessages { const val expiredRecoveryToken = "password recovery token has expired" + const val noGenerations = "no generations created yet" + + const val noGenerationsToInferYear = "no generations created yet, please specify school year" + + const val generationAlreadyExists = "generation already exists" + fun postNotFound(postId: Long): String = "post not found with id $postId" fun postNotFound(postSlug: String): String = "post not found with slug $postSlug" + fun eventNotFound(eventSlug: String): String = "event not found with slug $eventSlug" + fun projectNotFound(id: Long): String = "project not found with id $id" + fun projectNotFound(projectSlug: String): String = "project not found with slug $projectSlug" + fun eventNotFound(id: Long): String = "event not found with id $id" fun activityNotFound(id: Long): String = "activity not found with id $id" fun accountNotFound(id: Long): String = "account not found with id $id" + fun generationNotFound(id: Long): String = "generation not found with id $id" + + fun generationNotFound(year: String): String = "generation not found with year $year" + fun emailNotFound(email: String): String = "account not found with email $email" } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/GenerationService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/GenerationService.kt new file mode 100644 index 00000000..bcf9abbf --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/GenerationService.kt @@ -0,0 +1,115 @@ +package pt.up.fe.ni.website.backend.service + +import jakarta.transaction.Transactional +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import pt.up.fe.ni.website.backend.dto.entity.GenerationDto +import pt.up.fe.ni.website.backend.dto.generations.GetGenerationDto +import pt.up.fe.ni.website.backend.dto.generations.UpdateGenerationDto +import pt.up.fe.ni.website.backend.dto.generations.buildGetGenerationDto +import pt.up.fe.ni.website.backend.model.Generation +import pt.up.fe.ni.website.backend.repository.GenerationRepository +import pt.up.fe.ni.website.backend.service.activity.ActivityService + +@Service +@Transactional +class GenerationService( + private val repository: GenerationRepository, + private val accountService: AccountService, + private val activityService: ActivityService +) { + + fun getAllGenerations(): List = repository.findAllSchoolYearOrdered() + + fun getGenerationById(id: Long): GetGenerationDto { + val generation = + repository.findByIdOrNull(id) ?: throw NoSuchElementException(ErrorMessages.generationNotFound(id)) + return buildGetGenerationDto(generation) + } + + fun getGenerationByYear(year: String): GetGenerationDto { + val generation = + repository.findBySchoolYear(year) ?: throw NoSuchElementException(ErrorMessages.generationNotFound(year)) + return buildGetGenerationDto(generation) + } + + fun getLatestGeneration(): GetGenerationDto { + val generation = repository.findFirstByOrderBySchoolYearDesc() + ?: throw NoSuchElementException(ErrorMessages.noGenerations) + return buildGetGenerationDto(generation) + } + + fun createNewGeneration(dto: GenerationDto): Generation { + dto.schoolYear = inferSchoolYearIfNotSpecified(dto) + val generation = dto.create() + assignRolesAndActivities(generation, dto) + return repository.save(generation) + } + + fun updateGenerationById(id: Long, dto: UpdateGenerationDto): Generation { + val generation = + repository.findByIdOrNull(id) ?: throw NoSuchElementException(ErrorMessages.generationNotFound(id)) + return updateGenerationByYear(generation.schoolYear, dto) + } + + fun updateGenerationByYear(year: String, dto: UpdateGenerationDto): Generation { + val generation = + repository.findBySchoolYear(year) ?: throw NoSuchElementException(ErrorMessages.generationNotFound(year)) + + repository.findBySchoolYear(dto.schoolYear)?.let { + throw IllegalArgumentException(ErrorMessages.generationAlreadyExists) + } + + generation.schoolYear = dto.schoolYear + return repository.save(generation) + } + + fun deleteGenerationByYear(year: String) { + val generation = + repository.findBySchoolYear(year) ?: throw NoSuchElementException(ErrorMessages.generationNotFound(year)) + repository.delete(generation) + } + + fun deleteGenerationById(id: Long) { + repository.findByIdOrNull(id) ?: throw NoSuchElementException(ErrorMessages.generationNotFound(id)) + repository.deleteById(id) + } + + private fun inferSchoolYearIfNotSpecified(dto: GenerationDto): String { + dto.schoolYear?.let { + repository.findBySchoolYear(it)?.let { + throw IllegalArgumentException(ErrorMessages.generationAlreadyExists) + } + return it + } + + val lastSchoolYear = repository.findFirstByOrderBySchoolYearDesc()?.schoolYear + ?: throw IllegalArgumentException(ErrorMessages.noGenerationsToInferYear) + val lastYear = lastSchoolYear.substring(3, 5).toInt() + return "$lastYear-${lastYear + 1}" + } + + private fun assignRolesAndActivities(generation: Generation, dto: GenerationDto) { + generation.roles.forEachIndexed { roleIdx, role -> + val roleDto = dto.roles[roleIdx] + + roleDto.accountIds.forEach { + val account = accountService.getAccountById(it) + + // only owner side is needed after transaction, but it's useful to update the objects + account.roles.add(role) + role.accounts.add(account) + } + + roleDto.associatedActivities.forEachIndexed associatedLoop@{ activityRoleIdx, activityRoleDto -> + val perActivityRole = role.associatedActivities[activityRoleIdx] + val activityId = activityRoleDto.activityId ?: return@associatedLoop + val activity = activityService.getActivityById(activityId) + + // only owner side is needed after transaction, but it's useful to update the objects + perActivityRole.activity = activity + activity.associatedRoles.add(perActivityRole) + } + } + } +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/PostService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/PostService.kt index e246f4de..7020e23f 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/PostService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/PostService.kt @@ -33,10 +33,9 @@ class PostService(private val repository: PostRepository) { return repository.save(newPost) } - fun deletePostById(postId: Long): Map { + fun deletePostById(postId: Long) { repository.findByIdOrNull(postId) ?: throw NoSuchElementException(ErrorMessages.postNotFound(postId)) repository.deleteById(postId) - return mapOf() } fun getPostBySlug(postSlug: String): Post = diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/RoleService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/RoleService.kt index 885b7d2c..bf74ab31 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/RoleService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/RoleService.kt @@ -27,8 +27,11 @@ class RoleService( fun grantPermissionToRoleOnActivity(role: Role, activity: Activity, permission: Permission) { val foundActivity = activity.associatedRoles - .find { it.role == role } ?: PerActivityRole(role, activity, Permissions()) + .find { it.activity == activity } ?: PerActivityRole(Permissions()) + foundActivity.role = role + foundActivity.activity = activity + foundActivity.role = role foundActivity.permissions.add(permission) perActivityRoleRepository.save(foundActivity) } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/ActivityService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/AbstractActivityService.kt similarity index 57% rename from src/main/kotlin/pt/up/fe/ni/website/backend/service/ActivityService.kt rename to src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/AbstractActivityService.kt index b6165618..bda10799 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/ActivityService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/AbstractActivityService.kt @@ -1,16 +1,18 @@ -package pt.up.fe.ni.website.backend.service +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.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 @Service -abstract class ActivityService( - private val repository: ActivityRepository, - private val accountService: AccountService +abstract class AbstractActivityService( + protected val repository: ActivityRepository, + protected val accountService: AccountService ) { - private fun getActivityById(id: Long): T = repository.findByIdOrNull(id) + fun getActivityById(id: Long): T = repository.findByIdOrNull(id) ?: throw NoSuchElementException(ErrorMessages.activityNotFound(id)) fun addTeamMemberById(idActivity: Long, idAccount: Long): T { @@ -22,11 +24,13 @@ abstract class ActivityService( fun removeTeamMemberById(idActivity: Long, idAccount: Long): T { val activity = getActivityById(idActivity) - if (!accountService.doesAccountExist(idAccount)) throw NoSuchElementException( - ErrorMessages.accountNotFound( - idAccount + if (!accountService.doesAccountExist(idAccount)) { + throw NoSuchElementException( + ErrorMessages.accountNotFound( + idAccount + ) ) - ) + } activity.teamMembers.removeIf { it.id == idAccount } return repository.save(activity) } 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 new file mode 100644 index 00000000..87a47c89 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/ActivityService.kt @@ -0,0 +1,12 @@ +package pt.up.fe.ni.website.backend.service.activity + +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 + +@Service +class ActivityService( + repository: ActivityRepository, + accountService: AccountService +) : AbstractActivityService(repository, accountService) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/EventService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/EventService.kt similarity index 62% rename from src/main/kotlin/pt/up/fe/ni/website/backend/service/EventService.kt rename to src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/EventService.kt index 96df91ce..d31b9e66 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/EventService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/EventService.kt @@ -1,19 +1,28 @@ -package pt.up.fe.ni.website.backend.service +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.EventDto 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 @Service class EventService( - private val repository: EventRepository, - private val accountService: AccountService -) : ActivityService(repository, accountService) { + 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 { @@ -27,7 +36,7 @@ class EventService( fun getEventsByCategory(category: String): List = repository.findAllByCategory(category) fun getEventById(eventId: Long): Event = repository.findByIdOrNull(eventId) - ?: throw NoSuchElementException("event not found with id $eventId") + ?: throw NoSuchElementException(ErrorMessages.eventNotFound(eventId)) fun updateEventById(eventId: Long, dto: EventDto): Event { val event = getEventById(eventId) @@ -42,12 +51,11 @@ class EventService( return repository.save(newEvent) } - fun deleteEventById(eventId: Long): Map { + fun deleteEventById(eventId: Long) { if (!repository.existsById(eventId)) { - throw NoSuchElementException("event not found with id $eventId") + throw NoSuchElementException(ErrorMessages.eventNotFound(eventId)) } repository.deleteById(eventId) - return mapOf() } } diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/ProjectService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/ProjectService.kt similarity index 68% rename from src/main/kotlin/pt/up/fe/ni/website/backend/service/ProjectService.kt rename to src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/ProjectService.kt index a2e7a2cc..26b2916e 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/service/ProjectService.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/activity/ProjectService.kt @@ -1,20 +1,26 @@ -package pt.up.fe.ni.website.backend.service +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.ProjectDto 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 @Service class ProjectService( - private val repository: ProjectRepository, - private val accountService: AccountService -) : ActivityService(repository, accountService) { + override val repository: ProjectRepository, + accountService: AccountService +) : AbstractActivityService(repository, accountService) { fun getAllProjects(): List = repository.findAll().toList() fun createProject(dto: ProjectDto): Project { + repository.findBySlug(dto.slug)?.let { + throw IllegalArgumentException(ErrorMessages.slugAlreadyExists) + } + val project = dto.create() dto.teamMembersIds?.forEach { @@ -28,8 +34,16 @@ class ProjectService( 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) newProject.apply { teamMembers.clear() @@ -41,13 +55,12 @@ class ProjectService( return repository.save(newProject) } - fun deleteProjectById(id: Long): Map { + fun deleteProjectById(id: Long) { if (!repository.existsById(id)) { throw NoSuchElementException(ErrorMessages.projectNotFound(id)) } repository.deleteById(id) - return emptyMap() } fun archiveProjectById(id: Long): Project { 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 new file mode 100644 index 00000000..ee7f7081 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/upload/CloudinaryFileUploader.kt @@ -0,0 +1,23 @@ +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 { + override fun uploadImage(folder: String, fileName: String, image: ByteArray): String { + val path = "$basePath/$folder/$fileName" + + val imageTransformation = Transformation().width(250).height(250).crop("thumb").chain() + + val result = cloudinary.uploader().upload( + image, + mapOf( + "public_id" to path, + "overwrite" to true, + "transformation" to imageTransformation + ) + ) + + return result["url"]?.toString() ?: "" + } +} 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 new file mode 100644 index 00000000..e5fb1f1c --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/upload/FileUploader.kt @@ -0,0 +1,5 @@ +package pt.up.fe.ni.website.backend.service.upload + +interface FileUploader { + fun uploadImage(folder: String, fileName: String, image: ByteArray): String +} 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 new file mode 100644 index 00000000..1bc34e5d --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/upload/StaticFileUploader.kt @@ -0,0 +1,13 @@ +package pt.up.fe.ni.website.backend.service.upload + +import java.io.File + +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.createNewFile() + file.writeBytes(image) + + return "$servePath/$folder/$fileName" + } +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/utils/extensions/MultipartFileExtensions.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/utils/extensions/MultipartFileExtensions.kt new file mode 100644 index 00000000..80f1eaa7 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/utils/extensions/MultipartFileExtensions.kt @@ -0,0 +1,7 @@ +package pt.up.fe.ni.website.backend.utils.extensions + +import org.springframework.web.multipart.MultipartFile + +fun MultipartFile.filenameExtension(): String { + return this.originalFilename?.substringAfterLast(".") ?: "" +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/utils/validation/NoDuplicateRoles.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/utils/validation/NoDuplicateRoles.kt new file mode 100644 index 00000000..eb26062f --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/utils/validation/NoDuplicateRoles.kt @@ -0,0 +1,25 @@ +package pt.up.fe.ni.website.backend.utils.validation + +import jakarta.validation.Constraint +import jakarta.validation.ConstraintValidator +import jakarta.validation.ConstraintValidatorContext +import jakarta.validation.Payload +import kotlin.reflect.KClass +import pt.up.fe.ni.website.backend.model.Role + +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +@Constraint(validatedBy = [NoDuplicateRolesValidator::class]) +@MustBeDocumented +annotation class NoDuplicateRoles( + val message: String = "{no_duplicate_roles.error}", + val groups: Array> = [], + val payload: Array> = [] +) + +class NoDuplicateRolesValidator : ConstraintValidator> { + override fun isValid(value: List, context: ConstraintValidatorContext?): Boolean { + val names = value.map { it.name } + return names.size == names.toSet().size + } +} 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/utils/validation/NullOrNotBlank.kt similarity index 92% rename from src/main/kotlin/pt/up/fe/ni/website/backend/annotations/validation/NullOrNotBlank.kt rename to src/main/kotlin/pt/up/fe/ni/website/backend/utils/validation/NullOrNotBlank.kt index f3773f61..42ff17b5 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/utils/validation/NullOrNotBlank.kt @@ -1,4 +1,4 @@ -package pt.up.fe.ni.website.backend.annotations.validation +package pt.up.fe.ni.website.backend.utils.validation import jakarta.validation.Constraint import jakarta.validation.ConstraintValidator diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/utils/validation/SchoolYear.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/utils/validation/SchoolYear.kt new file mode 100644 index 00000000..a070d262 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/utils/validation/SchoolYear.kt @@ -0,0 +1,32 @@ +package pt.up.fe.ni.website.backend.utils.validation + +import jakarta.validation.Constraint +import jakarta.validation.ConstraintValidator +import jakarta.validation.ConstraintValidatorContext +import jakarta.validation.Payload +import kotlin.reflect.KClass + +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +@Constraint(validatedBy = [SchoolYearValidator::class]) +@MustBeDocumented +annotation class SchoolYear( + val message: String = "{school_year.error}", + val groups: Array> = [], + val payload: Array> = [] +) + +class SchoolYearValidator : ConstraintValidator { + private val regex = Regex("\\d{2}-\\d{2}") + + override fun isValid(value: String, context: ConstraintValidatorContext?): Boolean { + if (!value.matches(regex)) return false + + val years = value.split("-") + if (years.size != 2) return false + + val firstYear = years[0].toIntOrNull() ?: return false + val secondYear = years[1].toIntOrNull() ?: return false + return secondYear == firstYear + 1 + } +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/annotations/validation/ValidDateInterval.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/utils/validation/ValidDateInterval.kt similarity index 93% rename from src/main/kotlin/pt/up/fe/ni/website/backend/annotations/validation/ValidDateInterval.kt rename to src/main/kotlin/pt/up/fe/ni/website/backend/utils/validation/ValidDateInterval.kt index 10200d02..e41aa8b4 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/annotations/validation/ValidDateInterval.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/utils/validation/ValidDateInterval.kt @@ -1,11 +1,11 @@ -package pt.up.fe.ni.website.backend.annotations.validation +package pt.up.fe.ni.website.backend.utils.validation import jakarta.validation.Constraint import jakarta.validation.ConstraintValidator import jakarta.validation.ConstraintValidatorContext import jakarta.validation.Payload -import pt.up.fe.ni.website.backend.model.embeddable.DateInterval import kotlin.reflect.KClass +import pt.up.fe.ni.website.backend.model.embeddable.DateInterval @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.RUNTIME) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/utils/validation/ValidImage.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/utils/validation/ValidImage.kt new file mode 100644 index 00000000..a13cde9a --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/utils/validation/ValidImage.kt @@ -0,0 +1,47 @@ +package pt.up.fe.ni.website.backend.utils.validation + +import jakarta.validation.Constraint +import jakarta.validation.ConstraintValidator +import jakarta.validation.ConstraintValidatorContext +import jakarta.validation.Payload +import kotlin.reflect.KClass +import org.springframework.web.multipart.MultipartFile +import pt.up.fe.ni.website.backend.model.constants.UploadConstants +import pt.up.fe.ni.website.backend.utils.extensions.filenameExtension + +@MustBeDocumented +@Constraint(validatedBy = [ValidImageValidator::class]) +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class ValidImage( + val message: String = "{files.invalid_image}", + val groups: Array> = [], + val payload: Array> = [] +) + +class ValidImageValidator : ConstraintValidator { + + override fun isValid(value: MultipartFile?, context: ConstraintValidatorContext?): Boolean { + if (value == null) { + return true + } + + if (!isSupportedContentType(value.contentType)) { + return false + } + + if (!isSupportedFileExtension(value.filenameExtension())) { + return false + } + + return true + } + + private fun isSupportedContentType(contentType: String?): Boolean { + return UploadConstants.SupportedTypes.contentTypes.contains(contentType) + } + + private fun isSupportedFileExtension(extension: String?): Boolean { + return UploadConstants.SupportedTypes.fileExtensions.contains(extension) + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 141dda9e..feb315ca 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -26,6 +26,16 @@ auth.jwt-access-expiration-minutes=60 auth.jwt-refresh-expiration-days=7 auth.jwt-recovery-expiration-minutes=15 +# File Upload Config +spring.servlet.multipart.max-file-size=500KB +upload.provider=static +upload.cloudinary-base-path=website +upload.cloudinary-url=GET_YOURS_AT_CLOUDINARY_DASHBOARD +# Folder in which files will be stored +upload.static-path=classpath:static +# URL that will serve static content +upload.static-serve=http://localhost:3000/static + # Due to a problem with Hibernate, which is using a deprecated property. This should be removed when fixed # See https://github.com/spring-projects/spring-data-jpa/issues/2717 for more information spring.jpa.properties.jakarta.persistence.sharedCache.mode=UNSPECIFIED diff --git a/src/main/resources/static/profile/.gitkeep b/src/main/resources/static/profile/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/resources/validation_errors.properties b/src/main/resources/validation_errors.properties index f6833279..bee292a1 100644 --- a/src/main/resources/validation_errors.properties +++ b/src/main/resources/validation_errors.properties @@ -1,3 +1,6 @@ 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) diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/annotations/validation/DateIntervalTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/annotations/validation/DateIntervalTest.kt index dd06d0b7..a7ed65d7 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/annotations/validation/DateIntervalTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/annotations/validation/DateIntervalTest.kt @@ -3,6 +3,8 @@ package pt.up.fe.ni.website.backend.annotations.validation import org.junit.jupiter.api.Test import pt.up.fe.ni.website.backend.model.embeddable.DateInterval import pt.up.fe.ni.website.backend.utils.TestUtils +import pt.up.fe.ni.website.backend.utils.validation.DateIntervalValidator +import pt.up.fe.ni.website.backend.utils.validation.ValidDateInterval internal class DateIntervalTest { @Test @@ -52,13 +54,14 @@ internal class DateIntervalTest { @Test fun `should fail when endDate is equal to startDate`() { + val date = TestUtils.createDate(2022, 12, 6) val validator = DateIntervalValidator() validator.initialize(ValidDateInterval()) assert( !validator.isValid( DateInterval( - TestUtils.createDate(2022, 12, 6), - TestUtils.createDate(2022, 12, 6) + date, + date ), null ) diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/annotations/validation/NoDuplicateRolesTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/annotations/validation/NoDuplicateRolesTest.kt new file mode 100644 index 00000000..f6f7b33b --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/annotations/validation/NoDuplicateRolesTest.kt @@ -0,0 +1,50 @@ +package pt.up.fe.ni.website.backend.annotations.validation + +import org.junit.jupiter.api.Test +import pt.up.fe.ni.website.backend.model.Role +import pt.up.fe.ni.website.backend.model.permissions.Permissions +import pt.up.fe.ni.website.backend.utils.validation.NoDuplicateRoles +import pt.up.fe.ni.website.backend.utils.validation.NoDuplicateRolesValidator + +internal class NoDuplicateRolesTest { + @Test + fun `should succeed with empty list`() { + val validator = NoDuplicateRolesValidator() + validator.initialize(NoDuplicateRoles()) + assert(validator.isValid(emptyList(), null)) + } + + @Test + fun `should succeed with one role`() { + val validator = NoDuplicateRolesValidator() + validator.initialize(NoDuplicateRoles()) + val roles = listOf(buildTestRole("role")) + assert(validator.isValid(roles, null)) + } + + @Test + fun `should succeed with unique roles`() { + val validator = NoDuplicateRolesValidator() + validator.initialize(NoDuplicateRoles()) + val roles = listOf(buildTestRole("role1"), buildTestRole("role2")) + assert(validator.isValid(roles, null)) + } + + @Test + fun `should fail with only duplicate roles`() { + val validator = NoDuplicateRolesValidator() + validator.initialize(NoDuplicateRoles()) + val roles = listOf(buildTestRole("role"), buildTestRole("role")) + assert(!validator.isValid(roles, null)) + } + + @Test + fun `should fail with duplicate roles`() { + val validator = NoDuplicateRolesValidator() + validator.initialize(NoDuplicateRoles()) + val roles = listOf(buildTestRole("role1"), buildTestRole("role2"), buildTestRole("role1")) + assert(!validator.isValid(roles, null)) + } + + private fun buildTestRole(name: String) = Role(name, Permissions(emptySet()), false, mutableListOf()) +} 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 index 21657fc8..d6e97636 100644 --- 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 @@ -1,6 +1,8 @@ package pt.up.fe.ni.website.backend.annotations.validation import org.junit.jupiter.api.Test +import pt.up.fe.ni.website.backend.utils.validation.NullOrNotBlank +import pt.up.fe.ni.website.backend.utils.validation.NullOrNotBlankValidator internal class NullOrNotBlankTest { @Test diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/annotations/validation/SchoolYearTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/annotations/validation/SchoolYearTest.kt new file mode 100644 index 00000000..d765af55 --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/annotations/validation/SchoolYearTest.kt @@ -0,0 +1,77 @@ +package pt.up.fe.ni.website.backend.annotations.validation + +import org.junit.jupiter.api.Test +import pt.up.fe.ni.website.backend.utils.validation.SchoolYear +import pt.up.fe.ni.website.backend.utils.validation.SchoolYearValidator + +internal class SchoolYearTest { + @Test + fun `should succeed when right format`() { + val validator = SchoolYearValidator() + validator.initialize(SchoolYear()) + assert(validator.isValid("22-23", null)) + } + + @Test + fun `should fail when wrong format`() { + val validator = SchoolYearValidator() + validator.initialize(SchoolYear()) + assert(!validator.isValid("22/23", null)) + } + + @Test + fun `should fail when given random string`() { + val validator = SchoolYearValidator() + validator.initialize(SchoolYear()) + assert(!validator.isValid("random_string_123", null)) + } + + @Test + fun `should fail when given empty string`() { + val validator = SchoolYearValidator() + validator.initialize(SchoolYear()) + assert(!validator.isValid("", null)) + } + + @Test + fun `should fail when given blank string`() { + val validator = SchoolYearValidator() + validator.initialize(SchoolYear()) + assert(!validator.isValid(" ", null)) + } + + @Test + fun `should fail when first year is not 2 digits`() { + val validator = SchoolYearValidator() + validator.initialize(SchoolYear()) + assert(!validator.isValid("2-23", null)) + } + + @Test + fun `should fail when second year is not 2 digits`() { + val validator = SchoolYearValidator() + validator.initialize(SchoolYear()) + assert(!validator.isValid("22-3", null)) + } + + @Test + fun `should fail when not subsequent years`() { + val validator = SchoolYearValidator() + validator.initialize(SchoolYear()) + assert(!validator.isValid("22-24", null)) + } + + @Test + fun `should fail when first year after second year`() { + val validator = SchoolYearValidator() + validator.initialize(SchoolYear()) + assert(!validator.isValid("23-22", null)) + } + + @Test + fun `should fail when years are the same`() { + val validator = SchoolYearValidator() + validator.initialize(SchoolYear()) + assert(!validator.isValid("22-22", null)) + } +} 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 79aeec65..067465b8 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 @@ -1,47 +1,76 @@ package pt.up.fe.ni.website.backend.controller +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.AfterEach +import org.junit.jupiter.api.Assertions 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.mock.web.MockPart +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put +import org.springframework.restdocs.payload.JsonFieldType +import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.post import org.springframework.test.web.servlet.put +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.constants.AccountConstants as Constants import pt.up.fe.ni.website.backend.repository.AccountRepository import pt.up.fe.ni.website.backend.utils.TestUtils import pt.up.fe.ni.website.backend.utils.ValidationTester import pt.up.fe.ni.website.backend.utils.annotations.ControllerTest import pt.up.fe.ni.website.backend.utils.annotations.NestedTest -import java.util.Calendar -import java.util.Date -import pt.up.fe.ni.website.backend.model.constants.AccountConstants as Constants +import pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model.PayloadAccount +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField +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.andDocumentCustomRequestSchemaEmptyResponse +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 @ControllerTest class AccountControllerTest @Autowired constructor( val mockMvc: MockMvc, val objectMapper: ObjectMapper, - val repository: AccountRepository + val repository: AccountRepository, + val encoder: PasswordEncoder, + val uploadConfigProperties: UploadConfigProperties ) { + val documentation: ModelDocumentation = PayloadAccount() + val testAccount = Account( "Test Account", "test_account@test.com", "test_password", "This is a test account", TestUtils.createDate(2001, Calendar.JULY, 28), - "https://test-photo.com", + null, "https://linkedin.com", "https://github.com", listOf( CustomWebsite("https://test-website.com", "https://test-website.com/logo.png") ), - emptyList() + mutableListOf() ) @NestedTest @@ -59,7 +88,7 @@ class AccountControllerTest @Autowired constructor( null, null, emptyList(), - emptyList() + mutableListOf() ) ) @@ -70,12 +99,18 @@ class AccountControllerTest @Autowired constructor( @Test fun `should return all accounts`() { - mockMvc.get("/accounts") - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - content { json(objectMapper.writeValueAsString(testAccounts)) } - } + mockMvc.perform(get("/accounts")) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + content().json(objectMapper.writeValueAsString(testAccounts)) + ) + .andDocument( + documentation.getModelDocumentationArray(), + "Get all the accounts", + "The operation returns an array of accounts, allowing to easily retrieve all " + + "the created accounts." + ) } } @@ -87,33 +122,49 @@ class AccountControllerTest @Autowired constructor( repository.save(testAccount) } + private val parameters = listOf( + parameterWithName("id").description( + "ID of the account to retrieve" + ) + ) + @Test fun `should return the account`() { - mockMvc.get("/accounts/${testAccount.id}") + mockMvc.perform(get("/accounts/{id}", 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) } + 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("$.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) } + .andDocument( + documentation, + "Get accounts by ID", + "This endpoint allows the retrieval of a single account using its ID.", + urlParameters = parameters + ) } @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") } - } + mockMvc.perform(get("/accounts/{id}", 1234)) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("account not found with id 1234") + ) + .andDocumentErrorResponse( + documentation, + urlParameters = parameters + ) } } @@ -127,24 +178,24 @@ class AccountControllerTest @Autowired constructor( @Test fun `should create the account`() { - mockMvc.post("/accounts/new") { - contentType = MediaType.APPLICATION_JSON - content = testAccount.toJson() - }.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) } - } + mockMvc.multipartBuilder("/accounts/new") + .addPart("dto", testAccount.toJson()) + .perform() + .andExpectAll( + 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("$.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 create an account with an empty website list`() { val noWebsite = Account( @@ -158,32 +209,104 @@ class AccountControllerTest @Autowired constructor( "https://github.com" ) - mockMvc.post("/accounts/new") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString( - mapOf( - "name" to noWebsite.name, - "email" to noWebsite.email, - "password" to noWebsite.password, - "bio" to noWebsite.bio, - "birthDate" to noWebsite.birthDate, - "photoPath" to noWebsite.photoPath, - "linkedin" to noWebsite.linkedin, - "github" to noWebsite.github - ) + val data = objectMapper.writeValueAsString( + mapOf( + "name" to noWebsite.name, + "email" to noWebsite.email, + "password" to noWebsite.password, + "bio" to noWebsite.bio, + "birthDate" to noWebsite.birthDate, + "linkedin" to noWebsite.linkedin, + "github" to noWebsite.github ) - }.andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.name") { value(noWebsite.name) } - jsonPath("$.email") { value(noWebsite.email) } - jsonPath("$.bio") { value(noWebsite.bio) } - jsonPath("$.birthDate") { value(noWebsite.birthDate.toJson()) } - jsonPath("$.photoPath") { value(noWebsite.photoPath) } - jsonPath("$.linkedin") { value(noWebsite.linkedin) } - jsonPath("$.github") { value(noWebsite.github) } - jsonPath("$.websites.length()") { value(0) } - } + ) + + mockMvc.multipartBuilder("/accounts/new") + .addPart("dto", data) + .perform() + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.name").value(noWebsite.name), + jsonPath("$.email").value(noWebsite.email), + jsonPath("$.bio").value(noWebsite.bio), + jsonPath("$.birthDate").value(noWebsite.birthDate.toJson()), + jsonPath("$.linkedin").value(noWebsite.linkedin), + jsonPath("$.github").value(noWebsite.github), + jsonPath("$.websites.length()").value(0) + ) + } + + @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()) + .addFile() + .perform() + .andExpectAll( + 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("$.linkedin").value(testAccount.linkedin), + jsonPath("$.github").value(testAccount.github), + jsonPath("$.photo").value(expectedPhotoPath), + jsonPath("$.websites.length()").value(1), + jsonPath("$.websites[0].url").value(testAccount.websites[0].url), + jsonPath("$.websites[0].iconPath").value(testAccount.websites[0].iconPath) + ) + + mockedSettings.close() + } + + @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) + .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].param").value("createAccount.photo") + ) + + mockedSettings.close() + } + + @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()) + .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].param").value("createAccount.photo") + ) + + mockedSettings.close() } @NestedTest @@ -191,10 +314,9 @@ class AccountControllerTest @Autowired constructor( inner class InputValidation { private val validationTester = ValidationTester( req = { params: Map -> - mockMvc.post("/accounts/new") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(params) - } + mockMvc.multipartBuilder("/accounts/new") + .addPart("dto", objectMapper.writeValueAsString(params)) + .perform() }, requiredFields = mapOf( "name" to testAccount.name, @@ -283,21 +405,6 @@ class AccountControllerTest @Autowired constructor( fun `should be in the past`() = validationTester.isPastDate() } - @NestedTest - @DisplayName("photoPath") - 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() - } - @NestedTest @DisplayName("linkedin") inner class LinkedinValidation { @@ -333,17 +440,21 @@ class AccountControllerTest @Autowired constructor( inner class WebsitesValidation { private val validationTester = ValidationTester( req = { params: Map -> - mockMvc.post("/accounts/new") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString( + val accountPart = MockPart( + "dto", + objectMapper.writeValueAsString( mapOf( "name" to testAccount.name, "email" to testAccount.email, "password" to testAccount.password, "websites" to listOf(params) ) - ) - } + ).toByteArray() + ) + accountPart.headers.contentType = MediaType.APPLICATION_JSON + + mockMvc.perform(multipart("/accounts/new").part(accountPart)) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) }, requiredFields = mapOf( "url" to "https://www.google.com" @@ -402,21 +513,393 @@ class AccountControllerTest @Autowired constructor( @Test fun `should fail to create account with existing email`() { - println("testAccount: ${objectMapper.writeValueAsString(testAccount)}") - mockMvc.post("/accounts/new") { + mockMvc.multipartBuilder("/accounts/new") + .addPart("dto", testAccount.toJson()) + .perform() + .andExpect(status().isOk) + + mockMvc.multipartBuilder("/accounts/new") + .addPart("dto", testAccount.toJson()) + .perform() + .andExpectAll( + status().isUnprocessableEntity, + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("email already exists") + ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + } + } + + @NestedTest + @DisplayName("POST /accounts/changePassword/{id}") + inner class ChangePassword { + private val password = "test_password" + private val changePasswordAccount: Account = ObjectMapper().readValue( + ObjectMapper().writeValueAsString(testAccount), + Account::class.java + ) + + init { + changePasswordAccount.password = encoder.encode(changePasswordAccount.password) + } + + private val parameters = listOf( + parameterWithName("id").description( + "ID of the account to change the password." + ) + ) + + private val passwordChangePayload = PayloadSchema( + "password-change", + mutableListOf( + DocumentedJSONField("oldPassword", "Current account password", JsonFieldType.STRING), + DocumentedJSONField("newPassword", "New account password", JsonFieldType.STRING) + ) + ) + + @BeforeEach + fun addAccount() { + repository.save(changePasswordAccount) + } + + @Test + fun `should change password`() { + mockMvc.perform( + post("/accounts/changePassword/{id}", changePasswordAccount.id) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "oldPassword" to password, + "newPassword" to "test_password2" + ) + ) + ) + ).andExpectAll(status().isOk) + .andDocumentCustomRequestSchemaEmptyResponse( + documentation, + passwordChangePayload, + "Change account password", + "Replaces sets a new account password", + urlParameters = parameters, + documentRequestPayload = true + ) + + mockMvc.post("/auth/new") { contentType = MediaType.APPLICATION_JSON - content = testAccount.toJson() + content = objectMapper.writeValueAsString( + mapOf( + "email" to changePasswordAccount.email, + "password" to "test_password2" + ) + ) }.andExpect { status { isOk() } } + } - mockMvc.post("/accounts/new") { - contentType = MediaType.APPLICATION_JSON - content = testAccount.toJson() - }.andExpect { - status { isUnprocessableEntity() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.errors.length()") { value(1) } - jsonPath("$.errors[0].message") { value("email already exists") } - } + @Test + fun `should fail due to wrong password`() { + mockMvc.perform( + post("/accounts/changePassword/{id}", changePasswordAccount.id) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "oldPassword" to "wrong_password", + "newPassword" to "test_password2" + ) + ) + ) + ).andExpectAll(status().isUnprocessableEntity) + .andDocumentCustomRequestSchemaErrorResponse( + documentation, + passwordChangePayload, + urlParameters = parameters, + hasRequestPayload = true + ) + } + } + + @NestedTest + @DisplayName("DELETE /accounts/{accountId}") + inner class DeleteAccount { + @BeforeEach + fun addAccount() { + repository.save(testAccount) + } + + private val parameters = listOf(parameterWithName("id").description("ID of the account to delete")) + + @Test + fun `should delete the account`() { + mockMvc.perform(delete("/accounts/{id}", testAccount.id)).andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$").isEmpty + ) + .andDocumentEmptyObjectResponse( + documentation, + "Delete accounts", + "This operation deletes an account using its ID.", + urlParameters = parameters + ) + + assert(repository.findById(testAccount.id!!).isEmpty) + } + + @Test + fun `should fail if the account does not exist`() { + mockMvc.perform(delete("/accounts/{id}", 1234)).andExpectAll( + + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("account not found with id 1234") + ) + .andDocumentErrorResponse( + documentation, + urlParameters = parameters + ) + } + } + + @NestedTest + @DisplayName("PUT /accounts/{accountId}") + inner class UpdateAccount { + private val newAccount = Account( + "Another test Account", + "test2_account@test.com", + "test_password", + "This is another test account", + TestUtils.createDate(2003, Calendar.APRIL, 4), + "https://test-photo.com", + "https://linkedin.com", + "https://github.com", + listOf( + CustomWebsite("https://test-website.com", "https://test-website.com/logo.png") + ) + ) + + @BeforeEach + fun addAccounts() { + repository.save(testAccount) + repository.save(newAccount) + } + + private val documentation = PayloadAccount(includePassword = false) + 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") + ) + + 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}") + .addPart("dto", data) + .asPutMethod() + .perform() + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.name").value(newName), + jsonPath("$.email").value(newEmail), + jsonPath("$.bio").value(newBio), + jsonPath("$.birthDate").value(newBirthDate.toJson()), + jsonPath("$.linkedin").value(newLinkedin), + jsonPath("$.github").value(newGithub), + jsonPath("$.websites.length()").value(1), + 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) + Assertions.assertEquals(newEmail, updatedAccount.email) + Assertions.assertEquals(newBio, updatedAccount.bio) + Assertions.assertEquals(newBirthDate.toJson(), updatedAccount.birthDate.toJson()) + Assertions.assertEquals(newLinkedin, updatedAccount.linkedin) + Assertions.assertEquals(newWebsites[0].url, updatedAccount.websites[0].url) + Assertions.assertEquals(newWebsites[0].iconPath, updatedAccount.websites[0].iconPath) + } + + @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) + .addFile() + .perform() + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.name").value(newName), + jsonPath("$.email").value(newEmail), + jsonPath("$.bio").value(newBio), + jsonPath("$.birthDate").value(newBirthDate.toJson()), + jsonPath("$.photo").value(expectedPhotoPath), + jsonPath("$.linkedin").value(newLinkedin), + jsonPath("$.github").value(newGithub), + jsonPath("$.websites.length()").value(1), + 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) + Assertions.assertEquals(newEmail, updatedAccount.email) + Assertions.assertEquals(expectedPhotoPath, updatedAccount.photo) + Assertions.assertEquals(newBio, updatedAccount.bio) + Assertions.assertEquals(newBirthDate.toJson(), updatedAccount.birthDate.toJson()) + 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") + ) + + 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 + ) + ) + + mockMvc.multipartBuilder("/accounts/${1234}") + .addPart("dto", data) + .asPutMethod() + .perform() + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("account not found with id 1234") + ) +// .andDocumentErrorResponse( +// documentation, +// urlParameters = parameters, +// hasRequestPayload = true +// ) + } + + @Test + fun `should fail if the new email is already taken`() { + val newName = "Test Account 2" + 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") + ) + + val data = objectMapper.writeValueAsString( + mapOf( + "name" to newName, + "email" to "test2_account@test.com", + "bio" to newBio, + "birthDate" to newBirthDate, + "photoPath" to newPhotoPath, + "linkedin" to newLinkedin, + "github" to newGithub, + "websites" to newWebsites + ) + ) + + mockMvc.multipartBuilder("/accounts/${testAccount.id}") + .addPart("dto", data) + .asPutMethod() + .perform() + .andExpectAll( + status().isUnprocessableEntity, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("email already exists") + ) +// .andDocumentErrorResponse( +// documentation, +// urlParameters = parameters, +// hasRequestPayload = true +// ) } } @@ -425,6 +908,17 @@ class AccountControllerTest @Autowired constructor( inner class RecoverPassword { private val newPassword = "new-password" + private val parameters = listOf( + parameterWithName("recoveryToken").description("The recovery token sent to the user's email.") + ) + + private val passwordRecoveryPayload = PayloadSchema( + "password-recover", + mutableListOf( + DocumentedJSONField("password", "The new password.", JsonFieldType.STRING), + ) + ) + @BeforeEach fun setup() { repository.save(testAccount) @@ -432,37 +926,52 @@ class AccountControllerTest @Autowired constructor( @Test fun `should update the password`() { - mockMvc.post("/auth/recoverPassword/${testAccount.id}") + mockMvc.perform(post("/auth/recoverPassword/${testAccount.id}")) .andReturn().response.let { authResponse -> val recoveryLink = objectMapper.readTree(authResponse.contentAsString)["recovery_url"].asText() .removePrefix("localhost:8080") - mockMvc.put(recoveryLink) { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString( - mapOf( - "password" to newPassword + mockMvc.perform(put(recoveryLink) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "password" to newPassword + ) ) ) - }.andExpect { - status { isOk() } - } + ).andExpectAll( + status().isOk() + ).andDocumentCustomRequestSchemaEmptyResponse( + documentation, + passwordRecoveryPayload, + "Recover password", + "Update the password of an account using a recovery token.", + urlParameters = parameters, + documentRequestPayload = true + ) } } @Test fun `should fail when token is invalid`() { - mockMvc.put("/accounts/recoverPassword/invalid_token") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString( - mapOf( - "password" to newPassword + mockMvc.perform(put("/accounts/recoverPassword/invalid_token") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "password" to newPassword + ) ) ) - }.andExpect { - status { isUnauthorized() } - jsonPath("$.errors.length()") { value(1) } - jsonPath("$.errors[0].message") { value("invalid password recovery token") } - } + ).andExpectAll( + status().isUnauthorized(), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("invalid password recovery token") + ).andDocumentCustomRequestSchemaErrorResponse( + documentation, + passwordRecoveryPayload, + hasRequestPayload = true + ) } } 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 0975e607..34375725 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 @@ -1,16 +1,23 @@ package pt.up.fe.ni.website.backend.controller +import com.epages.restdocs.apispec.HeaderDescriptorWithType +import com.epages.restdocs.apispec.ResourceDocumentation.headerWithName import com.fasterxml.jackson.databind.ObjectMapper +import java.util.Calendar import org.hamcrest.Matchers.startsWith import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpHeaders import org.springframework.http.MediaType +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.post +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.dto.auth.LoginDto import pt.up.fe.ni.website.backend.dto.auth.TokenDto import pt.up.fe.ni.website.backend.model.Account @@ -19,7 +26,13 @@ import pt.up.fe.ni.website.backend.repository.AccountRepository import pt.up.fe.ni.website.backend.utils.TestUtils import pt.up.fe.ni.website.backend.utils.annotations.ControllerTest import pt.up.fe.ni.website.backend.utils.annotations.NestedTest -import java.util.Calendar +import pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model.PayloadAuthCheck +import pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model.PayloadAuthNew +import pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model.PayloadAuthRefresh +import pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model.PayloadRecoverPassword +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.andDocumentErrorResponse +import pt.up.fe.ni.website.backend.utils.documentation.utils.ModelDocumentation @ControllerTest class AuthControllerTest @Autowired constructor( @@ -43,7 +56,11 @@ class AuthControllerTest @Autowired constructor( listOf( CustomWebsite("https://test-website.com", "https://test-website.com/logo.png") ), - emptyList(), + mutableListOf() + ) + + private val checkAuthHeaders = listOf( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer authentication token") ) @NestedTest @@ -54,43 +71,62 @@ class AuthControllerTest @Autowired constructor( repository.save(testAccount) } + val documentation: ModelDocumentation = PayloadAuthNew() + @Test fun `should fail when email is not registered`() { - mockMvc.post("/auth/new") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString( - mapOf( - "email" to "president@niaefeup.pt", - "password" to testPassword + mockMvc.perform( + post("/auth/new") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "email" to "president@niaefeup.pt", + "password" to testPassword + ) + ) ) + ) + .andExpectAll( + status().isNotFound, + jsonPath("$.errors[0].message").value("account not found with email president@niaefeup.pt") ) - }.andExpect { - status { isNotFound() } - jsonPath("$.errors[0].message") { value("account not found with email president@niaefeup.pt") } - } + .andDocumentErrorResponse(documentation, hasRequestPayload = true) } @Test fun `should fail when password is incorrect`() { - mockMvc.post("/auth/new") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(LoginDto(testAccount.email, "wrong_password")) - }.andExpect { - status { isUnauthorized() } - jsonPath("$.errors[0].message") { value("invalid credentials") } - } + mockMvc.perform( + post("/auth/new") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(LoginDto(testAccount.email, "wrong_password"))) + ) + .andExpectAll( + status().isUnauthorized, + jsonPath("$.errors[0].message").value("invalid credentials") + ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) } @Test fun `should return access and refresh tokens`() { - mockMvc.post("/auth/new") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(LoginDto(testAccount.email, testPassword)) - }.andExpect { - status { isOk() } - jsonPath("$.access_token") { exists() } - jsonPath("$.refresh_token") { exists() } - } + mockMvc.perform( + post("/auth/new") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(LoginDto(testAccount.email, testPassword))) + ) + .andExpectAll( + status().isOk, + jsonPath("$.access_token").exists(), + jsonPath("$.refresh_token").exists() + ) + .andDocument( + documentation, + "Authenticate account", + "This endpoint operation allows authentication using user's password and email, " + + "generating new access and refresh tokens to be used in following requests.", + documentRequestPayload = true + ) } } @@ -102,15 +138,20 @@ class AuthControllerTest @Autowired constructor( repository.save(testAccount) } + val documentation: ModelDocumentation = PayloadAuthRefresh() + @Test fun `should fail when refresh token is invalid`() { - mockMvc.post("/auth/refresh") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(TokenDto("invalid_refresh_token")) - }.andExpect { - status { isUnauthorized() } - jsonPath("$.errors[0].message") { value("invalid refresh token") } - } + mockMvc.perform( + post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(TokenDto("invalid_refresh_token"))) + ) + .andExpectAll( + status().isUnauthorized, + jsonPath("$.errors[0].message").value("invalid refresh token") + ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) } @Test @@ -120,13 +161,22 @@ class AuthControllerTest @Autowired constructor( content = objectMapper.writeValueAsString(LoginDto(testAccount.email, testPassword)) }.andReturn().response.let { response -> val refreshToken = objectMapper.readTree(response.contentAsString)["refresh_token"].asText() - mockMvc.post("/auth/refresh") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(TokenDto(refreshToken)) - }.andExpect { - status { isOk() } - jsonPath("$.access_token") { exists() } - } + mockMvc.perform( + post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(TokenDto(refreshToken))) + ) + .andExpectAll( + status().isOk, + jsonPath("$.access_token").exists() + ) + .andDocument( + documentation, + "Refresh access token", + "This endpoint operation allows the renewal of the access token, " + + "using the currently valid refresh token.", + documentRequestPayload = true + ) } } } @@ -134,6 +184,8 @@ class AuthControllerTest @Autowired constructor( @NestedTest @DisplayName("POST /auth/recoverPassword/{id}") inner class RecoverPassword { + var documentation: ModelDocumentation = PayloadRecoverPassword() + @BeforeEach fun setup() { repository.save(testAccount) @@ -141,48 +193,62 @@ class AuthControllerTest @Autowired constructor( @Test fun `should fail if id is not found`() { - mockMvc.post("/auth/recoverPassword/1234") - .andExpect { - status { isNotFound() } - jsonPath("$.errors.length()") { value(1) } - jsonPath("$.errors[0].message") { value("account not found with id 1234") } - } + mockMvc.perform(post("/auth/recoverPassword/1234")) + .andExpectAll( + status().isNotFound(), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("account not found with id 1234") + ).andDocument( + documentation, + "Recover password", + "This endpoint operation allows the recovery of the password of an account, " + + "sending an email with a link to reset the password.", + documentRequestPayload = false + ) } @Test fun `should return password recovery link`() { - mockMvc.post("/auth/recoverPassword/${testAccount.id}") - .andExpect { - status { isOk() } - jsonPath("$.recovery_url") { exists() } - } + mockMvc.perform(post("/auth/recoverPassword/${testAccount.id}")) + .andExpectAll( + status().isOk(), + jsonPath("$.recovery_url").exists() + ).andDocumentErrorResponse(documentation) } } @NestedTest - @DisplayName("GET /auth/check") + @DisplayName("GET /auth") inner class CheckToken { @BeforeEach fun setup() { repository.save(testAccount) } + val documentation: ModelDocumentation = PayloadAuthCheck() + @Test fun `should fail when no access token is provided`() { - mockMvc.get("/auth").andExpect { - status { isForbidden() } - jsonPath("$.errors[0].message") { value("Access Denied") } - } + mockMvc.perform(get("/auth")).andExpectAll( + status().isForbidden, + jsonPath("$.errors[0].message").value("Access Denied") + ) + .andDocumentErrorResponse(documentation) } @Test fun `should fail when access token is invalid`() { - mockMvc.get("/auth") { - header("Authorization", "Bearer invalid_access_token") - }.andExpect { - status { isUnauthorized() } - jsonPath("$.errors[0].message") { startsWith("An error occurred while attempting to decode the Jwt") } - } + mockMvc.perform( + get("/auth") + .header("Authorization", "Bearer invalid_access_token") + ) + .andExpectAll( + status().isUnauthorized, + jsonPath("$.errors[0].message").value( + startsWith("An error occurred while attempting to decode the Jwt") + ) + ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) } @Test @@ -192,12 +258,22 @@ class AuthControllerTest @Autowired constructor( content = objectMapper.writeValueAsString(LoginDto(testAccount.email, testPassword)) }.andReturn().response.let { response -> val accessToken = objectMapper.readTree(response.contentAsString)["access_token"].asText() - mockMvc.get("/auth") { - header("Authorization", "Bearer $accessToken") - }.andExpect { - status { isOk() } - jsonPath("$.authenticated_user") { value(testAccount.email) } - } + + mockMvc.perform( + get("/auth") + .header("Authorization", "Bearer $accessToken") + ).andExpectAll( + status().isOk, + jsonPath("$.authenticated_user.email").value(testAccount.email) + ) + .andDocument( + documentation, + "Check access token", + "This endpoint operation allows to check if a given access token is valid, returning " + + "the associated account's information.", + checkAuthHeaders, + documentRequestPayload = true + ) } } } 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 86a73028..ca0e12fb 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 @@ -1,6 +1,9 @@ package pt.up.fe.ni.website.backend.controller +import com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName import com.fasterxml.jackson.databind.ObjectMapper +import java.util.Calendar +import java.util.Date import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeEach @@ -8,15 +11,20 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test 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.delete -import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.post -import org.springframework.test.web.servlet.put +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.model.Account import pt.up.fe.ni.website.backend.model.CustomWebsite import pt.up.fe.ni.website.backend.model.Event import pt.up.fe.ni.website.backend.model.constants.ActivityConstants +import pt.up.fe.ni.website.backend.model.constants.EventConstants as Constants import pt.up.fe.ni.website.backend.model.embeddable.DateInterval import pt.up.fe.ni.website.backend.repository.AccountRepository import pt.up.fe.ni.website.backend.repository.EventRepository @@ -24,9 +32,10 @@ import pt.up.fe.ni.website.backend.utils.TestUtils import pt.up.fe.ni.website.backend.utils.ValidationTester import pt.up.fe.ni.website.backend.utils.annotations.ControllerTest import pt.up.fe.ni.website.backend.utils.annotations.NestedTest -import java.util.Calendar -import java.util.Date -import pt.up.fe.ni.website.backend.model.constants.EventConstants as Constants +import pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model.PayloadEvent +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 @ControllerTest internal class EventControllerTest @Autowired constructor( @@ -46,14 +55,15 @@ internal class EventControllerTest @Autowired constructor( "https://github.com", listOf( CustomWebsite("https://test-website.com", "https://test-website.com/logo.png") - ), - emptyList() + ) ) val testEvent = Event( "Great event", "This was a nice and iconic event", mutableListOf(testAccount), + mutableListOf(), + "great-event", "https://docs.google.com/forms", DateInterval( TestUtils.createDate(2022, Calendar.JULY, 28), @@ -64,6 +74,8 @@ internal class EventControllerTest @Autowired constructor( "https://example.com/exampleThumbnail" ) + val documentation = PayloadEvent() + @NestedTest @DisplayName("GET /events") inner class GetAllEvents { @@ -73,6 +85,8 @@ internal class EventControllerTest @Autowired constructor( "Bad event", "This event was a failure", mutableListOf(), + mutableListOf(), + null, null, DateInterval( TestUtils.createDate(2021, Calendar.OCTOBER, 27), @@ -92,52 +106,123 @@ internal class EventControllerTest @Autowired constructor( @Test fun `should return all events`() { - mockMvc.get("/events") - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - content { json(objectMapper.writeValueAsString(testEvents)) } - } + mockMvc.perform(get("/events").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(testEvents))) + .andDocument( + documentation.getModelDocumentationArray(), + "Get all the events", + """The operation returns an array of events, allowing to easily retrieve all the created events. + |This is useful for example in the frontend's event page, where events are displayed. + """.trimMargin() + ) } } @NestedTest @DisplayName("GET /events/{id}") - inner class GetEvent { + inner class GetEventById { @BeforeEach fun addToRepositories() { accountRepository.save(testAccount) repository.save(testEvent) } + private val parameters = listOf(parameterWithName("id").description("ID of the event to retrieve")) + @Test fun `should return the event`() { - mockMvc.get("/events/${testEvent.id}") - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.title") { value(testEvent.title) } - jsonPath("$.description") { value(testEvent.description) } - jsonPath("$.teamMembers.length()") { value(1) } - jsonPath("$.teamMembers[0].email") { value(testAccount.email) } - jsonPath("$.teamMembers[0].name") { value(testAccount.name) } - jsonPath("$.registerUrl") { value(testEvent.registerUrl) } - jsonPath("$.dateInterval.startDate") { value(testEvent.dateInterval.startDate.toJson()) } - jsonPath("$.dateInterval.endDate") { value(testEvent.dateInterval.endDate.toJson()) } - jsonPath("$.location") { value(testEvent.location) } - jsonPath("$.category") { value(testEvent.category) } - jsonPath("$.thumbnailPath") { value(testEvent.thumbnailPath) } - } + mockMvc.perform(get("/events/{id}", testEvent.id)) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.title").value(testEvent.title), + jsonPath("$.description").value(testEvent.description), + jsonPath("$.teamMembers.length()").value(1), + jsonPath("$.teamMembers[0].email").value(testAccount.email), + jsonPath("$.teamMembers[0].name").value(testAccount.name), + jsonPath("$.registerUrl").value(testEvent.registerUrl), + jsonPath("$.dateInterval.startDate").value(testEvent.dateInterval.startDate.toJson()), + jsonPath("$.dateInterval.endDate").value(testEvent.dateInterval.endDate.toJson()), + jsonPath("$.location").value(testEvent.location), + jsonPath("$.category").value(testEvent.category), + jsonPath("$.thumbnailPath").value(testEvent.thumbnailPath), + jsonPath("$.slug").value(testEvent.slug) + + ) + .andDocument( + documentation, + "Get events by ID", + "This endpoint allows the retrieval of a single event using its ID. " + + "It might be used to generate the specific event page.", + urlParameters = parameters + ) } @Test fun `should fail if the event does not exist`() { - mockMvc.get("/events/1234").andExpect { - status { isNotFound() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.errors.length()") { value(1) } - jsonPath("$.errors[0].message") { value("event not found with id 1234") } - } + mockMvc.perform(get("/events/{id}", 1234)) + .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) + } + } + + @NestedTest + @DisplayName("GET /events/{eventSlug}") + inner class GetEventBySlug { + @BeforeEach + fun addToRepositories() { + accountRepository.save(testAccount) + repository.save(testEvent) + } + + private val parameters = listOf( + parameterWithName("slug").description("Short and friendly textual event identifier") + ) + + @Test + fun `should return the event`() { + mockMvc.perform(get("/events/{slug}", testEvent.slug)) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.title").value(testEvent.title), + jsonPath("$.description").value(testEvent.description), + jsonPath("$.teamMembers.length()").value(1), + jsonPath("$.teamMembers[0].email").value(testAccount.email), + jsonPath("$.teamMembers[0].name").value(testAccount.name), + jsonPath("$.registerUrl").value(testEvent.registerUrl), + jsonPath("$.dateInterval.startDate").value(testEvent.dateInterval.startDate.toJson()), + jsonPath("$.dateInterval.endDate").value(testEvent.dateInterval.endDate.toJson()), + jsonPath("$.location").value(testEvent.location), + jsonPath("$.category").value(testEvent.category), + jsonPath("$.thumbnailPath").value(testEvent.thumbnailPath), + jsonPath("$.slug").value(testEvent.slug) + ) + .andDocument( + documentation, + "Get events by slug", + "This endpoint allows the retrieval of a single event using its slug.", + urlParameters = parameters + ) + } + + @Test + fun `should fail if the event slug does not exist`() { + mockMvc.perform(get("/events/{slug}", "fail-slug")) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("event not found with slug fail-slug") + ) + .andDocumentErrorResponse(documentation, urlParameters = parameters) } } @@ -150,6 +235,8 @@ internal class EventControllerTest @Autowired constructor( "Bad event", "This event was a failure", mutableListOf(testAccount), + mutableListOf(), + null, null, DateInterval( TestUtils.createDate(2021, Calendar.OCTOBER, 27), @@ -163,6 +250,8 @@ internal class EventControllerTest @Autowired constructor( "Mid event", "This event was ok", mutableListOf(), + mutableListOf(), + null, null, DateInterval( TestUtils.createDate(2022, Calendar.JANUARY, 15), @@ -176,6 +265,8 @@ internal class EventControllerTest @Autowired constructor( "Cool event", "This event was a awesome", mutableListOf(testAccount), + mutableListOf(), + null, null, DateInterval( TestUtils.createDate(2022, Calendar.SEPTEMBER, 11), @@ -187,6 +278,8 @@ internal class EventControllerTest @Autowired constructor( ) ) + private val parameters = listOf(parameterWithName("category").description("Category of the events to retrieve")) + @BeforeEach fun addToRepositories() { accountRepository.save(testAccount) @@ -195,14 +288,21 @@ internal class EventControllerTest @Autowired constructor( @Test fun `should return all events of the category`() { - mockMvc.get("/events/category/${testEvent.category}") - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.length()") { value(2) } - jsonPath("$[0].category") { value(testEvent.category) } - jsonPath("$[1].category") { value(testEvent.category) } - } + mockMvc.perform(get("/events/category/{category}", testEvent.category)) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.length()").value(2), + jsonPath("$[0].category").value(testEvent.category), + jsonPath("$[1].category").value(testEvent.category) + ) + .andDocument( + documentation.getModelDocumentationArray(), + "Get events by category", + "This endpoint allows the retrieval of events labeled with a given category. " + + "It might be used to filter events in the event page.", + urlParameters = parameters + ) } } @@ -216,35 +316,83 @@ internal class EventControllerTest @Autowired constructor( @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 + ) + ) + ) + ) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.title").value(testEvent.title), + jsonPath("$.description").value(testEvent.description), + jsonPath("$.teamMembers.length()").value(1), + jsonPath("$.teamMembers[0].email").value(testAccount.email), + jsonPath("$.teamMembers[0].name").value(testAccount.name), + jsonPath("$.dateInterval.startDate").value(testEvent.dateInterval.startDate.toJson()), + jsonPath("$.dateInterval.endDate").value(testEvent.dateInterval.endDate.toJson()), + jsonPath("$.location").value(testEvent.location), + jsonPath("$.category").value(testEvent.category), + jsonPath("$.thumbnailPath").value(testEvent.thumbnailPath), + jsonPath("$.slug").value(testEvent.slug) + ) + .andDocument( + documentation, + "Create new events", + "This endpoint operation creates a new event.", + documentRequestPayload = true + ) + } + + @Test + fun `should fail if slug already exists`() { + val duplicatedSlugEvent = Event( + "Duplicated slug", + "This have a duplicated slug", + mutableListOf(testAccount), + mutableListOf(), + testEvent.slug, + "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" + ) + mockMvc.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 - ) + content = objectMapper.writeValueAsString(testEvent) + }.andExpect { status { isOk() } } + + mockMvc.perform( + post("/events/new") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(duplicatedSlugEvent)) + ) + .andExpectAll( + status().isUnprocessableEntity, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("slug already exists") ) - } - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.title") { value(testEvent.title) } - jsonPath("$.description") { value(testEvent.description) } - jsonPath("$.teamMembers.length()") { value(1) } - jsonPath("$.teamMembers[0].email") { value(testAccount.email) } - jsonPath("$.teamMembers[0].name") { value(testAccount.name) } - jsonPath("$.dateInterval.startDate") { value(testEvent.dateInterval.startDate.toJson()) } - jsonPath("$.dateInterval.endDate") { value(testEvent.dateInterval.endDate.toJson()) } - jsonPath("$.location") { value(testEvent.location) } - jsonPath("$.category") { value(testEvent.category) } - jsonPath("$.thumbnailPath") { value(testEvent.thumbnailPath) } - } + .andDocumentErrorResponse(documentation, hasRequestPayload = true) } @NestedTest @@ -252,16 +400,19 @@ internal class EventControllerTest @Autowired constructor( inner class InputValidation { private val validationTester = ValidationTester( req = { params: Map -> - mockMvc.post("/events/new") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(params) - } + mockMvc.perform( + post("/events/new") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(params)) + ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) }, requiredFields = mapOf( "title" to testEvent.title, "description" to testEvent.description, "dateInterval" to testEvent.dateInterval, - "thumbnailPath" to testEvent.thumbnailPath + "thumbnailPath" to testEvent.thumbnailPath, + "slug" to testEvent.slug ) ) @@ -277,7 +428,10 @@ internal class EventControllerTest @Autowired constructor( fun `should be required`() = validationTester.isRequired() @Test - @DisplayName("size should be between ${ActivityConstants.Title.minSize} and ${ActivityConstants.Title.maxSize}()") + @DisplayName( + "size should be between ${ActivityConstants.Title.minSize}" + + " and ${ActivityConstants.Title.maxSize}()" + ) fun size() = validationTester.hasSizeBetween(ActivityConstants.Title.minSize, ActivityConstants.Title.maxSize) } @@ -294,7 +448,10 @@ internal class EventControllerTest @Autowired constructor( fun `should be required`() = validationTester.isRequired() @Test - @DisplayName("size should be between ${ActivityConstants.Description.minSize} and ${ActivityConstants.Description.maxSize}()") + @DisplayName( + "size should be between ${ActivityConstants.Description.minSize}" + + " and ${ActivityConstants.Description.maxSize}()" + ) fun size() = validationTester.hasSizeBetween( ActivityConstants.Description.minSize, @@ -377,6 +534,24 @@ internal class EventControllerTest @Autowired constructor( @Test fun `should not be empty`() = validationTester.isNotEmpty() } + + @NestedTest + @DisplayName("slug") + inner class SlugValidation { + @BeforeAll + fun setParam() { + validationTester.param = "slug" + } + + @Test + @DisplayName( + "size should be between ${ActivityConstants.Slug.minSize} and ${ActivityConstants.Slug.maxSize}()" + ) + fun size() = validationTester.hasSizeBetween( + ActivityConstants.Slug.minSize, + ActivityConstants.Slug.maxSize + ) + } } } @@ -389,25 +564,36 @@ internal class EventControllerTest @Autowired constructor( repository.save(testEvent) } + private val parameters = listOf(parameterWithName("id").description("ID of the event to delete")) + @Test fun `should delete the event`() { - mockMvc.delete("/events/${testEvent.id}").andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$") { isEmpty() } - } + mockMvc.perform(delete("/events/{id}", testEvent.id)) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$").isEmpty + ) + .andDocumentEmptyObjectResponse( + documentation, + "Delete events", + "This operation deletes an event using its ID.", + urlParameters = parameters + ) assert(repository.findById(testEvent.id!!).isEmpty) } @Test fun `should fail if the event does not exist`() { - mockMvc.delete("/events/1234").andExpect { - status { isNotFound() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.errors.length()") { value(1) } - jsonPath("$.errors[0].message") { value("event not found with id 1234") } - } + mockMvc.perform(delete("/events/{id}", 1234)) + .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) } } @@ -426,8 +612,7 @@ internal class EventControllerTest @Autowired constructor( "https://github.com", listOf( CustomWebsite("https://test-website.com", "https://test-website.com/logo.png") - ), - emptyList() + ) ) @BeforeEach @@ -437,46 +622,57 @@ internal class EventControllerTest @Autowired constructor( repository.save(testEvent) } + private val parameters = listOf( + parameterWithName("eventId") + .description("ID of the event to add the member to"), + parameterWithName("accountId").description("ID of the account to add") + ) + @Test fun `should add a team member`() { - mockMvc.put("/events/${testEvent.id}/addTeamMember/${newAccount.id}") - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.teamMembers.length()") { value(2) } - jsonPath("$.teamMembers.length()") { value(2) } - jsonPath("$.teamMembers[0].name") { value(testAccount.name) } - jsonPath("$.teamMembers[0].email") { value(testAccount.email) } - jsonPath("$.teamMembers[0].bio") { value(testAccount.bio) } - jsonPath("$.teamMembers[0].birthDate") { value(testAccount.birthDate.toJson()) } - jsonPath("$.teamMembers[0].photoPath") { value(testAccount.photoPath) } - jsonPath("$.teamMembers[0].linkedin") { value(testAccount.linkedin) } - jsonPath("$.teamMembers[0].github") { value(testAccount.github) } - jsonPath("$.teamMembers[0].websites.length()") { value(1) } - jsonPath("$.teamMembers[0].websites[0].url") { value(testAccount.websites[0].url) } - jsonPath("$.teamMembers[0].websites[0].iconPath") { value(testAccount.websites[0].iconPath) } - jsonPath("$.teamMembers[1].name") { value(newAccount.name) } - jsonPath("$.teamMembers[1].email") { value(newAccount.email) } - jsonPath("$.teamMembers[1].bio") { value(newAccount.bio) } - jsonPath("$.teamMembers[1].birthDate") { value(newAccount.birthDate.toJson()) } - jsonPath("$.teamMembers[1].photoPath") { value(newAccount.photoPath) } - jsonPath("$.teamMembers[1].linkedin") { value(newAccount.linkedin) } - jsonPath("$.teamMembers[1].github") { value(newAccount.github) } - jsonPath("$.teamMembers[1].websites.length()") { value(1) } - jsonPath("$.teamMembers[1].websites[0].url") { value(newAccount.websites[0].url) } - jsonPath("$.teamMembers[1].websites[0].iconPath") { value(newAccount.websites[0].iconPath) } - } + mockMvc.perform(put("/events/{eventId}/addTeamMember/{accountId}", testEvent.id, newAccount.id)) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.teamMembers.length()").value(2), + jsonPath("$.teamMembers.length()").value(2), + jsonPath("$.teamMembers[0].name").value(testAccount.name), + jsonPath("$.teamMembers[0].email").value(testAccount.email), + jsonPath("$.teamMembers[0].bio").value(testAccount.bio), + jsonPath("$.teamMembers[0].birthDate").value(testAccount.birthDate.toJson()), + jsonPath("$.teamMembers[0].linkedin").value(testAccount.linkedin), + jsonPath("$.teamMembers[0].github").value(testAccount.github), + jsonPath("$.teamMembers[0].websites.length()").value(1), + jsonPath("$.teamMembers[0].websites[0].url").value(testAccount.websites[0].url), + jsonPath("$.teamMembers[0].websites[0].iconPath").value(testAccount.websites[0].iconPath), + jsonPath("$.teamMembers[1].name").value(newAccount.name), + jsonPath("$.teamMembers[1].email").value(newAccount.email), + jsonPath("$.teamMembers[1].bio").value(newAccount.bio), + jsonPath("$.teamMembers[1].birthDate").value(newAccount.birthDate.toJson()), + jsonPath("$.teamMembers[1].linkedin").value(newAccount.linkedin), + jsonPath("$.teamMembers[1].github").value(newAccount.github), + jsonPath("$.teamMembers[1].websites.length()").value(1), + jsonPath("$.teamMembers[1].websites[0].url").value(newAccount.websites[0].url), + jsonPath("$.teamMembers[1].websites[0].iconPath").value(newAccount.websites[0].iconPath) + ) + .andDocument( + documentation, + "Add team member to event", + "This operation adds a team member to a given event.", + urlParameters = parameters + ) } @Test fun `should fail if the team member does not exist`() { - mockMvc.put("/events/${testEvent.id}/addTeamMember/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") } - } + mockMvc.perform(put("/events/{eventId}/addTeamMember/{accountId}", testEvent.id, 1234)) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("account not found with id 1234") + ) + .andDocumentErrorResponse(documentation, urlParameters = parameters) } } @@ -490,25 +686,38 @@ internal class EventControllerTest @Autowired constructor( repository.save(testEvent) } + private val parameters = listOf( + parameterWithName("eventId") + .description("ID of the event to remove the member from"), + parameterWithName("accountId").description("ID of the account to remove") + ) + @Test fun `should remove a team member`() { - mockMvc.put("/events/${testEvent.id}/removeTeamMember/${testAccount.id}") - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.teamMembers.length()") { value(0) } - } + mockMvc.perform(put("/events/{eventId}/removeTeamMember/{accountId}", testEvent.id, testAccount.id)) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.teamMembers.length()").value(0) + ) + .andDocument( + documentation, + "Remove a team member from event", + "This operation removes a team member of a given event.", + urlParameters = parameters + ) } @Test fun `should fail if the team member does not exist`() { - mockMvc.put("/events/${testEvent.id}/removeTeamMember/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") } - } + mockMvc.perform(put("/events/{eventId}/removeTeamMember/{accountId}", testEvent.id, 1234)) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("account not found with id 1234") + ) + .andDocumentErrorResponse(documentation, urlParameters = parameters) } } @@ -521,6 +730,8 @@ internal class EventControllerTest @Autowired constructor( repository.save(testEvent) } + val parameters = listOf(parameterWithName("id").description("ID of the event to update")) + @Test fun `should update the event`() { val newTitle = "New event title" @@ -534,35 +745,49 @@ internal class EventControllerTest @Autowired constructor( val newLocation = "FLUP" val newCategory = "Greatest Events" val newThumbnailPath = "https://thumbnails/new.png" - - mockMvc.put("/events/${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 + 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 + ) + ) ) + ) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.title").value(newTitle), + jsonPath("$.description").value(newDescription), + jsonPath("$.teamMembers.length()").value(0), + 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) + + ) + .andDocument( + documentation, + "Update events", + "Update a previously created event, using its ID.", + urlParameters = parameters, + documentRequestPayload = true ) - } - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.title") { value(newTitle) } - jsonPath("$.description") { value(newDescription) } - jsonPath("$.teamMembers.length()") { value(0) } - 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) } - } val updatedEvent = repository.findById(testEvent.id!!).get() assertEquals(newTitle, updatedEvent.title) @@ -573,27 +798,33 @@ internal class EventControllerTest @Autowired constructor( assertEquals(newLocation, updatedEvent.location) assertEquals(newCategory, updatedEvent.category) assertEquals(newThumbnailPath, updatedEvent.thumbnailPath) + assertEquals(newSlug, updatedEvent.slug) } @Test fun `should fail if the event does not exist`() { - mockMvc.put("/events/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" + 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 + ) + ) ) + ) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("event not found with id 1234") ) - } - .andExpect { - 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) } @NestedTest @@ -601,10 +832,12 @@ internal class EventControllerTest @Autowired constructor( inner class InputValidation { private val validationTester = ValidationTester( req = { params: Map -> - mockMvc.put("/events/${testEvent.id}") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(params) - } + mockMvc.perform( + put("/events/{id}", testEvent.id) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(params)) + ) + .andDocumentErrorResponse(documentation, urlParameters = parameters, hasRequestPayload = true) }, requiredFields = mapOf( "title" to testEvent.title, @@ -626,7 +859,10 @@ internal class EventControllerTest @Autowired constructor( fun `should be required`() = validationTester.isRequired() @Test - @DisplayName("size should be between ${ActivityConstants.Title.minSize} and ${ActivityConstants.Title.maxSize}()") + @DisplayName( + "size should be between ${ActivityConstants.Title.minSize}" + + " and ${ActivityConstants.Title.maxSize}()" + ) fun size() = validationTester.hasSizeBetween( ActivityConstants.Title.minSize, @@ -646,7 +882,10 @@ internal class EventControllerTest @Autowired constructor( fun `should be required`() = validationTester.isRequired() @Test - @DisplayName("size should be between ${ActivityConstants.Description.minSize} and ${ActivityConstants.Description.maxSize}()") + @DisplayName( + "size should be between ${ActivityConstants.Description.minSize} " + + "and ${ActivityConstants.Description.maxSize}()" + ) fun size() = validationTester.hasSizeBetween( ActivityConstants.Description.minSize, 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 new file mode 100644 index 00000000..ff3c2555 --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/controller/GenerationControllerTest.kt @@ -0,0 +1,1157 @@ +package pt.up.fe.ni.website.backend.controller + +import com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName +import com.epages.restdocs.apispec.SimpleType +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.transaction.Transactional +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +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.patch +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post +import org.springframework.restdocs.payload.JsonFieldType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.delete +import org.springframework.test.web.servlet.get +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.dto.entity.GenerationDto +import pt.up.fe.ni.website.backend.dto.entity.PerActivityRoleDto +import pt.up.fe.ni.website.backend.dto.entity.RoleDto +import pt.up.fe.ni.website.backend.model.Account +import pt.up.fe.ni.website.backend.model.Activity +import pt.up.fe.ni.website.backend.model.Event +import pt.up.fe.ni.website.backend.model.Generation +import pt.up.fe.ni.website.backend.model.PerActivityRole +import pt.up.fe.ni.website.backend.model.Project +import pt.up.fe.ni.website.backend.model.Role +import pt.up.fe.ni.website.backend.model.embeddable.DateInterval +import pt.up.fe.ni.website.backend.model.permissions.Permission +import pt.up.fe.ni.website.backend.model.permissions.Permissions +import pt.up.fe.ni.website.backend.repository.AccountRepository +import pt.up.fe.ni.website.backend.repository.ActivityRepository +import pt.up.fe.ni.website.backend.repository.GenerationRepository +import pt.up.fe.ni.website.backend.repository.RoleRepository +import pt.up.fe.ni.website.backend.utils.TestUtils +import pt.up.fe.ni.website.backend.utils.annotations.ControllerTest +import pt.up.fe.ni.website.backend.utils.annotations.NestedTest +import pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model.PayloadGeneration +import pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model.PayloadGenerationSections +import pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model.PayloadGenerationYears +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField +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.andDocumentCustomRequestSchema +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.PayloadSchema + +@ControllerTest +@Transactional +class GenerationControllerTest @Autowired constructor( + val mockMvc: MockMvc, + val objectMapper: ObjectMapper, + val repository: GenerationRepository, + val accountRepository: AccountRepository, + val activityRepository: ActivityRepository, + val roleRepository: RoleRepository +) { + private lateinit var testGeneration: Generation + private lateinit var testGenerations: List + + private final val documentation = PayloadGeneration() + private final val generationSectionsDocumentation = PayloadGenerationSections() + + private val updateSchoolYearSchema = PayloadSchema( + "update-generation-year", + mutableListOf( + DocumentedJSONField( + "schoolYear", + "New school year", + JsonFieldType.STRING + ) + ) + ) + + private val schoolYearParameter = listOf( + parameterWithName("schoolYear").description("School year associated with a generation").type( + SimpleType.STRING + ) + ) + + private val idParameter = listOf( + parameterWithName("id").description("Id of a generation").type( + SimpleType.STRING + ) + ) + + @NestedTest + @DisplayName("GET /generations") + inner class GetAllGenerations { + + @BeforeEach + fun addGenerations() { + initializeTestGenerations() + } + + private val allGenerationsDocumentation = PayloadGenerationYears() + + @Test + fun `should return all generation years`() { + mockMvc.perform(get("/generations")) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.length()").value(2), + jsonPath("$[0]").value("22-23"), + jsonPath("$[1]").value("21-22") + ) + .andDocument( + allGenerationsDocumentation, + "Get all the school years with recorded generations", + """This returns an array of years with recorded generations, + |allowing other operations on the generations themselves. + """.trimMargin() + ) + } + } + + @NestedTest + @DisplayName("GET /generations/year") + inner class GetGenerationByYear { + @BeforeEach + fun addGenerations() { + initializeTestGenerations() + } + + @Test + fun `should return the generation of the year`() { + mockMvc.perform(get("/generations/{schoolYear}", testGenerations[0].schoolYear)) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.length()").value(2), + jsonPath("$[0].section").value("section-role1"), + jsonPath("$[0].accounts.length()").value(1), + jsonPath("$[0].accounts[0].name").value("Test Account") + ) + .andDocument( + generationSectionsDocumentation, + "Get generation by school year", + "This operation retrieves the generation associated with a given school year.", + urlParameters = schoolYearParameter + ) + } + + @Test + fun `roles should be ordered`() { + mockMvc.get("/generations/${testGenerations[0].schoolYear}") + .andExpect { + status { isOk() } + content { contentType(MediaType.APPLICATION_JSON) } + jsonPath("$.length()") { value(2) } + jsonPath("$[0].section") { value("section-role1") } + jsonPath("$[1].section") { value("section-role2") } + jsonPath("$[0].accounts.length()") { value(1) } + jsonPath("$[0].accounts[0].roles.length()") { value(2) } + jsonPath("$[0].accounts[0].roles[0]") { value("regular-role1") } + jsonPath("$[0].accounts[0].roles[1]") { value("regular-role2") } + } + } + + @Test + fun `shouldn't return repeated accounts`() { + mockMvc.get("/generations/${testGenerations[0].schoolYear}") + .andExpect { + status { isOk() } + content { contentType(MediaType.APPLICATION_JSON) } + jsonPath("$.length()") { value(2) } + jsonPath("$[0].section") { value("section-role1") } + jsonPath("$[0].accounts.length()") { value(1) } + jsonPath("$[0].accounts[0].name") { value("Test Account") } + jsonPath("$[1].section") { value("section-role2") } + jsonPath("$[1].accounts.length()") { value(1) } + jsonPath("$[1].accounts[0].name") { value("Test Account 2") } + } + } + + @Test + fun `should return non-section roles`() { + mockMvc.get("/generations/${testGenerations[0].schoolYear}") + .andExpect { + status { isOk() } + content { contentType(MediaType.APPLICATION_JSON) } + jsonPath("$.length()") { value(2) } + jsonPath("$[0].section") { value("section-role1") } + jsonPath("$[0].accounts.length()") { value(1) } + jsonPath("$[0].accounts[0].roles.length()") { value(2) } + jsonPath("$[0].accounts[0].roles[0]") { value("regular-role1") } + jsonPath("$[0].accounts[0].roles[1]") { value("regular-role2") } + jsonPath("$[1].section") { value("section-role2") } + jsonPath("$[1].accounts.length()") { value(1) } + jsonPath("$[1].accounts[0].roles.length()") { value(1) } + jsonPath("$[1].accounts[0].roles[0]") { value("regular-role2") } + } + } + + @Test + fun `should fail if the year does not exit`() { + mockMvc.perform(get("/generations/{schoolYear}", "14-15")) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("generation not found with year 14-15") + ).andDocumentErrorResponse(generationSectionsDocumentation, urlParameters = schoolYearParameter) + } + } + + @NestedTest + @DisplayName("GET /generations/id") + inner class GetGenerationById { + @BeforeEach + fun addGenerations() { + initializeTestGenerations() + } + + @Test + fun `should return the generation of the id`() { + mockMvc.perform(get("/generations/{id}", testGenerations[0].id)) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.length()").value(2), + jsonPath("$[0].section").value("section-role1"), + jsonPath("$[0].accounts.length()").value(1), + jsonPath("$[0].accounts[0].name").value("Test Account") + ).andDocument( + generationSectionsDocumentation, + "Get a generation by id", + "This operation retrieves the generation using its id.", + urlParameters = idParameter + ) + } + + @Test + fun `roles should be ordered`() { + mockMvc.get("/generations/${testGenerations[0].id}") + .andExpect { + status { isOk() } + content { contentType(MediaType.APPLICATION_JSON) } + jsonPath("$.length()") { value(2) } + jsonPath("$[0].section") { value("section-role1") } + jsonPath("$[1].section") { value("section-role2") } + jsonPath("$[0].accounts.length()") { value(1) } + jsonPath("$[0].accounts[0].roles.length()") { value(2) } + jsonPath("$[0].accounts[0].roles[0]") { value("regular-role1") } + jsonPath("$[0].accounts[0].roles[1]") { value("regular-role2") } + } + } + + @Test + fun `shouldn't return repeated accounts`() { + mockMvc.get("/generations/${testGenerations[0].id}") + .andExpect { + status { isOk() } + content { contentType(MediaType.APPLICATION_JSON) } + jsonPath("$.length()") { value(2) } + jsonPath("$[0].section") { value("section-role1") } + jsonPath("$[0].accounts.length()") { value(1) } + jsonPath("$[0].accounts[0].name") { value("Test Account") } + jsonPath("$[1].section") { value("section-role2") } + jsonPath("$[1].accounts.length()") { value(1) } + jsonPath("$[1].accounts[0].name") { value("Test Account 2") } + } + } + + @Test + fun `should return non-section roles`() { + mockMvc.get("/generations/${testGenerations[0].id}") + .andExpect { + status { isOk() } + content { contentType(MediaType.APPLICATION_JSON) } + jsonPath("$.length()") { value(2) } + jsonPath("$[0].section") { value("section-role1") } + jsonPath("$[0].accounts.length()") { value(1) } + jsonPath("$[0].accounts[0].roles.length()") { value(2) } + jsonPath("$[0].accounts[0].roles[0]") { value("regular-role1") } + jsonPath("$[0].accounts[0].roles[1]") { value("regular-role2") } + jsonPath("$[1].section") { value("section-role2") } + jsonPath("$[1].accounts.length()") { value(1) } + jsonPath("$[1].accounts[0].roles.length()") { value(1) } + jsonPath("$[1].accounts[0].roles[0]") { value("regular-role2") } + } + } + + @Test + fun `should fail if the year does not exit`() { + mockMvc.perform(get("/generations/{id}", 123)) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("generation not found with id 123") + ) + .andDocumentErrorResponse(generationSectionsDocumentation, urlParameters = idParameter) + } + } + + @NestedTest + @DisplayName("GET /generations/latest") + inner class GetLatestGeneration { + @BeforeEach + fun addGenerations() { + initializeTestGenerations() + } + + @Test + fun `should return the latest generation`() { + mockMvc.perform(get("/generations/latest")) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.length()").value(2), + jsonPath("$[0].section").value("section-role1"), + jsonPath("$[0].accounts.length()").value(1), + jsonPath("$[0].accounts[0].name").value("Test Account") + ).andDocument( + generationSectionsDocumentation, + "Get the latest generation", + "This operation retrieves the latest generation using its id." + ) + } + + @Test + fun `roles should be ordered`() { + mockMvc.get("/generations/latest") + .andExpect { + status { isOk() } + content { contentType(MediaType.APPLICATION_JSON) } + jsonPath("$.length()") { value(2) } + jsonPath("$[0].section") { value("section-role1") } + jsonPath("$[1].section") { value("section-role2") } + jsonPath("$[0].accounts.length()") { value(1) } + jsonPath("$[0].accounts[0].roles.length()") { value(2) } + jsonPath("$[0].accounts[0].roles[0]") { value("regular-role1") } + jsonPath("$[0].accounts[0].roles[1]") { value("regular-role2") } + } + } + + @Test + fun `shouldn't return repeated accounts`() { + mockMvc.get("/generations/latest") + .andExpect { + status { isOk() } + content { contentType(MediaType.APPLICATION_JSON) } + jsonPath("$.length()") { value(2) } + jsonPath("$[0].section") { value("section-role1") } + jsonPath("$[0].accounts.length()") { value(1) } + jsonPath("$[0].accounts[0].name") { value("Test Account") } + jsonPath("$[1].section") { value("section-role2") } + jsonPath("$[1].accounts.length()") { value(1) } + jsonPath("$[1].accounts[0].name") { value("Test Account 2") } + } + } + + @Test + fun `should return non-section roles`() { + mockMvc.get("/generations/latest") + .andExpect { + status { isOk() } + content { contentType(MediaType.APPLICATION_JSON) } + jsonPath("$.length()") { value(2) } + jsonPath("$[0].section") { value("section-role1") } + jsonPath("$[0].accounts.length()") { value(1) } + jsonPath("$[0].accounts[0].roles.length()") { value(2) } + jsonPath("$[0].accounts[0].roles[0]") { value("regular-role1") } + jsonPath("$[0].accounts[0].roles[1]") { value("regular-role2") } + jsonPath("$[1].section") { value("section-role2") } + jsonPath("$[1].accounts.length()") { value(1) } + jsonPath("$[1].accounts[0].roles.length()") { value(1) } + jsonPath("$[1].accounts[0].roles[0]") { value("regular-role2") } + } + } + + @Test + fun `should fail if no generations`() { + repository.deleteAll() + mockMvc.perform(get("/generations/latest")) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("no generations created yet") + ).andDocumentErrorResponse(generationSectionsDocumentation) + } + } + + @NestedTest + @DisplayName("POST /generations/new") + inner class CreateGeneration { + @Test + fun `should create a new generation`() { + mockMvc.perform( + post("/generations/new").contentType(MediaType.APPLICATION_JSON).content( + objectMapper.writeValueAsString(GenerationDto("22-23", emptyList())) + ) + ) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.schoolYear").value("22-23") + ) + .andDocument( + documentation, + "Create new generations", + "This operation creates a new generation.", + documentRequestPayload = true + ) + } + + @Test + fun `should create a generation with roles`() { + val generationDtoWithRoles = GenerationDto( + "20-21", + listOf( + RoleDto( + "role1", + emptyList(), + true, + emptyList(), + emptyList() + ), + RoleDto( + "role2", + emptyList(), + false, + emptyList(), + emptyList() + ) + ) + ) + + mockMvc.perform( + post("/generations/new") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(generationDtoWithRoles)) + ) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.schoolYear").value("20-21"), + jsonPath("$.roles.length()").value(2), + jsonPath("$.roles[0].name").value("role1"), + jsonPath("$.roles[0].isSection").value(true), + jsonPath("$.roles[0].permissions.length()").value(0), + jsonPath("$.roles[0].associatedActivities.length()").value(0), + jsonPath("$.roles[1].name").value("role2"), + jsonPath("$.roles[1].isSection").value(false), + jsonPath("$.roles[1].permissions.length()").value(0), + jsonPath("$.roles[1].associatedActivities.length()").value(0) + ).andDocument( + documentation, + documentRequestPayload = true + ) + + val roles = roleRepository.findAll().toList() + assertEquals(2, roles.size) + val generation = repository.findBySchoolYear("20-21") + assertNotNull(generation) + assert(generation!!.roles.containsAll(roles)) + assert(roles.all { it.generation == generation }) + } + + @Test + fun `should create a generation with roles and permissions`() { + val generationDtoWithRoles = GenerationDto( + "20-21", + listOf( + RoleDto( + "role1", + listOf(0, 1), + true, + emptyList(), + emptyList() + ) + ) + ) + + mockMvc.perform( + post("/generations/new") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(generationDtoWithRoles)) + ) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.schoolYear").value("20-21"), + jsonPath("$.roles.length()").value(1), + jsonPath("$.roles[0].name").value("role1"), + jsonPath("$.roles[0].permissions.length()").value(2), + jsonPath("$.roles[0].permissions[0]").value(Permission.values()[0].name), + jsonPath("$.roles[0].permissions[1]").value(Permission.values()[1].name) + ).andDocument( + documentation, + documentRequestPayload = true + ) + + val roles = roleRepository.findAll().toList() + assertEquals(1, roles.size) + assert(roles[0].permissions.contains(Permission.values()[0])) + assert(roles[0].permissions.contains(Permission.values()[1])) + } + + @Test + fun `should fail if year is not specified and there are no generations`() { + mockMvc.perform( + post("/generations/new") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(GenerationDto(null, emptyList()))) + ) + .andExpectAll( + status().isUnprocessableEntity, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("no generations created yet, please specify school year") + ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) + } + + @NestedTest + @DisplayName("with existing generations") + inner class WithExistingGenerations { + @BeforeEach + fun addGenerations() { + initializeTestGenerations() + } + + @Test + fun `should infer the year if not specified and create generation`() { + mockMvc.perform( + post("/generations/new") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(GenerationDto(null, emptyList()))) + ) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.schoolYear").value("23-24") + ) + .andDocument(documentation, documentRequestPayload = true) + } + + @Test + fun `should create a generation with role and associated accounts`() { + val generationDtoWithAccounts = GenerationDto( + "20-21", + listOf( + RoleDto( + "role1", + emptyList(), + true, + listOf(1, 2), + emptyList() + ) + ) + ) + + mockMvc.perform( + post("/generations/new") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(generationDtoWithAccounts)) + ) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.schoolYear").value("20-21"), + jsonPath("$.roles.length()").value(1), + jsonPath("$.roles[0].name").value("role1") + ).andDocument(documentation, documentRequestPayload = true) + + val role = roleRepository.findAll().toList() + .filter { it.generation.schoolYear == "20-21" } + .find { it.name == "role1" } + assert(role != null) + + val account1 = accountRepository.findById(1).get() + val account2 = accountRepository.findById(2).get() + assert(account1.roles.contains(role)) + assert(account2.roles.contains(role)) + + assert(role!!.accounts.contains(account1)) + assert(role.accounts.contains(account2)) + } + + @Test + fun `should create a generation with role and associated activities`() { + val generationDtoWithActivities = GenerationDto( + "20-21", + listOf( + RoleDto( + "role1", + emptyList(), + true, + emptyList(), + listOf( + PerActivityRoleDto( + 1, + listOf(0, 1) + ), + PerActivityRoleDto( + 2, + listOf(2) + ) + ) + ) + ) + ) + + mockMvc.perform( + post("/generations/new") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(generationDtoWithActivities)) + ) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.schoolYear").value("20-21"), + jsonPath("$.roles.length()").value(1), + jsonPath("$.roles[0].associatedActivities.length()").value(2), + jsonPath("$.roles[0].associatedActivities[0].permissions.length()").value(2), + jsonPath("$.roles[0].associatedActivities[0].permissions[0]") + .value( + Permission.values()[0].name + ), + jsonPath("$.roles[0].associatedActivities[0].permissions[1]") + .value( + Permission.values()[1].name + ), + + jsonPath("$.roles[0].associatedActivities[1].permissions.length()").value(1), + jsonPath("$.roles[0].associatedActivities[1].permissions[0]") + .value( + Permission.values()[2].name + ) + ).andDocument(documentation, documentRequestPayload = true) + + val role = roleRepository.findAll().toList() + .filter { it.generation.schoolYear == "20-21" } + .find { it.name == "role1" } + assert(role != null) + + assert(role!!.associatedActivities.all { it.role == role }) + + val activity1 = activityRepository.findById(1).get() + val activity2 = activityRepository.findById(2).get() + assert( + activity1.associatedRoles.any { + it.role == role && it.activity == activity1 && + it.permissions.contains(Permission.values()[0]) && + it.permissions.contains(Permission.values()[1]) + } + ) + assert( + activity2.associatedRoles.any { + it.role == role && it.activity == activity2 && + it.permissions.contains(Permission.values()[2]) + } + ) + } + + @Test + fun `should fail if the year already exists`() { + mockMvc.perform( + post("/generations/new") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString(GenerationDto("22-23", emptyList())) + ) + ) + .andExpectAll( + status().isUnprocessableEntity, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("generation already exists") + ).andDocumentErrorResponse(documentation, hasRequestPayload = true) + } + } + } + + @NestedTest + @DisplayName("PATCH /generations/year") + inner class UpdateGenerationByYear { + @BeforeEach + fun addGenerations() { + initializeTestGenerations() + } + + @Test + fun `should update the generation year`() { + mockMvc.perform( + patch("/generations/{schoolYear}", testGenerations[0].schoolYear) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("schoolYear" to "19-20"))) + ) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.schoolYear").value("19-20") + ) + .andDocumentCustomRequestSchema( + documentation, + updateSchoolYearSchema, + "Update a generation school year by its school year", + "Update a generation school year, using its school year as a parameter", + urlParameters = schoolYearParameter, + documentRequestPayload = true + ) + } + + @Test + fun `should fail if the year does not exist`() { + mockMvc.perform( + patch("/generations/{schoolYear}", "17-18") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf("schoolYear" to "19-20") + ) + ) + ) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("generation not found with year 17-18") + ).andDocumentCustomRequestSchemaErrorResponse( + documentation, + updateSchoolYearSchema, + urlParameters = schoolYearParameter, + hasRequestPayload = true + ) + } + + @Test + fun `should fail if the new year is already taken`() { + mockMvc.perform( + patch("/generations/{schoolYear}", testGenerations[0].schoolYear) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf("schoolYear" to "21-22") + ) + ) + ) + .andExpectAll( + status().isUnprocessableEntity, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("generation already exists") + ) + .andDocumentCustomRequestSchemaErrorResponse( + documentation, + updateSchoolYearSchema, + urlParameters = schoolYearParameter, + hasRequestPayload = true + ) + } + + @Test + fun `should fail if the new year is not valid`() { + mockMvc.perform( + patch("/generations/{schoolYear}", testGenerations[0].schoolYear) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf("schoolYear" to "123") + ) + ) + ) + .andExpectAll( + status().isBadRequest, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("must be formatted as where yy=xx+1") + ) + .andDocumentCustomRequestSchemaErrorResponse( + documentation, + updateSchoolYearSchema, + urlParameters = schoolYearParameter, + hasRequestPayload = true + ) + } + } + + @NestedTest + @DisplayName("PATCH /generations/id") + inner class UpdateGenerationById { + @BeforeEach + fun addGenerations() { + initializeTestGenerations() + } + + @Test + fun `should update the generation year`() { + mockMvc.perform( + patch("/generations/{id}", testGenerations[0].id) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf("schoolYear" to "19-20") + ) + ) + ) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.schoolYear").value("19-20") + ) + .andDocumentCustomRequestSchema( + documentation, + updateSchoolYearSchema, + "Update a generation school year by its Id", + "Update a generation school year, using its Id as a parameter", + urlParameters = idParameter, + documentRequestPayload = true + ) + } + + @Test + fun `should fail if the generation does not exist`() { + mockMvc.perform( + patch("/generations/{id}", 123) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf("schoolYear" to "19-20") + ) + ) + ) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("generation not found with id 123") + ) + .andDocumentCustomRequestSchemaErrorResponse( + documentation, + updateSchoolYearSchema, + urlParameters = idParameter, + hasRequestPayload = true + ) + } + + @Test + fun `should fail if the new year is already taken`() { + mockMvc.perform( + patch("/generations/{id}", testGenerations[0].id) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf("schoolYear" to "21-22") + ) + ) + ) + .andExpectAll( + status().isUnprocessableEntity, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("generation already exists") + ) + .andDocumentCustomRequestSchemaErrorResponse( + documentation, + updateSchoolYearSchema, + urlParameters = idParameter, + hasRequestPayload = true + ) + } + + @Test + fun `should fail if the new year is not valid`() { + mockMvc.perform( + patch("/generations/{id}", 1) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf("schoolYear" to "123") + ) + ) + ) + .andExpectAll( + status().isBadRequest(), + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("must be formatted as where yy=xx+1") + ) + .andDocumentCustomRequestSchemaErrorResponse( + documentation, + updateSchoolYearSchema, + urlParameters = idParameter, + hasRequestPayload = true + ) + } + } + + @NestedTest + @DisplayName("DELETE /generations/year") + inner class DeleteGenerationByYear { + @BeforeEach + fun addGenerations() { + initializeTestGenerations() + } + + @Test + fun `should delete the generation`() { + mockMvc.perform(delete("/generations/{schoolYear}", testGenerations[0].schoolYear)) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$").isEmpty + ).andDocumentEmptyObjectResponse( + documentation, + "Delete a generation by its school year", + "Delete a generation by its school year, using its school year as a parameter", + urlParameters = schoolYearParameter + ) + + assert(repository.findById(testGenerations[0].id!!).isEmpty) + } + + @Test + fun `should fail if the generation does not exist`() { + mockMvc.perform(delete("/generations/{schoolYear}", "17-18")) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("generation not found with year 17-18") + ) + .andDocumentErrorResponse( + documentation, + urlParameters = schoolYearParameter + ) + } + + @Test + fun `should cascade delete the generation roles`() { + mockMvc.delete("/generations/${testGenerations[0].schoolYear}") + .andExpect { + status { isOk() } + content { contentType(MediaType.APPLICATION_JSON) } + jsonPath("$") { isEmpty() } + } + + val roles = roleRepository.findAll().filter { it.generation.id == testGenerations[0].id } + assert(roles.isEmpty()) + } + + @Test + fun `should not cascade delete the role accounts`() { + val accountNumber = accountRepository.count() + + mockMvc.delete("/generations/${testGenerations[0].schoolYear}") + .andExpect { + status { isOk() } + content { contentType(MediaType.APPLICATION_JSON) } + jsonPath("$") { isEmpty() } + } + + assertEquals(accountNumber, accountRepository.count()) + } + + @Test + fun `should not cascade delete the role associated activities`() { + val activityNumber = activityRepository.count() + + mockMvc.delete("/generations/${testGenerations[0].schoolYear}") + .andExpect { + status { isOk() } + content { contentType(MediaType.APPLICATION_JSON) } + jsonPath("$") { isEmpty() } + } + + assertEquals(activityNumber, activityRepository.count()) + } + } + + @NestedTest + @DisplayName("DELETE /generations/id") + inner class DeleteGenerationById { + @BeforeEach + fun addGenerations() { + initializeTestGenerations() + } + + @Test + fun `should delete the generation`() { + mockMvc.perform(delete("/generations/{id}", testGenerations[0].id)) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$").isEmpty + ).andDocumentEmptyObjectResponse( + documentation, + "Delete a generation by its id", + "Delete a generation by its id, using its id as a parameter", + urlParameters = idParameter + ) + + assert(repository.findById(testGenerations[0].id!!).isEmpty) + } + + @Test + fun `should fail if the generation does not exist`() { + mockMvc.perform(delete("/generations/{id}", 123)) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("generation not found with id 123") + ).andDocumentErrorResponse( + documentation, + urlParameters = idParameter + ) + } + } + + private fun initializeTestGenerations() { + val testAccount = Account( + "Test Account", + "test-account@gmail.com", + "12345678", + null, + null, + null, + null, + null, + emptyList() + ) + + val testAccount2 = Account( + "Test Account 2", + "test-account2@gmail.com", + "12345678", + null, + null, + null, + null, + null, + emptyList() + ) + + testGeneration = buildTestGeneration( + "22-23", + listOf( + buildTestRole( + "section-role1", + true, + listOf(testAccount), + listOf( + buildTestPerActivityRole( + Project("NIJobs", "cool project") + ) + ) + ), + buildTestRole( + "section-role2", + true, + listOf(testAccount, testAccount2), + emptyList() + ), + buildTestRole( + "regular-role1", + false, + listOf(testAccount), + listOf( + buildTestPerActivityRole( + Event( + title = "SINF", + description = "cool event", + dateInterval = DateInterval(TestUtils.createDate(2023, 9, 10)), + location = null, + category = null, + thumbnailPath = "https://www.google.com" + ) + ) + ) + ), + buildTestRole( + "regular-role2", + false, + listOf(testAccount, testAccount2), + emptyList() + ) + ) + ) + + testGenerations = listOf( + testGeneration, + buildTestGeneration( + "21-22", + listOf( + buildTestRole( + "section-role1", + true, + listOf(testAccount), + emptyList() + ), + buildTestRole( + "regular-role1", + false, + listOf(testAccount), + emptyList() + ) + ) + ) + ) + + testGenerations.forEach(::saveGeneration) + } + + private fun saveGeneration(generation: Generation) { + generation.roles.forEach { role -> + accountRepository.saveAll(role.accounts) + activityRepository.saveAll(role.associatedActivities.map { it.activity }) + } + repository.save(generation) + + // Add inverse relationships + generation.roles.forEach { role -> + role.accounts.forEach { it.roles.add(role); accountRepository.save(it) } + role.associatedActivities.forEach { it.role = role } + } + repository.save(generation) + } + + private fun buildTestGeneration(schoolYear: String, roles: List = emptyList()): Generation { + val generation = Generation(schoolYear) + generation.roles.addAll(roles) + roles.forEach { it.generation = generation } + return generation + } + + private fun buildTestRole( + name: String, + isSection: Boolean, + accounts: List = emptyList(), + associatedActivities: List = emptyList() + ): Role { + val role = Role(name, Permissions(emptySet()), isSection) + role.accounts.addAll(accounts) + role.associatedActivities.addAll(associatedActivities) + return role + } + + private fun buildTestPerActivityRole(activity: Activity): PerActivityRole { + val perActivityRole = PerActivityRole(Permissions(emptySet())) + perActivityRole.activity = activity + return perActivityRole + } +} 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 1f312c2d..ee616532 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 @@ -1,6 +1,9 @@ package pt.up.fe.ni.website.backend.controller +import com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName import com.fasterxml.jackson.databind.ObjectMapper +import java.text.SimpleDateFormat +import java.util.Date import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.BeforeAll @@ -9,19 +12,26 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test 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.delete -import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.post -import org.springframework.test.web.servlet.put +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.model.Post +import pt.up.fe.ni.website.backend.model.constants.PostConstants as Constants import pt.up.fe.ni.website.backend.repository.PostRepository import pt.up.fe.ni.website.backend.utils.ValidationTester import pt.up.fe.ni.website.backend.utils.annotations.ControllerTest import pt.up.fe.ni.website.backend.utils.annotations.NestedTest -import java.text.SimpleDateFormat -import java.util.Date -import pt.up.fe.ni.website.backend.model.constants.PostConstants as Constants +import pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model.PayloadPost +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.documentation.utils.ModelDocumentation @ControllerTest internal class PostControllerTest @Autowired constructor( @@ -29,6 +39,8 @@ internal class PostControllerTest @Autowired constructor( val objectMapper: ObjectMapper, val repository: PostRepository ) { + val documentation: ModelDocumentation = PayloadPost() + val testPost = Post( "New test released", "this is a test post", @@ -55,11 +67,19 @@ internal class PostControllerTest @Autowired constructor( @Test fun `should return all posts`() { - mockMvc.get("/posts").andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - content { json(objectMapper.writeValueAsString(testPosts)) } - } + mockMvc.perform(get("/posts")) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + content().json( + objectMapper.writeValueAsString(testPosts) + ) + ) + .andDocument( + documentation.getModelDocumentationArray(), + "Get all the posts", + "The operation returns an array of posts, allowing to easily retrieve all the created posts." + ) } } @@ -71,28 +91,46 @@ internal class PostControllerTest @Autowired constructor( repository.save(testPost) } + private val parameters = listOf( + parameterWithName("id").description( + "ID of the post to retrieve" + ) + ) + @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(true)) } - } + mockMvc.perform(get("/posts/{id}", testPost.id)) + .andExpectAll( + 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)) + ) + .andDocument( + documentation, + "Get posts by ID", + "This endpoint allows the retrieval of a single post using its ID. " + + "It might be used to generate the specific post page.", + urlParameters = parameters + ) } @Test fun `should fail if the post does not exist`() { - mockMvc.get("/posts/1234").andExpect { - status { isNotFound() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.errors.length()") { value(1) } - jsonPath("$.errors[0].message") { value("post not found with id 1234") } - } + mockMvc.perform(get("/posts/{id}", 1234)) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("post not found with id 1234") + ) + .andDocumentErrorResponse( + documentation, + urlParameters = parameters + ) } } @@ -104,29 +142,47 @@ internal class PostControllerTest @Autowired constructor( repository.save(testPost) } + private val parameters = listOf( + parameterWithName("slug").description( + "Short and friendly textual post identifier" + ) + ) + @Test fun `should return the post`() { - mockMvc.get("/posts/${testPost.slug}") - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.id") { value(testPost.id) } - 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)) } - } + mockMvc.perform(get("/posts/{slug}", testPost.slug)) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.id").value(testPost.id), + 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)) + ) + .andDocument( + documentation, + "Get posts by slug", + "This endpoint allows the retrieval of a single post using its slug. " + + "It might be used to generate the specific post page.", + urlParameters = parameters + ) } @Test fun `should fail if the post does not exist`() { - mockMvc.get("/posts/fail-slug").andExpect { - status { isNotFound() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.errors.length()") { value(1) } - jsonPath("$.errors[0].message") { value("post not found with slug fail-slug") } - } + mockMvc.perform(get("/posts/{slug}", "fail-slug")) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("post not found with slug fail-slug") + ) + .andDocumentErrorResponse( + documentation, + urlParameters = parameters + ) } } @@ -140,20 +196,27 @@ internal class PostControllerTest @Autowired constructor( @Test fun `should create a new post`() { - mockMvc.post("/posts/new") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(testPost) - } - .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") { exists() } - jsonPath("$.lastUpdatedAt") { exists() } - jsonPath("$.slug") { value(testPost.slug) } - } + mockMvc.perform( + post("/posts/new") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(testPost)) + ) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.title").value(testPost.title), + jsonPath("$.body").value(testPost.body), + jsonPath("$.thumbnailPath").value(testPost.thumbnailPath), + jsonPath("$.publishDate").exists(), + jsonPath("$.lastUpdatedAt").exists(), + jsonPath("$.slug").value(testPost.slug) + ) + .andDocument( + documentation, + "Create new posts", + "This endpoint operation creates a new post.", + documentRequestPayload = true + ) } @Test @@ -163,15 +226,18 @@ internal class PostControllerTest @Autowired constructor( content = objectMapper.writeValueAsString(testPost) }.andExpect { status { isOk() } } - mockMvc.post("/posts/new") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(testPost) - }.andExpect { - status { isUnprocessableEntity() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.errors.length()") { value(1) } - jsonPath("$.errors[0].message") { value("slug already exists") } - } + mockMvc.perform( + post("/posts/new") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(testPost)) + ) + .andExpectAll( + status().isUnprocessableEntity, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("slug already exists") + ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) } @NestedTest @@ -179,10 +245,12 @@ internal class PostControllerTest @Autowired constructor( inner class InputValidation { private val validationTester = ValidationTester( req = { params: Map -> - mockMvc.post("/posts/new") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(params) - } + mockMvc.perform( + post("/posts/new") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(params)) + ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) }, requiredFields = mapOf( "title" to testPost.title, @@ -261,25 +329,43 @@ internal class PostControllerTest @Autowired constructor( repository.save(testPost) } + private val parameters = listOf( + parameterWithName("id").description( + "ID of the post to delete" + ) + ) + @Test fun `should delete the post`() { - mockMvc.delete("/posts/${testPost.id}").andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$") { isEmpty() } - } + mockMvc.perform(delete("/posts/{id}", testPost.id)) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$").isEmpty + ) + .andDocumentEmptyObjectResponse( + documentation, + "Delete posts", + "This operation deletes an event using its ID.", + urlParameters = parameters + ) assert(repository.findById(testPost.id!!).isEmpty) } @Test fun `should fail if the post does not exist`() { - mockMvc.delete("/posts/1234").andExpect { - status { isNotFound() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.errors.length()") { value(1) } - jsonPath("$.errors[0].message") { value("post not found with id 1234") } - } + mockMvc.perform(delete("/posts/{id}", 1234)) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("post not found with id 1234") + ) + .andDocumentErrorResponse( + documentation, + urlParameters = parameters + ) } } @@ -299,32 +385,48 @@ internal class PostControllerTest @Autowired constructor( ) } + val parameters = listOf( + parameterWithName("id").description( + "ID of the post to update" + ) + ) + @Test fun `should update the post without the slug`() { val newTitle = "New Title" val newBody = "New Body of the post" val newThumbnailPath = "https://thumbnails/new.png" - mockMvc.put("/posts/${testPost.id}") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString( - mapOf( - "title" to newTitle, - "body" to newBody, - "thumbnailPath" to newThumbnailPath + mockMvc.perform( + put("/posts/{id}", testPost.id) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "title" to newTitle, + "body" to newBody, + "thumbnailPath" to newThumbnailPath + ) + ) ) + ) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.title").value(newTitle), + jsonPath("$.body").value(newBody), + jsonPath("$.thumbnailPath").value(newThumbnailPath), + jsonPath("$.publishDate").value(testPost.publishDate.toJson()), + jsonPath("$.lastUpdatedAt").exists(), + jsonPath("$.slug").value(testPost.slug) + ) + .andDocument( + documentation, + "Update posts", + "Update a previously created post, using its ID.", + urlParameters = parameters, + documentRequestPayload = true ) - } - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.title") { value(newTitle) } - jsonPath("$.body") { value(newBody) } - jsonPath("$.thumbnailPath") { value(newThumbnailPath) } - jsonPath("$.publishDate") { value(testPost.publishDate.toJson()) } - jsonPath("$.lastUpdatedAt") { exists() } - jsonPath("$.slug") { value(testPost.slug) } - } val updatedPost = repository.findById(testPost.id!!).get() assertEquals(newTitle, updatedPost.title) @@ -340,27 +442,35 @@ internal class PostControllerTest @Autowired constructor( val newThumbnailPath = "https://thumbnails/new.png" val newSlug = "new-slug" - mockMvc.put("/posts/${testPost.id}") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString( - mapOf( - "title" to newTitle, - "body" to newBody, - "thumbnailPath" to newThumbnailPath, - "slug" to newSlug + mockMvc.perform( + put("/posts/{id}", testPost.id) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "title" to newTitle, + "body" to newBody, + "thumbnailPath" to newThumbnailPath, + "slug" to newSlug + ) + ) ) + ) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.title").value(newTitle), + jsonPath("$.body").value(newBody), + jsonPath("$.thumbnailPath").value(newThumbnailPath), + jsonPath("$.publishDate").value(testPost.publishDate.toJson()), + jsonPath("$.lastUpdatedAt").exists(), + jsonPath("$.slug").value(newSlug) + ) + .andDocument( + documentation, + urlParameters = parameters, + documentRequestPayload = true ) - } - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.title") { value(newTitle) } - jsonPath("$.body") { value(newBody) } - jsonPath("$.thumbnailPath") { value(newThumbnailPath) } - jsonPath("$.publishDate") { value(testPost.publishDate.toJson()) } - jsonPath("$.lastUpdatedAt") { exists() } - jsonPath("$.slug") { value(newSlug) } - } val updatedPost = repository.findById(testPost.id!!).get() assertEquals(newTitle, updatedPost.title) @@ -373,22 +483,30 @@ internal class PostControllerTest @Autowired constructor( @Test fun `should fail if the post does not exist`() { - mockMvc.put("/posts/1234") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString( - mapOf( - "title" to "New Title", - "body" to "New Body of the post", - "thumbnailPath" to "thumbnails/new.png" + mockMvc.perform( + put("/posts/{id}", 1234) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "title" to "New Title", + "body" to "New Body of the post", + "thumbnailPath" to "thumbnails/new.png" + ) + ) ) + ) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("post not found with id 1234") + ) + .andDocumentErrorResponse( + documentation, + urlParameters = parameters, + hasRequestPayload = true ) - } - .andExpect { - status { isNotFound() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.errors.length()") { value(1) } - jsonPath("$.errors[0].message") { value("post not found with id 1234") } - } } @Test @@ -398,23 +516,31 @@ internal class PostControllerTest @Autowired constructor( val newThumbnailPath = "https://thumbnails/new.png" val newSlug = "duplicated-slug" - mockMvc.put("/posts/${testPost.id}") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString( - mapOf( - "title" to newTitle, - "body" to newBody, - "thumbnailPath" to newThumbnailPath, - "slug" to newSlug + mockMvc.perform( + put("/posts/{id}", testPost.id) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "title" to newTitle, + "body" to newBody, + "thumbnailPath" to newThumbnailPath, + "slug" to newSlug + ) + ) ) + ) + .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 ) - } - .andExpect { - status { isUnprocessableEntity() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.errors.length()") { value(1) } - jsonPath("$.errors[0].message") { value("slug already exists") } - } } @NestedTest @@ -422,10 +548,16 @@ internal class PostControllerTest @Autowired constructor( inner class InputValidation { private val validationTester = ValidationTester( req = { params: Map -> - mockMvc.put("/posts/${testPost.id}") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(params) - } + mockMvc.perform( + put("/posts/{id}", testPost.id) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(params)) + ) + .andDocumentErrorResponse( + documentation, + urlParameters = parameters, + hasRequestPayload = true + ) }, requiredFields = mapOf( "title" to testPost.title, 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 d59bbafa..b4798a95 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 @@ -1,6 +1,9 @@ package pt.up.fe.ni.website.backend.controller +import com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName import com.fasterxml.jackson.databind.ObjectMapper +import java.util.Calendar +import java.util.Date import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeEach @@ -8,23 +11,30 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test 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.delete -import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.post -import org.springframework.test.web.servlet.put +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.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.repository.AccountRepository import pt.up.fe.ni.website.backend.repository.ProjectRepository import pt.up.fe.ni.website.backend.utils.TestUtils import pt.up.fe.ni.website.backend.utils.ValidationTester import pt.up.fe.ni.website.backend.utils.annotations.ControllerTest import pt.up.fe.ni.website.backend.utils.annotations.NestedTest -import java.util.Calendar -import java.util.Date -import pt.up.fe.ni.website.backend.model.constants.ActivityConstants as Constants +import pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model.PayloadProject +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.documentation.utils.ModelDocumentation @ControllerTest internal class ProjectControllerTest @Autowired constructor( @@ -52,10 +62,14 @@ internal class ProjectControllerTest @Autowired constructor( "Awesome project", "this is a test project", mutableListOf(testAccount), + mutableListOf(), + "awesome-project", false, listOf("Java", "Kotlin", "Spring") ) + val documentation: ModelDocumentation = PayloadProject() + @NestedTest @DisplayName("GET /projects") inner class GetAllProjects { @@ -65,6 +79,8 @@ internal class ProjectControllerTest @Autowired constructor( "NIJobs", "Job platform for students", mutableListOf(), + mutableListOf(), + null, false, listOf("ExpressJS", "React") ) @@ -78,43 +94,119 @@ internal class ProjectControllerTest @Autowired constructor( @Test fun `should return all projects`() { - mockMvc.get("/projects").andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - content { json(objectMapper.writeValueAsString(testProjects)) } - } + mockMvc.perform(get("/projects")) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + content().json(objectMapper.writeValueAsString(testProjects)) + ) + .andDocument( + documentation.getModelDocumentationArray(), + "Get all the projects", + "The operation returns an array of projects, allowing to easily retrieve all the created " + + "projects. This is useful for example in the frontend project page, " + + "where projects are displayed." + ) } } @NestedTest @DisplayName("GET /projects/{projectId}") - inner class GetProject { + inner class GetProjectById { @BeforeEach fun addToRepositories() { accountRepository.save(testAccount) repository.save(testProject) } + private val parameters = listOf(parameterWithName("id").description("ID of the project to retrieve")) + @Test fun `should return the project`() { - mockMvc.get("/projects/${testProject.id}").andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.title") { value(testProject.title) } - jsonPath("$.description") { value(testProject.description) } - jsonPath("$.technologies.length()") { value(testProject.technologies.size) } - jsonPath("$.technologies[0]") { value(testProject.technologies[0]) } - } + mockMvc.perform(get("/projects/{id}", testProject.id)) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.title").value(testProject.title), + jsonPath("$.description").value(testProject.description), + jsonPath("$.technologies.length()").value(testProject.technologies.size), + jsonPath("$.technologies[0]").value(testProject.technologies[0]), + jsonPath("$.slug").value(testProject.slug) + ) + .andDocument( + documentation, + "Get projects by ID", + "This endpoint allows the retrieval of a single project using its ID. " + + "It might be used to generate the specific project page.", + urlParameters = parameters + ) } @Test fun `should fail if the project does not exist`() { - mockMvc.get("/projects/1234").andExpect { - status { isNotFound() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.errors.length()") { value(1) } - jsonPath("$.errors[0].message") { value("project not found with id 1234") } - } + mockMvc.perform(get("/projects/{id}", 1234)) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("project not found with id 1234") + ) + .andDocumentErrorResponse( + documentation, + urlParameters = parameters + ) + } + } + + @NestedTest + @DisplayName("GET /projects/{projectSlug}") + inner class GetProjectBySlug { + @BeforeEach + fun addToRepositories() { + accountRepository.save(testAccount) + repository.save(testProject) + } + + private val parameters = listOf( + parameterWithName("slug").description( + "Short and friendly textual project identifier" + ) + ) + + @Test + fun `should return the project`() { + mockMvc.perform(get("/projects/{slug}", testProject.slug)) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.title").value(testProject.title), + jsonPath("$.description").value(testProject.description), + jsonPath("$.technologies.length()").value(testProject.technologies.size), + jsonPath("$.technologies[0]").value(testProject.technologies[0]), + jsonPath("$.slug").value(testProject.slug) + ) + .andDocument( + documentation, + "Get projects by slug", + "This endpoint allows the retrieval of a single project using its slug.", + urlParameters = parameters + ) + } + + @Test + fun `should fail if the project does not exist`() { + mockMvc.perform(get("/projects/{slug}", "does-not-exist")) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message") + .value("project not found with slug does-not-exist") + ) + .andDocumentErrorResponse( + documentation, + urlParameters = parameters + ) } } @@ -124,34 +216,75 @@ internal class ProjectControllerTest @Autowired constructor( @BeforeEach fun addToRepositories() { accountRepository.save(testAccount) - repository.save(testProject) } @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, + "teamMembersIds" to mutableListOf(testAccount.id!!), + "isArchived" to testProject.isArchived, + "technologies" to testProject.technologies, + "slug" to testProject.slug + ) + ) + ) + ) + .andExpectAll( + status().isOk, + 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) + ) + .andDocument( + documentation, + "Create new projects", + "This endpoint operation creates a new project.", + documentRequestPayload = true + ) + } + + @Test + fun `should fail if slug already exists`() { + val duplicatedSlugProject = Project( + "Duplicated Slug", + "this is a test project with a duplicated slug", + mutableListOf(testAccount), + mutableListOf(), + testProject.slug, + false, + listOf("Java", "Kotlin", "Spring") + ) + mockMvc.post("/projects/new") { contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString( - mapOf( - "title" to testProject.title, - "description" to testProject.description, - "teamMembersIds" to mutableListOf(testAccount.id!!), - "isArchived" to testProject.isArchived, - "technologies" to testProject.technologies - ) + content = objectMapper.writeValueAsString(testProject) + }.andExpect { status { isOk() } } + + mockMvc.perform( + post("/projects/new") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(duplicatedSlugProject)) + ) + .andExpectAll( + status().isUnprocessableEntity, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("slug already exists") ) - } - .andExpect { - status { isOk() } - 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]) } - } + .andDocumentErrorResponse(documentation, hasRequestPayload = true) } @NestedTest @@ -159,10 +292,12 @@ internal class ProjectControllerTest @Autowired constructor( inner class InputValidation { private val validationTester = ValidationTester( req = { params: Map -> - mockMvc.post("/projects/new") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(params) - } + mockMvc.perform( + post("/projects/new") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(params)) + ) + .andDocumentErrorResponse(documentation, hasRequestPayload = true) }, requiredFields = mapOf( "title" to testProject.title, @@ -198,10 +333,26 @@ internal class ProjectControllerTest @Autowired constructor( fun `should be required`() = validationTester.isRequired() @Test - @DisplayName("size should be between ${Constants.Description.minSize} and ${Constants.Description.maxSize}()") + @DisplayName( + "size should be between ${Constants.Description.minSize} " + + "and ${Constants.Description.maxSize}()" + ) fun size() = validationTester.hasSizeBetween(Constants.Description.minSize, Constants.Description.maxSize) } + + @NestedTest + @DisplayName("slug") + inner class SlugValidation { + @BeforeAll + fun setParam() { + validationTester.param = "slug" + } + + @Test + @DisplayName("size should be between ${Constants.Slug.minSize} and ${Constants.Slug.maxSize}()") + fun size() = validationTester.hasSizeBetween(Constants.Slug.minSize, Constants.Slug.maxSize) + } } } @@ -214,25 +365,39 @@ internal class ProjectControllerTest @Autowired constructor( repository.save(testProject) } + private val parameters = listOf(parameterWithName("id").description("ID of the project to delete")) + @Test fun `should delete the project`() { - mockMvc.delete("/projects/${testProject.id}").andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$") { isEmpty() } - } + mockMvc.perform(delete("/projects/{id}", testProject.id)) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$").isEmpty + ) + .andDocumentEmptyObjectResponse( + documentation, + "Delete projects", + "This operation deletes an projects using its ID.", + urlParameters = parameters + ) assert(repository.findById(testProject.id!!).isEmpty) } @Test fun `should fail if the project does not exist`() { - mockMvc.delete("/projects/1234").andExpect { - status { isNotFound() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.errors.length()") { value(1) } - jsonPath("$.errors[0].message") { value("project not found with id 1234") } - } + mockMvc.perform(delete("/projects/{id}", 1234)) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("project not found with id 1234") + ) + .andDocumentErrorResponse( + documentation, + urlParameters = parameters + ) } } @@ -246,56 +411,167 @@ internal class ProjectControllerTest @Autowired constructor( repository.save(testProject) } + val parameters = listOf(parameterWithName("id").description("ID of the project to update")) + @Test - fun `should update the project`() { + 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 - mockMvc.put("/projects/${testProject.id}") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString( - mapOf( - "title" to newTitle, - "description" to newDescription, - "teamMembersIds" to newTeamMembers, - "isArchived" to newIsArchived + 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 + ) + ) ) + ) + .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 + ) + + val updatedProject = repository.findById(testProject.id!!).get() + assertEquals(newTitle, updatedProject.title) + assertEquals(newDescription, updatedProject.description) + assertEquals(newIsArchived, updatedProject.isArchived) + } + + @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" + + 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 + ) + ) + ) + ) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.title").value(newTitle), + jsonPath("$.description").value(newDescription), + jsonPath("$.isArchived").value(newIsArchived), + jsonPath("$.slug").value(newSlug) + ) + .andDocument( + documentation, + urlParameters = parameters, + documentRequestPayload = true ) - } - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.title") { value(newTitle) } - jsonPath("$.description") { value(newDescription) } - jsonPath("$.teamMembers.length()") { value(0) } - jsonPath("$.isArchived") { value(newIsArchived) } - } val updatedProject = repository.findById(testProject.id!!).get() assertEquals(newTitle, updatedProject.title) assertEquals(newDescription, updatedProject.description) assertEquals(newIsArchived, updatedProject.isArchived) + assertEquals(newSlug, updatedProject.slug) } @Test fun `should fail if the project does not exist`() { - mockMvc.put("/projects/1234") { + 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" + ) + ) + ) + ) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("project not found with id 1234") + ) + .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( - mapOf( - "title" to "New Title", - "description" to "New description of the project" + Project( + "Duplicated Slug", + "this is a test project with a duplicated slug", + mutableListOf(testAccount), + mutableListOf(), + newSlug, + false, + listOf("Java", "Kotlin", "Spring") ) ) - } - .andExpect { - status { isNotFound() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.errors.length()") { value(1) } - jsonPath("$.errors[0].message") { value("project not found with id 1234") } - } + }.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 + ) + ) + ) + ) + .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 + ) } @NestedTest @@ -303,10 +579,16 @@ internal class ProjectControllerTest @Autowired constructor( inner class InputValidation { private val validationTester = ValidationTester( req = { params: Map -> - mockMvc.put("/projects/${testProject.id}") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(params) - } + mockMvc.perform( + put("/projects/{id}", testProject.id) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(params)) + ) + .andDocumentErrorResponse( + documentation, + urlParameters = parameters, + hasRequestPayload = true + ) }, requiredFields = mapOf( "title" to testProject.title, @@ -342,10 +624,26 @@ internal class ProjectControllerTest @Autowired constructor( fun `should be required`() = validationTester.isRequired() @Test - @DisplayName("size should be between ${Constants.Description.minSize} and ${Constants.Description.maxSize}()") + @DisplayName( + "size should be between ${Constants.Description.minSize}" + + " and ${Constants.Description.maxSize}()" + ) fun size() = validationTester.hasSizeBetween(Constants.Description.minSize, Constants.Description.maxSize) } + + @NestedTest + @DisplayName("slug") + inner class SlugValidation { + @BeforeAll + fun setParam() { + validationTester.param = "slug" + } + + @Test + @DisplayName("size should be between ${Constants.Slug.minSize} and ${Constants.Slug.maxSize}()") + fun size() = validationTester.hasSizeBetween(Constants.Slug.minSize, Constants.Slug.maxSize) + } } } @@ -358,19 +656,32 @@ internal class ProjectControllerTest @Autowired constructor( repository.save(testProject) } + private val parameters = listOf( + parameterWithName("id") + .description("ID of the project to archive") + ) + @Test fun `should archive the project`() { val newIsArchived = true - mockMvc.put("/projects/${testProject.id}/archive") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString("isArchived" to newIsArchived) - } - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.isArchived") { value(newIsArchived) } - } + mockMvc.perform( + put("/projects/{id}/archive", testProject.id) + ) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.isArchived").value(newIsArchived) + ) + .andDocument( + documentation, + "Archive projects", + """ + |This endpoint archives a project. + |This is useful to mark no longer maintained or complete projects. + """.trimMargin(), + urlParameters = parameters + ) val archivedProject = repository.findById(testProject.id!!).get() assertEquals(newIsArchived, archivedProject.isArchived) @@ -384,6 +695,8 @@ internal class ProjectControllerTest @Autowired constructor( "proj1", "very cool project", mutableListOf(), + mutableListOf(), + null, true, listOf("React", "TailwindCSS") ) @@ -394,19 +707,29 @@ internal class ProjectControllerTest @Autowired constructor( repository.save(project) } + private val parameters = listOf(parameterWithName("id").description("ID of the project to unarchive")) + @Test fun `should unarchive the project`() { val newIsArchived = false - mockMvc.put("/projects/${project.id}/unarchive") { - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString("isArchived" to newIsArchived) - } - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.isArchived") { value(newIsArchived) } - } + mockMvc.perform( + put("/projects/{id}/unarchive", project.id) + ) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.isArchived").value(newIsArchived) + ) + .andDocument( + documentation, + "Unarchive projects", + """ + |This endpoint unarchives a project. + |This is useful to mark previously archived projects as active. + """.trimMargin(), + urlParameters = parameters + ) val unarchivedProject = repository.findById(project.id!!).get() assertEquals(newIsArchived, unarchivedProject.isArchived) @@ -438,44 +761,61 @@ internal class ProjectControllerTest @Autowired constructor( repository.save(testProject) } + private val parameters = listOf( + parameterWithName("projectId").description( + "ID of the project to add the member to" + ), + parameterWithName("accountId").description("ID of the account to add") + ) + @Test fun `should add a team member`() { - mockMvc.put("/projects/${testProject.id}/addTeamMember/${newAccount.id}") - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.teamMembers.length()") { value(2) } - jsonPath("$.teamMembers[0].name") { value(testAccount.name) } - jsonPath("$.teamMembers[0].email") { value(testAccount.email) } - jsonPath("$.teamMembers[0].bio") { value(testAccount.bio) } - jsonPath("$.teamMembers[0].birthDate") { value(testAccount.birthDate.toJson()) } - jsonPath("$.teamMembers[0].photoPath") { value(testAccount.photoPath) } - jsonPath("$.teamMembers[0].linkedin") { value(testAccount.linkedin) } - jsonPath("$.teamMembers[0].github") { value(testAccount.github) } - jsonPath("$.teamMembers[0].websites.length()") { value(1) } - jsonPath("$.teamMembers[0].websites[0].url") { value(testAccount.websites[0].url) } - jsonPath("$.teamMembers[0].websites[0].iconPath") { value(testAccount.websites[0].iconPath) } - jsonPath("$.teamMembers[1].name") { value(newAccount.name) } - jsonPath("$.teamMembers[1].email") { value(newAccount.email) } - jsonPath("$.teamMembers[1].bio") { value(newAccount.bio) } - jsonPath("$.teamMembers[1].birthDate") { value(newAccount.birthDate.toJson()) } - jsonPath("$.teamMembers[1].photoPath") { value(newAccount.photoPath) } - jsonPath("$.teamMembers[1].linkedin") { value(newAccount.linkedin) } - jsonPath("$.teamMembers[1].github") { value(newAccount.github) } - jsonPath("$.teamMembers[1].websites.length()") { value(1) } - jsonPath("$.teamMembers[1].websites[0].url") { value(newAccount.websites[0].url) } - jsonPath("$.teamMembers[1].websites[0].iconPath") { value(newAccount.websites[0].iconPath) } - } + mockMvc.perform( + put("/projects/{projectId}/addTeamMember/{accountId}", testProject.id, newAccount.id) + ) + .andExpectAll( + status().isOk, content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.teamMembers.length()").value(2), + jsonPath("$.teamMembers[0].name").value(testAccount.name), + jsonPath("$.teamMembers[0].email").value(testAccount.email), + jsonPath("$.teamMembers[0].bio").value(testAccount.bio), + jsonPath("$.teamMembers[0].birthDate").value(testAccount.birthDate.toJson()), + jsonPath("$.teamMembers[0].linkedin").value(testAccount.linkedin), + jsonPath("$.teamMembers[0].github").value(testAccount.github), + jsonPath("$.teamMembers[0].websites.length()").value(1), + jsonPath("$.teamMembers[0].websites[0].url").value(testAccount.websites[0].url), + jsonPath("$.teamMembers[0].websites[0].iconPath").value(testAccount.websites[0].iconPath), + jsonPath("$.teamMembers[1].name").value(newAccount.name), + jsonPath("$.teamMembers[1].email").value(newAccount.email), + jsonPath("$.teamMembers[1].bio").value(newAccount.bio), + jsonPath("$.teamMembers[1].birthDate").value(newAccount.birthDate.toJson()), + jsonPath("$.teamMembers[1].linkedin").value(newAccount.linkedin), + jsonPath("$.teamMembers[1].github").value(newAccount.github), + jsonPath("$.teamMembers[1].websites.length()").value(1), + jsonPath("$.teamMembers[1].websites[0].url").value(newAccount.websites[0].url), + jsonPath("$.teamMembers[1].websites[0].iconPath").value(newAccount.websites[0].iconPath) + ) + .andDocument( + documentation, + "Add team member to Project", + "This operation adds a team member to a given project.", + urlParameters = parameters + ) } @Test fun `should fail if the team member does not exist`() { - mockMvc.put("/projects/${testProject.id}/addTeamMember/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") } - } + mockMvc.perform(put("/projects/{projectId}/addTeamMember/{accountId}", testProject.id, 1234)) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("account not found with id 1234") + ) + .andDocumentErrorResponse( + documentation, + urlParameters = parameters + ) } } @@ -489,24 +829,42 @@ internal class ProjectControllerTest @Autowired constructor( repository.save(testProject) } + private val parameters = listOf( + parameterWithName("projectId").description( + "ID of the project to remove the member from" + ), + parameterWithName("accountId").description("ID of the account to remove") + ) + @Test fun `should remove a team member`() { - mockMvc.put("/projects/${testProject.id}/removeTeamMember/${testAccount.id}") - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.teamMembers.length()") { value(0) } - } + mockMvc.perform(put("/projects/{projectId}/removeTeamMember/{accountId}", testProject.id, testAccount.id)) + .andExpectAll( + status().isOk, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.teamMembers.length()").value(0) + ) + .andDocument( + documentation, + "Remove team member from Project", + "This operation removes a team member of a given project.", + urlParameters = parameters + ) } @Test fun `should fail if the team member does not exist`() { - mockMvc.put("/projects/${testProject.id}/removeTeamMember/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") } - } + mockMvc.perform(put("/projects/{projectId}/removeTeamMember/{accountId}", testProject.id, 1234)) + .andExpectAll( + status().isNotFound, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1), + jsonPath("$.errors[0].message").value("account not found with id 1234") + ) + .andDocumentErrorResponse( + documentation, + urlParameters = parameters + ) } } diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/dto/generations/GetGenerationDtoTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/dto/generations/GetGenerationDtoTest.kt new file mode 100644 index 00000000..9db30128 --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/dto/generations/GetGenerationDtoTest.kt @@ -0,0 +1,278 @@ +package pt.up.fe.ni.website.backend.dto.generations + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import pt.up.fe.ni.website.backend.model.Account +import pt.up.fe.ni.website.backend.model.Generation +import pt.up.fe.ni.website.backend.model.Role +import pt.up.fe.ni.website.backend.model.permissions.Permissions + +class GetGenerationDtoTest { + private lateinit var testGeneration: Generation + + @BeforeEach + fun setup() { + testGeneration = Generation("22/23") + } + + @Test + fun `generation with no roles`() = testBuildGetGenerationDto(mutableListOf(), emptyList()) + + @Test + fun `generation with only non-section roles`() = testBuildGetGenerationDto( + mutableListOf( + buildTestRole("role", false), + buildTestRole( + "role2", + false, + mutableListOf(buildTestAccount("account", mutableListOf())) + ) + ), + emptyList() + ) + + @Test + fun `generation with a section role and no accounts`() = testBuildGetGenerationDto( + mutableListOf(buildTestRole("role", true, mutableListOf())), + emptyList() + ) + + @Test + fun `generation with a section role and a user with no roles`() = testBuildGetGenerationDto( + mutableListOf( + buildTestRole("role", true, mutableListOf(buildTestAccount("account", mutableListOf()))) + ), + listOf( + GenerationSectionDto( + "role", + listOf(buildTestGenerationUserDto("account", emptyList())) + ) + ) + ) + + @Test + fun `generation with a section role and a user with a role`() = testBuildGetGenerationDto( + mutableListOf( + buildTestRole( + "section-role", + true, + mutableListOf( + buildTestAccount( + "account", + mutableListOf(buildTestRole("user-role", false)) + ) + ) + ) + ), + listOf( + GenerationSectionDto( + "section-role", + listOf(buildTestGenerationUserDto("account", listOf("user-role"))) + ) + ) + ) + + @Test + fun `generation with a section role and a user with multiple roles`() = testBuildGetGenerationDto( + mutableListOf( + buildTestRole( + "section-role", + true, + mutableListOf( + buildTestAccount( + "account", + mutableListOf( + buildTestRole("user-role", false), + buildTestRole("user-role2", false) + ) + ) + ) + ) + ), + listOf( + GenerationSectionDto( + "section-role", + listOf(buildTestGenerationUserDto("account", listOf("user-role", "user-role2"))) + ) + ) + ) + + @Test + fun `generation with a section role and multiple accounts`() = testBuildGetGenerationDto( + mutableListOf( + buildTestRole( + "section-role", + true, + mutableListOf( + buildTestAccount( + "account", + mutableListOf(buildTestRole("user-role", false)) + ), + buildTestAccount( + "account2", + mutableListOf(buildTestRole("user-role2", false)) + ) + ) + ) + ), + listOf( + GenerationSectionDto( + "section-role", + listOf( + buildTestGenerationUserDto("account", listOf("user-role")), + buildTestGenerationUserDto("account2", listOf("user-role2")) + ) + ) + ) + ) + + @Test + fun `generation with multiple section roles and multiple accounts`() = testBuildGetGenerationDto( + mutableListOf( + buildTestRole( + "section-role", + true, + mutableListOf( + buildTestAccount( + "account", + mutableListOf(buildTestRole("user-role", false)) + ) + ) + ), + buildTestRole( + "section-role2", + true, + mutableListOf( + buildTestAccount( + "account2", + mutableListOf(buildTestRole("user-role2", false)) + ) + ) + ) + ), + listOf( + GenerationSectionDto( + "section-role", + listOf(buildTestGenerationUserDto("account", listOf("user-role"))) + ), + GenerationSectionDto( + "section-role2", + listOf(buildTestGenerationUserDto("account2", listOf("user-role2"))) + ) + ) + ) + + @Test + fun `generation with multiple section roles, accounts and respective roles`() = testBuildGetGenerationDto( + mutableListOf( + buildTestRole( + "section-role", + true, + mutableListOf( + buildTestAccount( + "account", + mutableListOf( + buildTestRole("user-role", false), + buildTestRole("user-role2", false) + ) + ) + ) + ), + buildTestRole( + "section-role2", + true, + mutableListOf( + buildTestAccount( + "account2", + mutableListOf( + buildTestRole("user-role3", false), + buildTestRole("user-role4", false) + ) + ) + ) + ) + ), + listOf( + GenerationSectionDto( + "section-role", + listOf(buildTestGenerationUserDto("account", listOf("user-role", "user-role2"))) + ), + GenerationSectionDto( + "section-role2", + listOf(buildTestGenerationUserDto("account2", listOf("user-role3", "user-role4"))) + ) + ) + ) + + @Test + fun `generation with multiple section roles and repeated accounts`() { + val account = buildTestAccount( + "account", + mutableListOf(buildTestRole("user-role", false)) + ) + + testBuildGetGenerationDto( + mutableListOf( + buildTestRole( + "section-role", + true, + mutableListOf(account) + ), + buildTestRole( + "section-role2", + true, + mutableListOf(account) + ) + ), + listOf( + GenerationSectionDto( + "section-role", + listOf(buildTestGenerationUserDto("account", listOf("user-role"))) + ), + GenerationSectionDto( + "section-role2", + emptyList() + ) + ) + ) + } + + private fun testBuildGetGenerationDto(generationRoles: MutableList, expected: GetGenerationDto) { + testGeneration.roles.apply { clear(); addAll(generationRoles) } + val actual = buildGetGenerationDto(testGeneration) + assertEquals(expected.size, actual.size) + + actual.forEachIndexed { sectionIdx, actualSection -> + val expectedSection = expected[sectionIdx] + assertEquals(expectedSection.section, actualSection.section) + assertEquals(expectedSection.accounts.size, actualSection.accounts.size) + + actualSection.accounts.forEachIndexed { userIdx, actualUser -> + val expectedUser = expectedSection.accounts[userIdx] + assertEquals(expectedUser.account.name, actualUser.account.name) + assertEquals(expectedUser.roles, actualUser.roles) + } + } + } + + private fun buildTestRole( + name: String, + isSection: Boolean, + accounts: MutableList = mutableListOf() + ): Role { + val role = Role(name, Permissions(emptySet()), isSection, accounts) + role.generation = testGeneration + return role + } + + private fun buildTestAccount(name: String, roles: MutableList) = Account( + name, "email", "password", null, null, + null, null, null, emptyList(), roles + ) + + private fun buildTestGenerationUserDto(name: String, roles: List) = GenerationUserDto( + buildTestAccount(name, mutableListOf()), + roles + ) +} 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 c9c16261..4a91859a 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 @@ -44,7 +44,7 @@ class AccountTest { null, null, emptyList(), - listOf( + mutableListOf( memberRole, websiteManagerRole, websiteDeveloperRole @@ -61,7 +61,7 @@ class AccountTest { null, null, emptyList(), - listOf( + mutableListOf( memberRole, websiteDeveloperRole ) @@ -77,53 +77,64 @@ class AccountTest { null, null, emptyList(), - listOf( + mutableListOf( memberRole ) ) - memberRole.accounts = listOf( - managerAccount, - developerAccount, - memberAccount + memberRole.accounts.addAll( + listOf( + managerAccount, + developerAccount, + memberAccount + ) ) - websiteManagerRole.accounts = listOf( - managerAccount + websiteManagerRole.accounts.addAll( + listOf( + managerAccount + ) ) - - websiteDeveloperRole.accounts = listOf( - managerAccount, - developerAccount + websiteDeveloperRole.accounts.addAll( + listOf( + managerAccount, + developerAccount + ) ) val websiteManagerToProject = PerActivityRole( - websiteManagerRole, - websiteProject, Permissions( listOf(Permission.EDIT_SETTINGS, Permission.DELETE_ACTIVITY) ) ) + websiteManagerToProject.role = websiteManagerRole + websiteManagerToProject.activity = websiteProject val websiteDeveloperToProject = PerActivityRole( - websiteDeveloperRole, - websiteProject, Permissions( listOf(Permission.VIEW_ACTIVITY, Permission.EDIT_ACTIVITY) ) ) + websiteDeveloperToProject.role = websiteDeveloperRole + websiteDeveloperToProject.activity = websiteProject - websiteProject.associatedRoles = listOf( - websiteManagerToProject, - websiteDeveloperToProject + websiteProject.associatedRoles.addAll( + listOf( + websiteManagerToProject, + websiteDeveloperToProject + ) ) - websiteManagerRole.associatedActivities = listOf( - websiteManagerToProject + websiteManagerRole.associatedActivities.addAll( + listOf( + websiteManagerToProject + ) ) - websiteDeveloperRole.associatedActivities = listOf( - websiteDeveloperToProject + websiteDeveloperRole.associatedActivities.addAll( + listOf( + websiteDeveloperToProject + ) ) Assertions.assertEquals( diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/ValidationTester.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/ValidationTester.kt index 3a92c1dc..55c850f4 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/ValidationTester.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/ValidationTester.kt @@ -1,10 +1,13 @@ package pt.up.fe.ni.website.backend.utils import org.springframework.http.MediaType -import org.springframework.test.web.servlet.ResultActionsDsl +import org.springframework.test.web.servlet.ResultActions +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 class ValidationTester( - private val req: (Map) -> ResultActionsDsl, + private val req: (Map) -> ResultActions, private val requiredFields: Map = mapOf() ) { lateinit var param: String @@ -18,12 +21,11 @@ class ValidationTester( val params = requiredFields.toMutableMap() params.remove(param) req(params) - .andDo { print() } .expectValidationError() - .andExpect { - jsonPath("$.errors[0].message") { value("required") } - jsonPath("$.errors[0].param") { value(getParamName()) } - } + .andExpectAll( + jsonPath("$.errors[0].message").value("required"), + jsonPath("$.errors[0].param").value(getParamName()) + ) } fun isNotEmpty() { @@ -31,11 +33,11 @@ class ValidationTester( params[param] = "" req(params) .expectValidationError() - .andExpect { - jsonPath("$.errors[0].message") { value("must not be empty") } - jsonPath("$.errors[0].param") { value(getParamName()) } - jsonPath("$.errors[0].value") { value("") } - } + .andExpectAll( + jsonPath("$.errors[0].message").value("must not be empty"), + jsonPath("$.errors[0].param").value(getParamName()), + jsonPath("$.errors[0].value").value("") + ) } fun isNullOrNotBlank() { @@ -43,11 +45,11 @@ class ValidationTester( params[param] = "" req(params) .expectValidationError() - .andExpect { - jsonPath("$.errors[0].message") { value("must be null or not blank") } - jsonPath("$.errors[0].param") { value(getParamName()) } - jsonPath("$.errors[0].value") { value("") } - } + .andExpectAll( + jsonPath("$.errors[0].message").value("must be null or not blank"), + jsonPath("$.errors[0].param").value(getParamName()), + jsonPath("$.errors[0].value").value("") + ) } fun isUrl() { @@ -55,11 +57,11 @@ class ValidationTester( params[param] = "invalid.com" req(params) .expectValidationError() - .andExpect { - jsonPath("$.errors[0].message") { value("must be a valid URL") } - jsonPath("$.errors[0].param") { value(getParamName()) } - jsonPath("$.errors[0].value") { value("invalid.com") } - } + .andExpectAll( + jsonPath("$.errors[0].message").value("must be a valid URL"), + jsonPath("$.errors[0].param").value(getParamName()), + jsonPath("$.errors[0].value").value("invalid.com") + ) } fun hasSizeBetween(min: Int, max: Int) { @@ -68,25 +70,21 @@ class ValidationTester( params[param] = smallValue req(params) .expectValidationError() - .andExpect { - jsonPath("$.errors[0].message") { - value("size must be between $min and $max") - } - jsonPath("$.errors[0].param") { value(getParamName()) } - jsonPath("$.errors[0].value") { value(smallValue) } - } + .andExpectAll( + jsonPath("$.errors[0].message").value("size must be between $min and $max"), + jsonPath("$.errors[0].param").value(getParamName()), + jsonPath("$.errors[0].value").value(smallValue) + ) val bigValue = "a".repeat(max + 1) params[param] = bigValue req(params) .expectValidationError() - .andExpect { - jsonPath("$.errors[0].message") { - value("size must be between $min and $max") - } - jsonPath("$.errors[0].param") { value(getParamName()) } - jsonPath("$.errors[0].value") { value(bigValue) } - } + .andExpectAll( + jsonPath("$.errors[0].message").value("size must be between $min and $max"), + jsonPath("$.errors[0].param").value(getParamName()), + jsonPath("$.errors[0].value").value(bigValue) + ) } fun hasMinSize(min: Int) { @@ -95,13 +93,11 @@ class ValidationTester( params[param] = smallValue req(params) .expectValidationError() - .andExpect { - jsonPath("$.errors[0].message") { - value("size must be greater or equal to $min") - } - jsonPath("$.errors[0].param") { value(getParamName()) } - jsonPath("$.errors[0].value") { value(smallValue) } - } + .andExpectAll( + jsonPath("$.errors[0].message").value("size must be greater or equal to $min"), + jsonPath("$.errors[0].param").value(getParamName()), + jsonPath("$.errors[0].value").value(smallValue) + ) } fun isDate() { @@ -109,10 +105,10 @@ class ValidationTester( params[param] = "invalid" req(params) .expectValidationError() - .andExpect { - jsonPath("$.errors[0].message") { value("must be date") } - jsonPath("$.errors[0].value") { value("invalid") } - } + .andExpectAll( + jsonPath("$.errors[0].message").value("must be date"), + jsonPath("$.errors[0].value").value("invalid") + ) } fun isPastDate() { @@ -120,10 +116,10 @@ class ValidationTester( 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") } - } + .andExpectAll( + jsonPath("$.errors[0].message").value("must be a past date"), + jsonPath("$.errors[0].value").value("01-01-3000") + ) } fun isValidDateInterval() { @@ -131,9 +127,9 @@ class ValidationTester( params[param] = "invalid" req(params) .expectValidationError() - .andExpect { - jsonPath("$.errors[0].message") { value("must be dateinterval") } - } + .andExpectAll( + jsonPath("$.errors[0].message").value("must be dateinterval") + ) params[param] = mapOf( "startDate" to "09-01-2023", @@ -141,10 +137,10 @@ class ValidationTester( ) req(params) .expectValidationError() - .andExpect { - jsonPath("$.errors[0].message") { value("endDate must be after startDate") } - jsonPath("$.errors[0].value") { value(params[param]) } - } + .andExpectAll( + jsonPath("$.errors[0].message").value("endDate must be after startDate"), + jsonPath("$.errors[0].value").value(params[param]) + ) } fun isEmail() { @@ -152,19 +148,19 @@ class ValidationTester( 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-an-email") } - jsonPath("$.errors[0].param") { value(getParamName()) } - } + .andExpectAll( + jsonPath("$.errors[0].message").value("must be a well-formed email address"), + jsonPath("$.errors[0].value").value("not-an-email"), + jsonPath("$.errors[0].param").value(getParamName()) + ) } - private fun ResultActionsDsl.expectValidationError(): ResultActionsDsl { - andExpect { - status { isBadRequest() } - content { contentType(MediaType.APPLICATION_JSON) } - jsonPath("$.errors.length()") { value(1) } - } + private fun ResultActions.expectValidationError(): ResultActions { + andExpectAll( + status().isBadRequest, + content().contentType(MediaType.APPLICATION_JSON), + jsonPath("$.errors.length()").value(1) + ) return this } } diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/annotations/ControllerTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/annotations/ControllerTest.kt index e81252f0..df9e151f 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/annotations/ControllerTest.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/annotations/ControllerTest.kt @@ -1,6 +1,7 @@ package pt.up.fe.ni.website.backend.utils.annotations import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.TestExecutionListeners @@ -10,6 +11,7 @@ import pt.up.fe.ni.website.backend.utils.listeners.DbCleanupListener @Retention(AnnotationRetention.RUNTIME) @SpringBootTest @AutoConfigureMockMvc +@AutoConfigureRestDocs @AutoConfigureTestDatabase @TestExecutionListeners( listeners = [DbCleanupListener::class], diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/Tag.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/Tag.kt new file mode 100644 index 00000000..3354c7f7 --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/Tag.kt @@ -0,0 +1,12 @@ +package pt.up.fe.ni.website.backend.utils.documentation + +import pt.up.fe.ni.website.backend.utils.documentation.utils.ITag + +enum class Tag(override val fullName: String) : ITag { + AUTH("Authentication"), + ACCOUNT("Accounts"), + EVENT("Events"), + GENERATION("Generations"), + POST("Posts"), + PROJECT("Projects") +} diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/EmptyObjectSchema.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/EmptyObjectSchema.kt new file mode 100644 index 00000000..5f1cf41b --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/EmptyObjectSchema.kt @@ -0,0 +1,8 @@ +package pt.up.fe.ni.website.backend.utils.documentation.payloadschemas + +import pt.up.fe.ni.website.backend.utils.documentation.utils.PayloadSchema + +class EmptyObjectSchema : PayloadSchema( + "empty", + mutableListOf() +) diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/ErrorSchema.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/ErrorSchema.kt new file mode 100644 index 00000000..16d8c297 --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/ErrorSchema.kt @@ -0,0 +1,21 @@ +package pt.up.fe.ni.website.backend.utils.documentation.payloadschemas + +import org.springframework.restdocs.payload.JsonFieldType +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField +import pt.up.fe.ni.website.backend.utils.documentation.utils.PayloadSchema + +class ErrorSchema : PayloadSchema( + "error", + mutableListOf( + DocumentedJSONField("errors[]", "Array of detected errors", JsonFieldType.ARRAY), + DocumentedJSONField("errors[].message", "Error message of a given error", JsonFieldType.STRING), + DocumentedJSONField( + "errors[].param", + "Parameter associated with the error", + JsonFieldType.STRING, + optional = true + ), + DocumentedJSONField("errors[].value", "Value that caused the error", JsonFieldType.VARIES, optional = true), + DocumentedJSONField("errors[].value.*", optional = true, ignored = true) + ) +) 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 new file mode 100644 index 00000000..a1925c8e --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadAccount.kt @@ -0,0 +1,72 @@ +package pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model + +import org.springframework.restdocs.payload.JsonFieldType +import pt.up.fe.ni.website.backend.utils.documentation.Tag +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField +import pt.up.fe.ni.website.backend.utils.documentation.utils.ModelDocumentation + +class PayloadAccount(includePassword: Boolean = true) : ModelDocumentation( + Tag.ACCOUNT.name.lowercase(), + Tag.ACCOUNT, + mutableListOf( + DocumentedJSONField("name", "Name of the account owner", JsonFieldType.STRING), + DocumentedJSONField("email", "Email associated to the account", JsonFieldType.STRING), + DocumentedJSONField("bio", "Short profile description", JsonFieldType.STRING, optional = true), + DocumentedJSONField("birthDate", "Birth date of the owner", JsonFieldType.STRING, optional = true), + DocumentedJSONField("photo", "Path to the photo resource", JsonFieldType.STRING, optional = true), + DocumentedJSONField( + "linkedin", + "Handle/link to the owner's LinkedIn profile", + JsonFieldType.STRING, + optional = true + ), + DocumentedJSONField( + "github", + "Handle/link to the owner's GitHub profile", + JsonFieldType.STRING, + optional = true + ), + DocumentedJSONField( + "websites[]", + "Array with relevant websites about the owner", + JsonFieldType.ARRAY, + optional = true + ), + DocumentedJSONField("websites[].url", "URL to the website", JsonFieldType.STRING, optional = true), + DocumentedJSONField( + "websites[].iconPath", + "URL to the website's icon", + JsonFieldType.STRING, + optional = true + ), + DocumentedJSONField( + "roles[]", + "Array with the roles of the account", + JsonFieldType.ARRAY, + optional = true, + isInRequest = false + ), + + DocumentedJSONField("id", "Account ID", JsonFieldType.NUMBER, isInRequest = false), + DocumentedJSONField( + "websites[].id", + "Related website ID", + JsonFieldType.NUMBER, + optional = true, + isInRequest = false + ) + ) +) { + init { + if (includePassword) { + payload.documentedJSONFields.add( + DocumentedJSONField( + "password", + "Account password", + JsonFieldType.STRING, + isInResponse = false + ) + ) + } + } +} 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 new file mode 100644 index 00000000..f6fd7e1d --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadActivity.kt @@ -0,0 +1,51 @@ +package pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model + +import org.springframework.restdocs.payload.JsonFieldType +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField.Companion.addFieldsBeneathPath +import pt.up.fe.ni.website.backend.utils.documentation.utils.PayloadSchema + +class PayloadActivity { + companion object { + val payload = PayloadSchema( + "activity", + mutableListOf( + DocumentedJSONField("id", "Id of the activity", JsonFieldType.NUMBER), + DocumentedJSONField("title", "Title of the activity", JsonFieldType.STRING), + DocumentedJSONField("description", "Description of the activity", JsonFieldType.STRING), + DocumentedJSONField("teamMembers", "Array of team members", JsonFieldType.ARRAY), + DocumentedJSONField("isArchived", "If the activity is archived", JsonFieldType.BOOLEAN), + DocumentedJSONField( + "technologies", + "Array of technologies", + JsonFieldType.ARRAY, + optional = true + ), + DocumentedJSONField("technologies[].*", "Technology", JsonFieldType.STRING, optional = true), + DocumentedJSONField( + "dateInterval", + "Date interval of the activity", + JsonFieldType.OBJECT, + optional = true + ), + DocumentedJSONField( + "thumbnailPath", + "Path to the thumbnail", + JsonFieldType.STRING, + optional = true + ) + ).addFieldsBeneathPath( + "dateInterval", + PayloadDateInterval.payload.documentedJSONFields, + addRequest = true, + addResponse = true + ).addFieldsBeneathPath( + "teamMembers[]", + PayloadAccount().payload.documentedJSONFields, + addRequest = true, + addResponse = true, + optional = true + ) + ) + } +} diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadAssociatedRoles.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadAssociatedRoles.kt new file mode 100644 index 00000000..28f7be8f --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadAssociatedRoles.kt @@ -0,0 +1,99 @@ +package pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model + +import org.springframework.restdocs.payload.JsonFieldType +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField.Companion.addFieldsBeneathPath +import pt.up.fe.ni.website.backend.utils.documentation.utils.PayloadSchema + +class PayloadAssociatedRoles { + companion object { + val payload = PayloadSchema( + "associated-role", + mutableListOf( + DocumentedJSONField("name", "Name of the role", JsonFieldType.STRING), + DocumentedJSONField("permissions", "Array of permissions", JsonFieldType.ARRAY), + DocumentedJSONField("permissions[].*", "Permission", JsonFieldType.NUMBER, isInResponse = false), + DocumentedJSONField("permissions[].*", "Permission", JsonFieldType.STRING, isInRequest = false), + DocumentedJSONField( + "id", + "Id of the role/activity association", + JsonFieldType.NUMBER, + isInRequest = false + ), + + DocumentedJSONField( + "isSection", + "If the role represents a generation section", + JsonFieldType.BOOLEAN + ), + DocumentedJSONField( + "accountIds", + "Array of account ids associated with this role", + JsonFieldType.ARRAY, + isInResponse = false + ), + DocumentedJSONField( + "accountIds[].*", + "Account id", + JsonFieldType.NUMBER, + isInResponse = false + ), + DocumentedJSONField( + "associatedActivities[]", + "Array of activities associated with this role", + JsonFieldType.ARRAY + ), + DocumentedJSONField( + "associatedActivities[].activityId", + "Id of the activity", + JsonFieldType.NUMBER, + optional = true, + isInResponse = false + ), + DocumentedJSONField( + "associatedActivities[].permissions", + "Permissions of the role in the activity", + JsonFieldType.ARRAY + ), + DocumentedJSONField( + "associatedActivities[].permissions[].*", + "Permissions", + JsonFieldType.STRING, + isInRequest = false + ), + DocumentedJSONField( + "associatedActivities[].permissions[].*", + "Permissions", + JsonFieldType.NUMBER, + isInResponse = false + ) + ).addFieldsBeneathPath( + "associatedActivities[]", + PayloadRole.payload.documentedJSONFields, + addResponse = true, + optional = true + ) + ) + } + + private class PayloadRole { + companion object { + val payload = PayloadSchema( + "role", + mutableListOf( + DocumentedJSONField("id", "Id of the role", JsonFieldType.NUMBER, isInRequest = false), + DocumentedJSONField( + "activity", + "Activity of the association", + JsonFieldType.OBJECT, + isInRequest = false + ) + ).addFieldsBeneathPath( + "activity", + PayloadActivity.payload.documentedJSONFields, + addResponse = true + ) + ) + } + } +} diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadAuth.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadAuth.kt new file mode 100644 index 00000000..4911f1e8 --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadAuth.kt @@ -0,0 +1,83 @@ +package pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model + +import org.springframework.restdocs.payload.JsonFieldType +import pt.up.fe.ni.website.backend.utils.documentation.Tag +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField.Companion.addFieldsBeneathPath +import pt.up.fe.ni.website.backend.utils.documentation.utils.ModelDocumentation + +class PayloadAuthNew : ModelDocumentation( + Tag.AUTH.name.lowercase() + "-new", + Tag.AUTH, + mutableListOf( + DocumentedJSONField( + "email", + "Email of the account", + JsonFieldType.STRING, + isInResponse = false + ), + DocumentedJSONField( + "password", + "Password of the account", + JsonFieldType.STRING, + isInResponse = false + ), + DocumentedJSONField( + "access_token", + "Access token, used to identify the user", + JsonFieldType.STRING, + isInRequest = false + ), + DocumentedJSONField( + "refresh_token", + "Refresh token, used to refresh the access token", + JsonFieldType.STRING, + isInRequest = false + ) + ) +) + +class PayloadAuthRefresh : ModelDocumentation( + Tag.AUTH.name.lowercase() + "-refresh", + Tag.AUTH, + mutableListOf( + DocumentedJSONField( + "token", + "Refresh token, used to refresh the access token", + JsonFieldType.STRING, + isInResponse = false + ), + DocumentedJSONField( + "access_token", + "Access token, used to identify the user", + JsonFieldType.STRING, + isInRequest = false + ) + ) +) + +class PayloadRecoverPassword : ModelDocumentation( + Tag.AUTH.name.lowercase() + "-recover", + Tag.AUTH, + mutableListOf( + DocumentedJSONField( + "id", + "Id of the account", + JsonFieldType.NUMBER, + isInResponse = false + ) + ) +) + +class PayloadAuthCheck : ModelDocumentation( + Tag.AUTH.name.lowercase() + "-check", + Tag.AUTH, + mutableListOf( + DocumentedJSONField( + "authenticated_user", + "Authenticated account's information.", + JsonFieldType.OBJECT, + isInRequest = false + ) + ).addFieldsBeneathPath("authenticated_user", PayloadAccount().payload.documentedJSONFields, addResponse = true) +) diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadDateInterval.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadDateInterval.kt new file mode 100644 index 00000000..23a0886c --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadDateInterval.kt @@ -0,0 +1,26 @@ +package pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model + +import org.springframework.restdocs.payload.JsonFieldType +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField +import pt.up.fe.ni.website.backend.utils.documentation.utils.PayloadSchema + +class PayloadDateInterval { + companion object { + val payload = PayloadSchema( + "date-interval", + mutableListOf( + DocumentedJSONField( + "startDate", + "Event beginning date", + JsonFieldType.STRING + ), + DocumentedJSONField( + "endDate", + "Event finishing date", + 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 new file mode 100644 index 00000000..95dbf13f --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadEvent.kt @@ -0,0 +1,57 @@ +package pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model + +import org.springframework.restdocs.payload.JsonFieldType +import pt.up.fe.ni.website.backend.utils.documentation.Tag +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField.Companion.addFieldsBeneathPath +import pt.up.fe.ni.website.backend.utils.documentation.utils.ModelDocumentation + +class PayloadEvent : ModelDocumentation( + Tag.EVENT.name.lowercase(), + Tag.EVENT, + mutableListOf( + DocumentedJSONField("title", "Event title", JsonFieldType.STRING), + DocumentedJSONField("description", "Event description", JsonFieldType.STRING), + DocumentedJSONField("thumbnailPath", "Thumbnail 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), + DocumentedJSONField("category", "Event category", JsonFieldType.STRING, optional = true), + DocumentedJSONField( + "slug", + "Short and friendly textual event identifier", + JsonFieldType.STRING, + optional = true + ), + DocumentedJSONField("id", "Event ID", JsonFieldType.NUMBER, isInRequest = false), + DocumentedJSONField( + "teamMembers", + "Array of members associated with the event", + JsonFieldType.ARRAY, + isInRequest = false + ), + DocumentedJSONField( + "teamMembersIds[]", + "Team member IDs", + JsonFieldType.ARRAY, + isInResponse = false + ), + DocumentedJSONField( + "teamMembersIds[].*", + "Team member ID", + JsonFieldType.NUMBER, + isInResponse = false, + optional = true + ) + ).addFieldsBeneathPath( + "teamMembers[]", + PayloadAccount().payload.documentedJSONFields, + optional = true, + addResponse = true + ).addFieldsBeneathPath( + "dateInterval", + PayloadDateInterval.payload.documentedJSONFields, + addRequest = true, + addResponse = true + ) +) diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadGeneration.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadGeneration.kt new file mode 100644 index 00000000..ca04ecc9 --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadGeneration.kt @@ -0,0 +1,52 @@ +package pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model + +import org.springframework.restdocs.payload.JsonFieldType +import pt.up.fe.ni.website.backend.utils.documentation.Tag +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField.Companion.addFieldsBeneathPath +import pt.up.fe.ni.website.backend.utils.documentation.utils.ModelDocumentation + +class PayloadGeneration : ModelDocumentation( + Tag.GENERATION.name.lowercase(), + Tag.GENERATION, + mutableListOf( + DocumentedJSONField("schoolYear", "School year of the generation", JsonFieldType.STRING, optional = true), + DocumentedJSONField("id", "Id of the generation", JsonFieldType.NUMBER, isInRequest = false), + DocumentedJSONField("roles", "Roles associated with the generation", JsonFieldType.ARRAY) + ).addFieldsBeneathPath( + "roles[]", + PayloadAssociatedRoles.payload.documentedJSONFields, + optional = true, + addResponse = true, + addRequest = true + ) +) + +class PayloadGenerationYears : ModelDocumentation( + "arrayOf-${Tag.GENERATION.name.lowercase()}-years", + Tag.GENERATION, + mutableListOf( + DocumentedJSONField("[]", "List of all generation years", JsonFieldType.ARRAY, isInRequest = false), + DocumentedJSONField("[].*", "School year", JsonFieldType.STRING, optional = true, isInRequest = false) + ) +) + +class PayloadGenerationSections : ModelDocumentation( + "${Tag.GENERATION.name.lowercase()}-sections", + Tag.GENERATION, + mutableListOf( + DocumentedJSONField("[]", "Generation sections", JsonFieldType.ARRAY, isInRequest = false), + DocumentedJSONField("[].section", "Section role name", JsonFieldType.STRING, isInRequest = false), + DocumentedJSONField( + "[].accounts[]", + "Array of accounts of with the section role", + JsonFieldType.ARRAY, + isInRequest = false + ) + ).addFieldsBeneathPath( + "[].accounts[]", + PayloadAccount().payload.documentedJSONFields, + optional = true, + addResponse = true + ) +) diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadPost.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadPost.kt new file mode 100644 index 00000000..a7e45775 --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadPost.kt @@ -0,0 +1,35 @@ +package pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model + +import org.springframework.restdocs.payload.JsonFieldType +import pt.up.fe.ni.website.backend.utils.documentation.Tag +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField +import pt.up.fe.ni.website.backend.utils.documentation.utils.ModelDocumentation + +class PayloadPost : ModelDocumentation( + Tag.POST.name.lowercase(), + Tag.POST, + mutableListOf( + DocumentedJSONField("title", "Post title", JsonFieldType.STRING), + DocumentedJSONField("body", "Post body", JsonFieldType.STRING), + DocumentedJSONField("thumbnailPath", "Path for the post thumbnail image", JsonFieldType.STRING), + DocumentedJSONField( + "slug", + "Short and friendly textual post identifier", + JsonFieldType.STRING, + optional = true + ), + DocumentedJSONField("id", "Post ID", JsonFieldType.NUMBER, isInRequest = false), + DocumentedJSONField( + "publishDate", + "Post's publish date", + JsonFieldType.STRING, + isInRequest = false + ), + DocumentedJSONField( + "lastUpdatedAt", + "Post's latest update", + JsonFieldType.STRING, + isInRequest = false + ) + ) +) 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 new file mode 100644 index 00000000..ba30436c --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/payloadschemas/model/PayloadProject.kt @@ -0,0 +1,56 @@ +package pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.model + +import org.springframework.restdocs.payload.JsonFieldType +import pt.up.fe.ni.website.backend.utils.documentation.Tag +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField.Companion.addFieldsBeneathPath +import pt.up.fe.ni.website.backend.utils.documentation.utils.ModelDocumentation + +class PayloadProject : ModelDocumentation( + Tag.PROJECT.name.lowercase(), + Tag.PROJECT, + mutableListOf( + DocumentedJSONField("title", "Project title", JsonFieldType.STRING), + DocumentedJSONField("description", "Project description", JsonFieldType.STRING), + DocumentedJSONField("isArchived", "If the project is no longer maintained", JsonFieldType.BOOLEAN), + DocumentedJSONField( + "technologies", + "Array of technologies used in the project", + JsonFieldType.ARRAY, + optional = true + ), + DocumentedJSONField("technologies.*", "Technology", JsonFieldType.STRING, optional = true), + DocumentedJSONField( + "slug", + "Short and friendly textual event identifier", + JsonFieldType.STRING, + optional = true + ), + DocumentedJSONField("id", "Project ID", JsonFieldType.NUMBER, isInRequest = false), + DocumentedJSONField( + "teamMembers", + "Array of members associated with the project", + JsonFieldType.ARRAY, + isInRequest = false + ), + DocumentedJSONField( + "teamMembersIds", + "Array with IDs of members associated with the project", + JsonFieldType.ARRAY, + optional = true, + isInResponse = false + ), + DocumentedJSONField( + "teamMembersIds.*", + "Account ID", + JsonFieldType.NUMBER, + optional = true, + isInResponse = false + ) + ).addFieldsBeneathPath( + "teamMembers[]", + PayloadAccount().payload.documentedJSONFields, + addResponse = true, + optional = true + ) +) diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/tag-descriptions.yaml b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/tag-descriptions.yaml new file mode 100644 index 00000000..771dfcd2 --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/tag-descriptions.yaml @@ -0,0 +1,6 @@ +Authentication: Endpoints and operations for authentication management. +Accounts: Endpoints and operations for account management. +Events: Endpoints and operations for event management. +Generations: Endpoints and operations for generations management. +Posts: Endpoints and operations for post management. +Projects: Endpoints and operations for project management. diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/utils/DocumentedJSONField.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/utils/DocumentedJSONField.kt new file mode 100644 index 00000000..05908013 --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/utils/DocumentedJSONField.kt @@ -0,0 +1,57 @@ +package pt.up.fe.ni.website.backend.utils.documentation.utils + +import kotlin.reflect.jvm.internal.impl.load.kotlin.JvmType +import org.springframework.restdocs.payload.FieldDescriptor +import org.springframework.restdocs.payload.JsonFieldType +import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath + +class DocumentedJSONField( + var path: String, + val description: String? = null, + private val type: JsonFieldType? = null, + var optional: Boolean = false, + var ignored: Boolean = false, + private val attributes: Map = emptyMap(), + var isInRequest: Boolean = true, + var isInResponse: Boolean = true +) : Cloneable { + companion object { + fun MutableList.addFieldsBeneathPath( + path: String, + documentedJSONFields: List, + optional: Boolean = false, + ignored: Boolean = false, + addRequest: Boolean = false, + addResponse: Boolean = false + ): MutableList { + documentedJSONFields.forEach { field -> + val fieldBeneath = field.clone() as DocumentedJSONField + fieldBeneath.path = path + + (if (field.path.startsWith("[") || field.path.startsWith(".")) "" else ".") + field.path + + fieldBeneath.optional = field.optional || optional + fieldBeneath.ignored = field.ignored || ignored + fieldBeneath.isInRequest = addRequest && field.isInRequest + fieldBeneath.isInResponse = addResponse && field.isInResponse + + this.add(fieldBeneath) + } + return this + } + } + + fun getFieldDescriptor(): FieldDescriptor { + val fieldDescriptor = fieldWithPath(path) + description?.let { fieldDescriptor.description(it) } + type?.let { fieldDescriptor.type(it) } + if (optional) fieldDescriptor.optional() + if (ignored) fieldDescriptor.ignored() + fieldDescriptor.attributes.putAll(attributes) + + return fieldDescriptor + } + + public override fun clone(): Any { + return super.clone() + } +} diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/utils/ITag.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/utils/ITag.kt new file mode 100644 index 00000000..9d3d12ca --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/utils/ITag.kt @@ -0,0 +1,6 @@ +package pt.up.fe.ni.website.backend.utils.documentation.utils + +interface ITag { + val name: String + val fullName: String +} diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/utils/MockMVCExtension.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/utils/MockMVCExtension.kt new file mode 100644 index 00000000..8d1d6d6d --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/utils/MockMVCExtension.kt @@ -0,0 +1,223 @@ +package pt.up.fe.ni.website.backend.utils.documentation.utils + +import com.epages.restdocs.apispec.HeaderDescriptorWithType +import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document +import com.epages.restdocs.apispec.ParameterDescriptorWithType +import com.epages.restdocs.apispec.ResourceDocumentation +import com.epages.restdocs.apispec.ResourceSnippetParameters.Companion.builder +import com.epages.restdocs.apispec.Schema +import org.springframework.restdocs.payload.FieldDescriptor +import org.springframework.test.web.servlet.ResultActions +import pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.EmptyObjectSchema +import pt.up.fe.ni.website.backend.utils.documentation.payloadschemas.ErrorSchema + +class MockMVCExtension { + companion object { + private val emptyPayload = EmptyObjectSchema() + private val errorPayloadSchema = ErrorSchema() + + fun ResultActions.andDocument( + documentation: ModelDocumentation, + summary: String? = null, + description: String? = null, + requestHeaders: List = emptyList(), + responseHeaders: List = emptyList(), + urlParameters: List = emptyList(), + documentRequestPayload: Boolean = false, + hasRequestPayload: Boolean = false + ): ResultActions { + return documenter( + documentation, + summary, + description, + urlParameters, + documentation.payload.Request().schema(), + documentation.payload.Request().getSchemaFieldDescriptors(), + requestHeaders, + documentation.payload.Response().schema(), + documentation.payload.Response().getSchemaFieldDescriptors(), + responseHeaders, + documentRequestPayload, + hasRequestPayload + ) + } + + fun ResultActions.andDocumentCustomRequestSchema( + documentation: ModelDocumentation, + requestPayload: PayloadSchema, + summary: String? = null, + description: String? = null, + requestHeaders: List = emptyList(), + responseHeaders: List = emptyList(), + urlParameters: List = emptyList(), + documentRequestPayload: Boolean = false, + hasRequestPayload: Boolean = false + ): ResultActions { + return documenter( + documentation, + summary, + description, + urlParameters, + requestPayload.Request().schema(), + requestPayload.Request().getSchemaFieldDescriptors(), + requestHeaders, + documentation.payload.Response().schema(), + documentation.payload.Response().getSchemaFieldDescriptors(), + responseHeaders, + documentRequestPayload, + hasRequestPayload + ) + } + + fun ResultActions.andDocumentErrorResponse( + documentation: ModelDocumentation, + summary: String? = null, + description: String? = null, + requestHeaders: List = emptyList(), + urlParameters: List = emptyList(), + documentRequestPayload: Boolean = false, + hasRequestPayload: Boolean = false + ): ResultActions { + return documenter( + documentation, + summary, + description, + urlParameters, + documentation.payload.Request().schema(), + documentation.payload.Request().getSchemaFieldDescriptors(), + requestHeaders, + errorPayloadSchema.Response().schema(), + errorPayloadSchema.Response().getSchemaFieldDescriptors(), + emptyList(), + documentRequestPayload, + hasRequestPayload + ) + } + + fun ResultActions.andDocumentCustomRequestSchemaErrorResponse( + documentation: ModelDocumentation, + requestPayload: PayloadSchema, + summary: String? = null, + description: String? = null, + requestHeaders: List = emptyList(), + urlParameters: List = emptyList(), + documentRequestPayload: Boolean = false, + hasRequestPayload: Boolean = false + ): ResultActions { + return documenter( + documentation, + summary, + description, + urlParameters, + requestPayload.Request().schema(), + requestPayload.Request().getSchemaFieldDescriptors(), + requestHeaders, + errorPayloadSchema.Response().schema(), + errorPayloadSchema.Response().getSchemaFieldDescriptors(), + emptyList(), + documentRequestPayload, + hasRequestPayload + ) + } + + fun ResultActions.andDocumentEmptyObjectResponse( + documentation: ModelDocumentation, + summary: String? = null, + description: String? = null, + requestHeaders: List = emptyList(), + urlParameters: List = emptyList(), + documentRequestPayload: Boolean = false, + hasRequestPayload: Boolean = false + ): ResultActions { + return documenter( + documentation, + summary, + description, + urlParameters, + documentation.payload.Request().schema(), + documentation.payload.Request().getSchemaFieldDescriptors(), + requestHeaders, + emptyPayload.Response().schema(), + emptyPayload.Response().getSchemaFieldDescriptors(), + emptyList(), + documentRequestPayload, + hasRequestPayload + ) + } + + fun ResultActions.andDocumentCustomRequestSchemaEmptyResponse( + documentation: ModelDocumentation, + requestPayload: PayloadSchema, + summary: String? = null, + description: String? = null, + requestHeaders: List = emptyList(), + urlParameters: List = emptyList(), + documentRequestPayload: Boolean = false, + hasRequestPayload: Boolean = false + ): ResultActions { + return documenter( + documentation, + summary, + description, + urlParameters, + requestPayload.Request().schema(), + requestPayload.Request().getSchemaFieldDescriptors(), + requestHeaders, + emptyPayload.Response().schema(), + emptyPayload.Response().getSchemaFieldDescriptors(), + emptyList(), + documentRequestPayload, + hasRequestPayload + ) + } + + private fun ResultActions.documenter( + documentation: ModelDocumentation, + summary: String?, + description: String?, + urlParameters: List, + requestSchema: Schema, + requestFields: List, + requestHeaders: List, + responseSchema: Schema, + responseFields: List, + responseHeaders: List, + documentRequestPayload: Boolean, + hasRequestPayload: Boolean + ): ResultActions { + val builder = builder() + if (summary != null) { + builder.summary(summary) + } + + if (description != null) { + builder.description(description) + } + + if (hasRequestPayload || documentRequestPayload) { + builder.requestSchema(requestSchema) + } + + if (requestFields.isNotEmpty() && documentRequestPayload) { + builder.requestFields(requestFields) + } + + builder.requestHeaders(requestHeaders).pathParameters(urlParameters) + + builder.responseSchema(responseSchema).responseFields(responseFields).responseHeaders(responseHeaders) + + builder.tag(documentation.tag.fullName) + + this.andDo( + document( + "${documentation.tag.name}/{ClassName}/{methodName}", + snippets = arrayOf( + ResourceDocumentation.resource(builder.build()) + ) + ) + ) + + return this + } + } +} diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/utils/ModelDocumentation.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/utils/ModelDocumentation.kt new file mode 100644 index 00000000..d28515be --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/utils/ModelDocumentation.kt @@ -0,0 +1,14 @@ +package pt.up.fe.ni.website.backend.utils.documentation.utils + +open class ModelDocumentation( + schemaName: String, + val tag: ITag, + documentedJsonFields: MutableList +) { + val payload = PayloadSchema(schemaName, documentedJsonFields) + + fun getModelDocumentationArray(): ModelDocumentation { + val payloadArraySchema = payload.getPayloadArraySchema() + return ModelDocumentation(payloadArraySchema.schemaName, tag, payloadArraySchema.documentedJSONFields) + } +} diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/utils/PayloadSchema.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/utils/PayloadSchema.kt new file mode 100644 index 00000000..20e567e1 --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation/utils/PayloadSchema.kt @@ -0,0 +1,58 @@ +package pt.up.fe.ni.website.backend.utils.documentation.utils + +import com.epages.restdocs.apispec.Schema +import org.springframework.restdocs.payload.FieldDescriptor +import pt.up.fe.ni.website.backend.utils.documentation.utils.DocumentedJSONField.Companion.addFieldsBeneathPath + +open class PayloadSchema( + open val schemaName: String, + val documentedJSONFields: MutableList +) { + companion object { + enum class MessageType(val type: String) { + REQUEST("request"), RESPONSE("response") + } + } + + open fun getPayloadArraySchema(): PayloadSchema { + val newPayload = PayloadSchema("arrayOf-$schemaName", mutableListOf()) + newPayload.addBeneathPath("[]", documentedJSONFields) + return newPayload + } + + abstract inner class Message(private val type: MessageType) { + fun schema(): Schema { + return Schema("$schemaName-${type.type}") + } + + fun getSchemaFieldDescriptors(): MutableList { + val fields = mutableListOf() + + documentedJSONFields.forEach { field -> + if ((field.isInRequest && type == MessageType.REQUEST) || + (field.isInResponse && type == MessageType.RESPONSE) + ) { + fields.add(field.getFieldDescriptor()) + } + } + + return fields + } + } + + inner class Request : Message(MessageType.REQUEST) + + inner class Response : Message(MessageType.RESPONSE) + + private fun addBeneathPath( + path: String, + documentedJSONFields: MutableList + ) { + this.documentedJSONFields.addFieldsBeneathPath( + path, + documentedJSONFields, + addRequest = true, + addResponse = true + ) + } +} diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/listeners/DbCleanupListener.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/listeners/DbCleanupListener.kt index 27bd7834..69e24ac6 100644 --- a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/listeners/DbCleanupListener.kt +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/listeners/DbCleanupListener.kt @@ -2,16 +2,17 @@ package pt.up.fe.ni.website.backend.utils.listeners import jakarta.persistence.EntityManager import jakarta.transaction.Transactional +import java.sql.Connection +import java.sql.Statement +import javax.sql.DataSource import org.hibernate.id.enhanced.PooledOptimizer import org.hibernate.id.enhanced.SequenceStyleGenerator import org.hibernate.metamodel.model.domain.internal.MappingMetamodelImpl import org.springframework.test.context.TestContext import org.springframework.test.context.TestExecutionListener -import java.sql.Connection -import java.sql.Statement -import javax.sql.DataSource +import pt.up.fe.ni.website.backend.config.Logging -class DbCleanupListener : TestExecutionListener { +class DbCleanupListener : TestExecutionListener, Logging { companion object { private const val SCHEMA_NAME = "PUBLIC" private const val DB_NAME = "H2" @@ -19,7 +20,7 @@ class DbCleanupListener : TestExecutionListener { override fun beforeTestMethod(testContext: TestContext) { super.beforeTestMethod(testContext) - println("Cleaning up database...") + logger.info("Cleaning up database...") val dataSource = testContext.applicationContext.getBean(DataSource::class.java) cleanDataSource(dataSource) @@ -27,7 +28,7 @@ class DbCleanupListener : TestExecutionListener { val entityManager = testContext.applicationContext.getBean(EntityManager::class.java) cleanEntityManager(entityManager) - println("Done cleaning database.") + logger.info("Done cleaning up database...") } @Transactional(Transactional.TxType.REQUIRES_NEW) @@ -40,7 +41,10 @@ class DbCleanupListener : TestExecutionListener { resetSequences(statement) enableConstraints(statement) } else { - print("Unexpected database type: ${connection.metaData.databaseProductName} (expected: $DB_NAME). Skipping cleanup.") + logger.warn( + "Unexpected database type: ${connection.metaData.databaseProductName}" + + " (expected: $DB_NAME). Skipping cleanup." + ) } } @@ -52,7 +56,9 @@ class DbCleanupListener : TestExecutionListener { metaModel.forEachEntityDescriptor { entityDescriptor -> if (!entityDescriptor.hasIdentifierProperty() || entityDescriptor.identifierGenerator !is SequenceStyleGenerator - ) return@forEachEntityDescriptor + ) { + return@forEachEntityDescriptor + } val sequenceStyleGenerator = (entityDescriptor.identifierGenerator as SequenceStyleGenerator) val optimizer = sequenceStyleGenerator.optimizer as? PooledOptimizer diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/mockmvc/MockMvcExtensions.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/mockmvc/MockMvcExtensions.kt new file mode 100644 index 00000000..d80db3bc --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/mockmvc/MockMvcExtensions.kt @@ -0,0 +1,7 @@ +package pt.up.fe.ni.website.backend.utils.mockmvc + +import org.springframework.test.web.servlet.MockMvc + +fun MockMvc.multipartBuilder(s: String): MockMvcMultipartBuilder { + return MockMvcMultipartBuilder(this, s) +} diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/utils/mockmvc/MockMvcMultipartBuilder.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/mockmvc/MockMvcMultipartBuilder.kt new file mode 100644 index 00000000..240ed891 --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/utils/mockmvc/MockMvcMultipartBuilder.kt @@ -0,0 +1,55 @@ +package pt.up.fe.ni.website.backend.utils.mockmvc + +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.mock.web.MockPart +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.RequestPostProcessor + +class MockMvcMultipartBuilder( + private val mockMvc: MockMvc, + uri: String +) { + private val multipart = multipart(uri) + + fun addPart(key: String, data: String): MockMvcMultipartBuilder { + val part = MockPart(key, data.toByteArray()) + part.headers.contentType = MediaType.APPLICATION_JSON + + multipart.part(part) + return this + } + + fun addFile( + name: String = "photo", + filename: String = "photo.jpeg", + content: String = "content", + contentType: String = MediaType.IMAGE_JPEG_VALUE + ): MockMvcMultipartBuilder { + val file = MockMultipartFile( + name, + filename, + contentType, + content.toByteArray() + ) + + multipart.file(file) + return this + } + + fun asPutMethod(): MockMvcMultipartBuilder { + multipart.with( + RequestPostProcessor { + it.method = "PUT" + it + } + ) + return this + } + + fun perform(): ResultActions { + return mockMvc.perform(multipart) + } +}