diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..5cbdc074c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.tex linguist-detectable=false +*.bib linguist-detectable=false +*.bibtex linguist-detectable=false diff --git a/.github/workflows/ci.yml.disabled b/.github/workflows/ci.yml similarity index 100% rename from .github/workflows/ci.yml.disabled rename to .github/workflows/ci.yml diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml new file mode 100644 index 000000000..d1c691708 --- /dev/null +++ b/.github/workflows/sonarqube.yml @@ -0,0 +1,36 @@ +name: SonarQube +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] +jobs: + build: + name: Build and analyze + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'zulu' # Alternative distribution options are available. + - name: Cache SonarQube packages + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-m2 + - name: Build and analyze + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=pet-ads_systematic \ No newline at end of file diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml new file mode 100644 index 000000000..a7265e4c8 --- /dev/null +++ b/.idea/dictionaries/project.xml @@ -0,0 +1,7 @@ + + + + picoc + + + \ No newline at end of file diff --git a/account/pom.xml b/account/pom.xml index 34e1db50c..5ae3d627f 100644 --- a/account/pom.xml +++ b/account/pom.xml @@ -30,22 +30,7 @@ org.jetbrains.kotlin kotlin-maven-plugin - - - compile - compile - - compile - - - - test-compile - test-compile - - test-compile - - - + true maven-surefire-plugin @@ -72,6 +57,7 @@ org.postgresql postgresql + 42.7.2 \ No newline at end of file diff --git a/account/src/main/kotlin/br/all/domain/user/Email.kt b/account/src/main/kotlin/br/all/domain/user/Email.kt index caf6dce7b..965415718 100644 --- a/account/src/main/kotlin/br/all/domain/user/Email.kt +++ b/account/src/main/kotlin/br/all/domain/user/Email.kt @@ -7,51 +7,60 @@ data class Email(val value: String) : ValueObject() { init { val notification = validate() - require(notification.hasNoErrors()) {notification.message()} + require(notification.hasNoErrors()) { notification.message() } } override fun validate(): Notification { val notification = Notification() + if (value.isEmpty()) { + notification.addError("Email must not be empty.") + return notification + } + if (value.isBlank()) { notification.addError("Email must not be blank.") return notification } + if (!isValidEmailFormat(value)) notification.addError("Wrong Email format.") + return notification } private fun isValidEmailFormat(email: String): Boolean { - if (!isValidEmailAddress(email)) return false - if (!hasLengthBelowMaximum(email)) return false - if (hasRepeatedSubdomains(email)) return false - if (email.contains("..")) return false - if (email.contains("@.")) return false - if (email.contains(".@")) return false - return true - } - - private fun isValidEmailAddress(email: String): Boolean { - val regex = Regex("^[A-Za-z0-9+_.-]+@[a-z.]+$") - return regex.matches(email) - } + if (email.contains("..") || email.contains(".@") || email.contains("@.")) return false + if (email.startsWith(".") || email.endsWith(".")) return false + if (email.startsWith("@") || email.endsWith("@")) return false - fun hasRepeatedSubdomains(email: String): Boolean { val parts = email.split("@") + if (parts.size != 2) return false - if (parts.size == 2) { - val subdomains = parts[1].split(".") - val verifyedSubdomains = HashSet() + val localPart = parts[0] + val domainPart = parts[1] - for (subdomain in subdomains){ - if (!verifyedSubdomains.add(subdomain)) return true - } + if (!hasValidLength(localPart, domainPart)) return false + if (!isValidStructure(localPart, domainPart)) return false + + return true + } + + private fun isValidStructure(localPart: String, domainPart: String): Boolean { + val localRegex = Regex("^[A-Za-z0-9_!#$%&'*+/=?`{|}~^.-]+$") + val domainRegex = Regex("^[A-Za-z0-9.-]+$") + + val domainLabels = domainPart.split(".") + if (domainLabels.last().length < 2 || domainLabels.any { it.startsWith("-") || it.endsWith("-") }) { + return false } - return false + + return localRegex.matches(localPart) && domainRegex.matches(domainPart) } - fun hasLengthBelowMaximum(email: String): Boolean { - val parts = email.split("@") - return !(parts[0].length > 64 || parts[1].length > 255) + private fun hasValidLength(localPart: String, domainPart: String): Boolean { + if (localPart.length > 64) return false + if (domainPart.length > 255) return false + if ((localPart.length + 1 + domainPart.length) > 254) return false + return true } } \ No newline at end of file diff --git a/account/src/test/kotlin/br/all/application/user/create/RegisterUserAccountServiceImplTest.kt b/account/src/test/kotlin/br/all/application/user/create/RegisterUserAccountServiceImplTest.kt index c5d6795b9..10f7cca27 100644 --- a/account/src/test/kotlin/br/all/application/user/create/RegisterUserAccountServiceImplTest.kt +++ b/account/src/test/kotlin/br/all/application/user/create/RegisterUserAccountServiceImplTest.kt @@ -33,7 +33,7 @@ class RegisterUserAccountServiceImplTest { inner class WhenSuccessfullyRegisteringUser { @Test fun `should register a new user`() { - val request = factory.registerRequest().copy(email = "user@example.com") + val request = factory.registerRequest() every { userAccountRepository.existsByEmail(request.email) } returns false every { userAccountRepository.existsByUsername(request.username) } returns false @@ -53,7 +53,7 @@ class RegisterUserAccountServiceImplTest { inner class WhenFailingToRegisterUser { @Test fun `should not register user with existing email`() { - val request = factory.registerRequest().copy(email = "user@example.com") + val request = factory.registerRequest() every { userAccountRepository.existsByEmail(request.email) } returns true every { userAccountRepository.existsByUsername(request.username) } returns false @@ -68,7 +68,7 @@ class RegisterUserAccountServiceImplTest { @Test fun `should not register user with existing username`() { - val request = factory.registerRequest().copy(email = "user@example.com") + val request = factory.registerRequest() every { userAccountRepository.existsByEmail(request.email) } returns false every { userAccountRepository.existsByUsername(request.username) } returns true diff --git a/account/src/test/kotlin/br/all/domain/user/UserAccountTest.kt b/account/src/test/kotlin/br/all/domain/user/UserAccountTest.kt index fdb27d3ee..25a1e4b8e 100644 --- a/account/src/test/kotlin/br/all/domain/user/UserAccountTest.kt +++ b/account/src/test/kotlin/br/all/domain/user/UserAccountTest.kt @@ -2,11 +2,13 @@ package br.all.domain.user import io.github.serpro69.kfaker.Faker import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Tag import java.time.LocalDateTime import java.util.UUID import kotlin.test.Test import kotlin.test.assertFailsWith +@Tag("UnitTest") class UserAccountTest{ private val faker = Faker() diff --git a/pom.xml b/pom.xml index e0f2e0a6b..da1865238 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ org.springframework.boot spring-boot-starter-parent - 3.1.2 + 3.4.4 @@ -29,6 +29,9 @@ UTF-8 official 1.8 + 1.9.0 + pet-ads + https://sonarcloud.io @@ -47,18 +50,19 @@ org.jetbrains.kotlin kotlin-reflect + ${kotlin.version} compile org.jetbrains.kotlin kotlin-stdlib - 1.9.0 + ${kotlin.version} compile org.jetbrains.kotlin kotlin-test-junit5 - 1.9.0 + ${kotlin.version} test @@ -66,12 +70,6 @@ kotlin-faker 1.15.0 - - org.junit.jupiter - junit-jupiter-engine - 5.8.2 - test - com.tngtech.archunit archunit-junit5 @@ -81,7 +79,7 @@ org.jetbrains.kotlin kotlin-test-junit - 1.9.0 + ${kotlin.version} test @@ -109,24 +107,20 @@ org.jetbrains.kotlin kotlin-maven-plugin - 1.9.0 + ${kotlin.version} - - org.mapstruct - mapstruct-processor - 1.4.2.Final - org.jetbrains.kotlin kotlin-maven-allopen - 2.0.0-RC1 + ${kotlin.version} org.jetbrains.kotlin kotlin-maven-noarg - 2.0.0-RC1 + ${kotlin.version} + + true spring jpa - - spring - diff --git a/review/pom.xml b/review/pom.xml index 6c9336ec0..920d13193 100644 --- a/review/pom.xml +++ b/review/pom.xml @@ -21,7 +21,6 @@ org.springframework.boot spring-boot-starter-data-mongodb - 3.1.2 org.springframework @@ -33,11 +32,11 @@ 1.0-SNAPSHOT compile + io.swagger.core.v3 swagger-annotations-jakarta - 2.2.19 - compile + 2.2.31 org.apache.commons diff --git a/review/src/main/kotlin/br/all/application/protocol/find/GetProtocolStagePresenter.kt b/review/src/main/kotlin/br/all/application/protocol/find/GetProtocolStagePresenter.kt new file mode 100644 index 000000000..8ba8c5d9b --- /dev/null +++ b/review/src/main/kotlin/br/all/application/protocol/find/GetProtocolStagePresenter.kt @@ -0,0 +1,6 @@ +package br.all.application.protocol.find + +import br.all.application.shared.presenter.GenericPresenter +import br.all.application.protocol.find.GetProtocolStageService.ResponseModel + +interface GetProtocolStagePresenter : GenericPresenter \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/application/protocol/find/GetProtocolStageService.kt b/review/src/main/kotlin/br/all/application/protocol/find/GetProtocolStageService.kt new file mode 100644 index 000000000..31ba40773 --- /dev/null +++ b/review/src/main/kotlin/br/all/application/protocol/find/GetProtocolStageService.kt @@ -0,0 +1,32 @@ +package br.all.application.protocol.find + +import io.swagger.v3.oas.annotations.media.Schema +import java.util.UUID + +interface GetProtocolStageService { + fun getStage(presenter: GetProtocolStagePresenter, request: RequestModel) + + data class RequestModel( + val userId: UUID, + val systematicStudyId: UUID + ) + + @Schema(name = "GetProtocolStageServiceResponseModel", description = "Response model for Get Protocol Stage Service") + data class ResponseModel( + val userId: UUID, + val systematicStudyId: UUID, + val currentStage: ProtocolStage + ) + + enum class ProtocolStage { + PROTOCOL_PART_I, + PICOC, + PROTOCOL_PART_II, + PROTOCOL_PART_III, + IDENTIFICATION, + SELECTION, + EXTRACTION, + GRAPHICS, + FINALIZATION + } +} \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/application/protocol/find/GetProtocolStageServiceImpl.kt b/review/src/main/kotlin/br/all/application/protocol/find/GetProtocolStageServiceImpl.kt new file mode 100644 index 000000000..22403c249 --- /dev/null +++ b/review/src/main/kotlin/br/all/application/protocol/find/GetProtocolStageServiceImpl.kt @@ -0,0 +1,129 @@ +package br.all.application.protocol.find + +import br.all.application.protocol.repository.ProtocolRepository +import br.all.application.review.repository.SystematicStudyRepository +import br.all.application.user.CredentialsService +import br.all.application.protocol.find.GetProtocolStageService.RequestModel +import br.all.application.protocol.find.GetProtocolStageService.ResponseModel +import br.all.application.protocol.find.GetProtocolStageService.ProtocolStage +import br.all.application.protocol.repository.ProtocolDto +import br.all.application.question.repository.QuestionRepository +import br.all.application.review.repository.fromDto +import br.all.application.shared.exceptions.EntityNotFoundException +import br.all.application.shared.presenter.prepareIfFailsPreconditions +import br.all.application.study.repository.StudyReviewRepository +import br.all.domain.model.question.QuestionContextEnum +import br.all.domain.model.review.SystematicStudy +import org.springframework.stereotype.Service + +@Service +class GetProtocolStageServiceImpl( + private val protocolRepository: ProtocolRepository, + private val systematicStudyRepository: SystematicStudyRepository, + private val studyReviewRepository: StudyReviewRepository, + private val credentialsService: CredentialsService, + private val questionRepository: QuestionRepository +) : GetProtocolStageService { + override fun getStage(presenter: GetProtocolStagePresenter, request: RequestModel) { + val user = credentialsService.loadCredentials(request.userId)?.toUser() + val systematicStudyDto = systematicStudyRepository.findById(request.systematicStudyId) + val systematicStudy = systematicStudyDto?.let { SystematicStudy.fromDto(it) } + + presenter.prepareIfFailsPreconditions(user, systematicStudy) + if (presenter.isDone()) return + + val protocolDto = protocolRepository.findById(request.systematicStudyId) + if (protocolDto == null) { + val message = "Protocol not found for systematic study ${request.systematicStudyId}" + presenter.prepareFailView(EntityNotFoundException(message)) + return + } + + val allStudies = studyReviewRepository.findAllFromReview(request.systematicStudyId) + val robQuestions = questionRepository.findAllBySystematicStudyId(systematicStudyDto!!.id, QuestionContextEnum.ROB).size + val extractionQuestions = questionRepository.findAllBySystematicStudyId(systematicStudyDto.id, QuestionContextEnum.EXTRACTION).size + + val totalStudiesCount = allStudies.size + val includedStudiesCount = allStudies.count { it.selectionStatus == "INCLUDED" } + val extractedStudiesCount = allStudies.count { it.extractionStatus == "INCLUDED" } + + val stage = evaluateStage( + protocolDto, + totalStudiesCount, + includedStudiesCount, + extractedStudiesCount, + robQuestions, + extractionQuestions, + ) + + presenter.prepareSuccessView(ResponseModel(request.userId, request.systematicStudyId, stage)) + } + + private fun evaluateStage( + dto: ProtocolDto, + totalStudiesCount: Int, + includedStudiesCount: Int, + extractedStudiesCount: Int, + robQuestionCount: Int, + extractionQuestionsCount: Int + ): ProtocolStage { + return when { + isProtocolPartI(dto) -> ProtocolStage.PROTOCOL_PART_I + picocStage(dto) -> ProtocolStage.PICOC + isProtocolPartII(dto) -> ProtocolStage.PROTOCOL_PART_II + !isProtocolPartIIICompleted(dto, robQuestionCount, extractionQuestionsCount) -> ProtocolStage.PROTOCOL_PART_III + totalStudiesCount == 0 -> ProtocolStage.IDENTIFICATION + includedStudiesCount == 0 -> ProtocolStage.SELECTION + extractedStudiesCount == 0 -> ProtocolStage.EXTRACTION + else -> ProtocolStage.GRAPHICS + } + } + + private fun isProtocolPartI(dto: ProtocolDto): Boolean { + return dto.goal.isNullOrBlank() && dto.justification.isNullOrBlank() + } + + private fun isProtocolPartII(dto: ProtocolDto): Boolean { + return dto.studiesLanguages.isEmpty() && + dto.eligibilityCriteria.isEmpty() && + dto.informationSources.isEmpty() && + dto.keywords.isEmpty() && + dto.sourcesSelectionCriteria.isNullOrBlank() && + dto.searchMethod.isNullOrBlank() && + dto.selectionProcess.isNullOrBlank() + } + + private fun isProtocolPartIIICompleted(dto: ProtocolDto, robQuestionCount: Int, extractionQuestionsCount: Int): Boolean { + val hasInclusionCriteria = dto.eligibilityCriteria.any { it.type.equals("INCLUSION", ignoreCase = true) } + val hasExclusionCriteria = dto.eligibilityCriteria.any { it.type.equals("EXCLUSION", ignoreCase = true) } + + val hasExtractionAndRob = robQuestionCount > 0 && extractionQuestionsCount > 0 + + val hasDatabases = dto.informationSources.isNotEmpty() + val hasResearchQuestions = dto.researchQuestions.isNotEmpty() + val hasAnalysisProcess = !dto.analysisAndSynthesisProcess.isNullOrBlank() + + return hasInclusionCriteria && hasExclusionCriteria && + hasExtractionAndRob && hasDatabases && + hasResearchQuestions && hasAnalysisProcess + } + + private fun picocStage(dto: ProtocolDto): Boolean { + val picoc = dto.picoc + if (picoc == null) return false + + val picocIsStarted = !picoc.population.isNullOrBlank() || !picoc.intervention.isNullOrBlank() || + !picoc.control.isNullOrBlank() || !picoc.outcome.isNullOrBlank() || !picoc.context.isNullOrBlank() + + if (picocIsStarted) { + val picocIsCompleted = !picoc.population.isNullOrBlank() && !picoc.intervention.isNullOrBlank() && + !picoc.control.isNullOrBlank() && !picoc.outcome.isNullOrBlank() && !picoc.context.isNullOrBlank() + + if (!picocIsCompleted) { + return true + } + } + + return false + } +} diff --git a/review/src/main/kotlin/br/all/application/report/find/presenter/FindStudyReviewCriteriaPresenter.kt b/review/src/main/kotlin/br/all/application/report/find/presenter/FindStudyReviewCriteriaPresenter.kt new file mode 100644 index 000000000..90e2ad4cf --- /dev/null +++ b/review/src/main/kotlin/br/all/application/report/find/presenter/FindStudyReviewCriteriaPresenter.kt @@ -0,0 +1,6 @@ +package br.all.application.report.find.presenter + +import br.all.application.shared.presenter.GenericPresenter +import br.all.application.report.find.service.FindStudyReviewCriteriaService + +interface FindStudyReviewCriteriaPresenter: GenericPresenter \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/application/report/find/service/FindStudyReviewCriteriaService.kt b/review/src/main/kotlin/br/all/application/report/find/service/FindStudyReviewCriteriaService.kt new file mode 100644 index 000000000..e97df28f4 --- /dev/null +++ b/review/src/main/kotlin/br/all/application/report/find/service/FindStudyReviewCriteriaService.kt @@ -0,0 +1,22 @@ +package br.all.application.report.find.service + +import br.all.application.report.find.presenter.FindStudyReviewCriteriaPresenter +import java.util.* + +interface FindStudyReviewCriteriaService { + fun findCriteria(presenter: FindStudyReviewCriteriaPresenter, request: RequestModel) + + data class RequestModel( + val userId: UUID, + val systematicStudyId: UUID, + val studyReviewId: Long + ) + + data class ResponseModel( + val userId: UUID, + val systematicStudyId: UUID, + val studyReviewId: Long, + val inclusionCriteria: Set, + val exclusionCriteria: Set + ) +} \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/application/report/find/service/FindStudyReviewCriteriaServiceImpl.kt b/review/src/main/kotlin/br/all/application/report/find/service/FindStudyReviewCriteriaServiceImpl.kt new file mode 100644 index 000000000..f1b8bc5ac --- /dev/null +++ b/review/src/main/kotlin/br/all/application/report/find/service/FindStudyReviewCriteriaServiceImpl.kt @@ -0,0 +1,60 @@ +package br.all.application.report.find.service + +import br.all.application.protocol.repository.CriterionDto +import br.all.application.protocol.repository.ProtocolRepository +import br.all.application.review.repository.SystematicStudyRepository +import br.all.application.review.repository.fromDto +import br.all.application.shared.presenter.prepareIfFailsPreconditions +import br.all.application.report.find.presenter.FindStudyReviewCriteriaPresenter +import br.all.application.study.repository.StudyReviewDto +import br.all.application.study.repository.StudyReviewRepository +import br.all.application.user.CredentialsService +import br.all.domain.model.review.SystematicStudy + +class FindStudyReviewCriteriaServiceImpl( + private val systematicStudyRepository: SystematicStudyRepository, + private val studyReviewRepository: StudyReviewRepository, + private val credentialsService: CredentialsService, + private val protocolRepository: ProtocolRepository, +): FindStudyReviewCriteriaService { + override fun findCriteria( + presenter: FindStudyReviewCriteriaPresenter, + request: FindStudyReviewCriteriaService.RequestModel + ) { + val user = credentialsService.loadCredentials(request.userId)?.toUser() + + val systematicStudyDto = systematicStudyRepository.findById(request.systematicStudyId) + val systematicStudy = systematicStudyDto?.let { SystematicStudy.fromDto(it) } + + presenter.prepareIfFailsPreconditions(user, systematicStudy) + + if(presenter.isDone()) return + + val criteriaSet = protocolRepository.findById(request.systematicStudyId)?.eligibilityCriteria?: emptyList() + + val studyReview = studyReviewRepository.findById(request.systematicStudyId, request.studyReviewId)!! + + val inclusionCriteria = getCriteriaByType(criteriaSet, studyReview, "INCLUSION") + val exclusionCriteria = getCriteriaByType(criteriaSet, studyReview, "EXCLUSION") + + val response = FindStudyReviewCriteriaService.ResponseModel( + userId = request.userId, + systematicStudyId = request.systematicStudyId, + studyReviewId = request.studyReviewId, + inclusionCriteria = inclusionCriteria, + exclusionCriteria = exclusionCriteria, + ) + + presenter.prepareSuccessView(response) + } + + private fun getCriteriaByType( + criteriaSet: Collection, + studyReview: StudyReviewDto, + type: String + ): Set { + return criteriaSet + .filter { it.type == type && it.description in studyReview.criteria } + .map { it.description }.toSet() + } +} \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/application/study/update/implementation/BatchAnswerQuestionServiceImpl.kt b/review/src/main/kotlin/br/all/application/study/update/implementation/BatchAnswerQuestionServiceImpl.kt new file mode 100644 index 000000000..724075ecd --- /dev/null +++ b/review/src/main/kotlin/br/all/application/study/update/implementation/BatchAnswerQuestionServiceImpl.kt @@ -0,0 +1,141 @@ +package br.all.application.study.update.implementation + +import br.all.application.question.repository.QuestionRepository +import br.all.application.question.repository.fromDto +import br.all.application.review.repository.SystematicStudyRepository +import br.all.application.review.repository.fromDto +import br.all.application.shared.exceptions.EntityNotFoundException +import br.all.application.shared.presenter.prepareIfFailsPreconditions +import br.all.application.study.repository.StudyReviewRepository +import br.all.application.study.repository.fromDto +import br.all.application.study.repository.toDto +import br.all.application.study.update.interfaces.BatchAnswerQuestionPresenter +import br.all.application.study.update.interfaces.BatchAnswerQuestionService +import br.all.application.study.update.interfaces.BatchAnswerQuestionService.RequestModel +import br.all.application.study.update.interfaces.BatchAnswerQuestionService.RequestModel.AnswerDetail +import br.all.application.study.update.interfaces.BatchAnswerQuestionService.ResponseModel +import br.all.application.study.update.interfaces.BatchAnswerQuestionService.FailedAnswer +import br.all.application.study.update.interfaces.BatchAnswerQuestionService.LabelDto +import br.all.application.user.CredentialsService +import br.all.domain.model.question.Label +import br.all.domain.model.question.LabeledScale +import br.all.domain.model.question.NumberScale +import br.all.domain.model.question.PickList +import br.all.domain.model.question.Question +import br.all.domain.model.question.QuestionContextEnum +import br.all.domain.model.question.Textual +import br.all.domain.model.review.SystematicStudy +import br.all.domain.model.study.Answer +import br.all.domain.model.study.StudyReview +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +class BatchAnswerQuestionServiceImpl( + private val studyReviewRepository: StudyReviewRepository, + private val questionRepository: QuestionRepository, + private val systematicStudyRepository: SystematicStudyRepository, + private val credentialsService: CredentialsService, +): BatchAnswerQuestionService { + + @Transactional + override fun batchAnswerQuestion( + presenter: BatchAnswerQuestionPresenter, + request: RequestModel, + context: String + ) { + val user = credentialsService.loadCredentials(request.userId)?.toUser() + + val systematicStudyDto = systematicStudyRepository.findById(request.systematicStudyId) + val systematicStudy = systematicStudyDto?.let { SystematicStudy.fromDto(it) } + + presenter.prepareIfFailsPreconditions(user, systematicStudy) + if (presenter.isDone()) return + + val reviewDto = studyReviewRepository.findById(request.systematicStudyId, request.studyReviewId) + if (reviewDto == null) { + val message = "Review with id ${request.studyReviewId} in systematic study ${request.systematicStudyId} does not exist!" + presenter.prepareFailView(EntityNotFoundException(message)) + return + } + val review = StudyReview.fromDto(reviewDto) + + val questionContext = QuestionContextEnum.valueOf(context.uppercase()) + val successfulQuestionIds = mutableListOf() + val failedAnswers = mutableListOf() + + for (answerDetail in request.answers) { + try { + val questionDto = questionRepository.findById(request.systematicStudyId, answerDetail.questionId) + + if (questionDto == null) throw EntityNotFoundException("Question with id ${answerDetail.questionId} in systematic study ${request.systematicStudyId} was not found!") + if (questionDto.context != questionContext) throw IllegalArgumentException("Should answer question with the context: $context, found: ${questionDto.context}") + + val question = Question.fromDto(questionDto) + val answer = convertAnswer(question, answerDetail, questionDto.questionType) + + if (questionDto.context == QuestionContextEnum.ROB) { + review.answerQualityQuestionOf(answer) + } else { + review.answerFormQuestionOf(answer) + } + + successfulQuestionIds.add(answerDetail.questionId) + } catch (e: Exception) { + failedAnswers.add( + FailedAnswer( + questionId = answerDetail.questionId, + reason = e.message ?: "An unknown error occurred!" + ) + ) + } + } + + studyReviewRepository.saveOrUpdate(review.toDto()) + + presenter.prepareSuccessView( + ResponseModel( + userId = request.userId, + systematicStudyId = request.systematicStudyId, + studyReviewId = request.studyReviewId, + succeededAnswers = successfulQuestionIds, + failedAnswers = failedAnswers, + totalAnswered = successfulQuestionIds.size + ) + ) + } + + private fun convertAnswer( + question: Question<*>, + detail: AnswerDetail, + questionType: String + ): Answer<*> { + if (detail.type != questionType) { + throw IllegalArgumentException("Type mismatch: Request payload type is '${detail.type}', but question ${question.id} is of type '${questionType}'") + } + return when { + questionType == "TEXTUAL" && detail.answer is String -> (question as Textual).answer(detail.answer) + questionType == "PICK_LIST" && detail.answer is String -> (question as PickList).answer(detail.answer) + questionType == "NUMBERED_SCALE" && detail.answer is Int -> (question as NumberScale).answer(detail.answer) + questionType == "LABELED_SCALE" -> { + when (val answer = detail.answer) { + is LinkedHashMap<*, *> -> { + (answer["name"] as? String)?.let { name -> + (answer["value"] as? Int)?.let { value -> + (question as LabeledScale).answer(Label(name, value)) + } + } ?: throw IllegalArgumentException("Invalid labeled scale answer: missing 'name' or 'value'") + } + is LabelDto -> { + (question as LabeledScale).answer(Label(answer.name, answer.value)) + } + else -> { + throw IllegalArgumentException("Unsupported answer type for 'LABELED_SCALE'") + } + } + } + else -> throw IllegalArgumentException("Answer type of '${detail.answer.javaClass}' is not compatible with question type '${questionType}'") + } + } +} \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/application/study/update/implementation/RemoveCriteriaServiceImpl.kt b/review/src/main/kotlin/br/all/application/study/update/implementation/RemoveCriteriaServiceImpl.kt new file mode 100644 index 000000000..986bf2327 --- /dev/null +++ b/review/src/main/kotlin/br/all/application/study/update/implementation/RemoveCriteriaServiceImpl.kt @@ -0,0 +1,70 @@ +package br.all.application.study.update.implementation + +import br.all.application.review.repository.SystematicStudyRepository +import br.all.application.review.repository.fromDto +import br.all.application.shared.exceptions.EntityNotFoundException +import br.all.application.shared.presenter.prepareIfFailsPreconditions +import br.all.application.study.repository.StudyReviewRepository +import br.all.application.study.repository.fromDto +import br.all.application.study.repository.toDto +import br.all.application.study.update.interfaces.RemoveCriteriaPresenter +import br.all.application.study.update.interfaces.RemoveCriteriaService +import br.all.application.user.CredentialsService +import br.all.domain.model.protocol.Criterion +import br.all.domain.model.review.SystematicStudy +import br.all.domain.model.study.StudyReview + +class RemoveCriteriaServiceImpl( + private val studyReviewRepository: StudyReviewRepository, + private val systematicStudyRepository: SystematicStudyRepository, + private val credentialsService: CredentialsService, +): RemoveCriteriaService { + override fun removeCriteria(presenter: RemoveCriteriaPresenter, request: RemoveCriteriaService.RequestModel) { + val user = credentialsService.loadCredentials(request.userId)?.toUser() + + val systematicStudyDto = systematicStudyRepository.findById(request.systematicStudyId) + val systematicStudy = systematicStudyDto?.let { SystematicStudy.fromDto(it) } + + presenter.prepareIfFailsPreconditions(user, systematicStudy) + if (presenter.isDone()) return + + val studyReviewDto = studyReviewRepository.findById(request.systematicStudyId, request.studyId) + if (studyReviewDto == null) { + presenter.prepareFailView( + EntityNotFoundException("Study review of id ${request.systematicStudyId} not found.") + ) + return + } + + val studyReview = StudyReview.fromDto(studyReviewDto) + + val inclusionCriteria: List = studyReview.criteria + .filter { it.type == Criterion.CriterionType.INCLUSION } + .map { it.description } + + val exclusionCriteria: List = studyReview.criteria + .filter { it.type == Criterion.CriterionType.EXCLUSION } + .map { it.description } + + request.criteria + .filter { it.isNotBlank() } + .forEach { criterionString -> + studyReview.criteria + .filter { it.description == criterionString } + .forEach { matching -> + studyReview.removeCriterion(matching) + } + } + + studyReviewRepository.saveOrUpdate(studyReview.toDto()) + + presenter.prepareSuccessView( + RemoveCriteriaService.ResponseModel( + request.systematicStudyId, + request.studyId, + inclusionCriteria, + exclusionCriteria, + ) + ) + } +} \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/application/study/update/interfaces/BatchAnswerQuestionPresenter.kt b/review/src/main/kotlin/br/all/application/study/update/interfaces/BatchAnswerQuestionPresenter.kt new file mode 100644 index 000000000..627d35bb8 --- /dev/null +++ b/review/src/main/kotlin/br/all/application/study/update/interfaces/BatchAnswerQuestionPresenter.kt @@ -0,0 +1,6 @@ +package br.all.application.study.update.interfaces + +import br.all.application.shared.presenter.GenericPresenter +import br.all.application.study.update.interfaces.BatchAnswerQuestionService.ResponseModel + +interface BatchAnswerQuestionPresenter: GenericPresenter \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/application/study/update/interfaces/BatchAnswerQuestionService.kt b/review/src/main/kotlin/br/all/application/study/update/interfaces/BatchAnswerQuestionService.kt new file mode 100644 index 000000000..36c6299d1 --- /dev/null +++ b/review/src/main/kotlin/br/all/application/study/update/interfaces/BatchAnswerQuestionService.kt @@ -0,0 +1,41 @@ +package br.all.application.study.update.interfaces + +import io.swagger.v3.oas.annotations.media.Schema +import java.util.UUID + +interface BatchAnswerQuestionService { + fun batchAnswerQuestion(presenter: BatchAnswerQuestionPresenter, request: RequestModel, context: String) + + data class RequestModel( + val userId: UUID, + val systematicStudyId: UUID, + val studyReviewId: Long, + val answers: List + ) { + data class AnswerDetail( + val questionId: UUID, + val type: String, + val answer: Any + ) + } + + data class FailedAnswer( + val questionId: UUID, + val reason: String + ) + + data class LabelDto( + val name: String, + val value: Int + ) + + @Schema(name = "BatchAnswerQuestionServiceResponseModel") + data class ResponseModel( + val userId: UUID, + val systematicStudyId: UUID, + val studyReviewId: Long, + val succeededAnswers: List, + val failedAnswers: List, + val totalAnswered: Int + ) +} \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/application/study/update/interfaces/RemoveCriteriaPresenter.kt b/review/src/main/kotlin/br/all/application/study/update/interfaces/RemoveCriteriaPresenter.kt new file mode 100644 index 000000000..ab24adad1 --- /dev/null +++ b/review/src/main/kotlin/br/all/application/study/update/interfaces/RemoveCriteriaPresenter.kt @@ -0,0 +1,6 @@ +package br.all.application.study.update.interfaces + +import br.all.application.shared.presenter.GenericPresenter + + +interface RemoveCriteriaPresenter: GenericPresenter \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/application/study/update/interfaces/RemoveCriteriaService.kt b/review/src/main/kotlin/br/all/application/study/update/interfaces/RemoveCriteriaService.kt new file mode 100644 index 000000000..1f4923842 --- /dev/null +++ b/review/src/main/kotlin/br/all/application/study/update/interfaces/RemoveCriteriaService.kt @@ -0,0 +1,22 @@ +package br.all.application.study.update.interfaces + +import java.util.* + + +interface RemoveCriteriaService { + fun removeCriteria(presenter: RemoveCriteriaPresenter, request: RequestModel) + + data class RequestModel( + val userId: UUID, + val systematicStudyId: UUID, + val studyId: Long, + val criteria: List + ) + + data class ResponseModel( + val systematicStudyId: UUID, + val studyId: Long, + val inclusionCriteria: List, + val exclusionCriteria: List, + ) +} diff --git a/review/src/main/kotlin/br/all/domain/shared/valueobject/Email.kt b/review/src/main/kotlin/br/all/domain/shared/valueobject/Email.kt index fa3f91af0..aa332b362 100644 --- a/review/src/main/kotlin/br/all/domain/shared/valueobject/Email.kt +++ b/review/src/main/kotlin/br/all/domain/shared/valueobject/Email.kt @@ -7,7 +7,7 @@ data class Email(val email: String) : ValueObject() { init { val notification = validate() - require(notification.hasNoErrors()) {notification.message()} + require(notification.hasNoErrors()) { notification.message() } } override fun validate(): Notification { @@ -17,45 +17,50 @@ data class Email(val email: String) : ValueObject() { notification.addError("Email must not be empty.") return notification } + if (email.isBlank()) { notification.addError("Email must not be blank.") return notification } + if (!isValidEmailFormat(email)) notification.addError("Wrong Email format.") + return notification } private fun isValidEmailFormat(email: String): Boolean { - if (!isValidEmailAddress(email)) return false - if (!HasLengthBelowMaximum(email)) return false - if (hasRepeatedSubdomains(email)) return false - if (email.contains("..")) return false - if (email.contains("@.")) return false - if (email.contains(".@")) return false - return true - } - - private fun isValidEmailAddress(email: String): Boolean { - val regex = Regex("^[A-Za-z0-9+_.-]+@[a-z.]+$") - return regex.matches(email) - } + if (email.contains("..") || email.contains(".@") || email.contains("@.")) return false + if (email.startsWith(".") || email.endsWith(".")) return false + if (email.startsWith("@") || email.endsWith("@")) return false - fun hasRepeatedSubdomains(email: String): Boolean { val parts = email.split("@") + if (parts.size != 2) return false + + val localPart = parts[0] + val domainPart = parts[1] - if (parts.size == 2) { - val subdomains = parts[1].split(".") - val verifyedSubdomains = HashSet() + if (!hasValidLength(localPart, domainPart)) return false + if (!isValidStructure(localPart, domainPart)) return false - for (subdomain in subdomains){ - if (!verifyedSubdomains.add(subdomain)) return true - } + return true + } + + private fun isValidStructure(localPart: String, domainPart: String): Boolean { + val localRegex = Regex("^[A-Za-z0-9_!#$%&'*+/=?`{|}~^.-]+$") + val domainRegex = Regex("^[A-Za-z0-9.-]+$") + + val domainLabels = domainPart.split(".") + if (domainLabels.last().length < 2 || domainLabels.any { it.startsWith("-") || it.endsWith("-") }) { + return false } - return false + + return localRegex.matches(localPart) && domainRegex.matches(domainPart) } - fun HasLengthBelowMaximum(email: String): Boolean { - val parts = email.split("@") - return !(parts[0].length > 64 || parts[1].length > 255) + private fun hasValidLength(localPart: String, domainPart: String): Boolean { + if (localPart.length > 64) return false + if (domainPart.length > 255) return false + if ((localPart.length + 1 + domainPart.length) > 254) return false + return true } } \ No newline at end of file diff --git a/review/src/test/kotlin/br/all/application/protocol/find/GetProtocolStageServiceImplTest.kt b/review/src/test/kotlin/br/all/application/protocol/find/GetProtocolStageServiceImplTest.kt new file mode 100644 index 000000000..37c359294 --- /dev/null +++ b/review/src/test/kotlin/br/all/application/protocol/find/GetProtocolStageServiceImplTest.kt @@ -0,0 +1,322 @@ +package br.all.application.protocol.find + +import br.all.application.protocol.repository.ProtocolRepository +import br.all.application.protocol.util.TestDataFactory as ProtocolFactory +import br.all.application.study.util.TestDataFactory as StudyReviewFactory +import br.all.application.question.util.TestDataFactory as QuestionFactory +import br.all.application.review.repository.SystematicStudyRepository +import br.all.application.study.repository.StudyReviewRepository +import br.all.application.user.CredentialsService +import br.all.application.util.PreconditionCheckerMockingNew +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.extension.ExtendWith +import java.util.UUID +import kotlin.test.Test +import br.all.application.protocol.find.GetProtocolStageService.RequestModel +import br.all.application.protocol.find.GetProtocolStageService.ResponseModel +import br.all.application.protocol.find.GetProtocolStageService.ProtocolStage +import br.all.application.protocol.repository.CriterionDto +import br.all.application.protocol.repository.PicocDto +import br.all.application.question.repository.QuestionRepository +import br.all.domain.model.question.QuestionContextEnum +import io.mockk.verify + +@Tag("UnitTest") +@Tag("ServiceTest") +@ExtendWith(MockKExtension::class) +class GetProtocolStageServiceImplTest { + + @MockK(relaxUnitFun = true) + private lateinit var protocolRepository: ProtocolRepository + + @MockK(relaxUnitFun = true) + private lateinit var systematicStudyRepository: SystematicStudyRepository + + @MockK(relaxUnitFun = true) + private lateinit var studyReviewRepository: StudyReviewRepository + + @MockK(relaxUnitFun = true) + private lateinit var questionRepository: QuestionRepository + + @MockK + private lateinit var credentialsService: CredentialsService + + @MockK(relaxUnitFun = true) + private lateinit var presenter: GetProtocolStagePresenter + + @InjectMockKs + private lateinit var sut: GetProtocolStageServiceImpl + + private lateinit var precondition: PreconditionCheckerMockingNew + private lateinit var protocolFactory: ProtocolFactory + private lateinit var studyReviewFactory: StudyReviewFactory + private lateinit var questionFactory: QuestionFactory + + private lateinit var researcherId: UUID + private lateinit var systematicStudyId: UUID + + @BeforeEach + fun setup() { + protocolFactory = ProtocolFactory() + studyReviewFactory = StudyReviewFactory() + questionFactory = QuestionFactory() + + researcherId = protocolFactory.researcher + systematicStudyId = protocolFactory.systematicStudy + + precondition = PreconditionCheckerMockingNew( + presenter, + credentialsService, + systematicStudyRepository, + researcherId, + systematicStudyId + ) + + precondition.makeEverythingWork() + } + + @Nested + @DisplayName("When successfully getting protocol's current stage") + inner class SuccessfullyGettingProtocolStage { + @Test + fun `should return PROTOCOL_PART_I stage when goal and justification are empty`() { + val protocolDto = protocolFactory.protocolDto( + systematicStudy = systematicStudyId, + goal = null, + justification = null + ) + + every { protocolRepository.findById(systematicStudyId) } returns protocolDto + every { studyReviewRepository.findAllFromReview(systematicStudyId) } returns emptyList() + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.ROB) } returns emptyList() + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.EXTRACTION) } returns emptyList() + + val request = RequestModel(researcherId, systematicStudyId) + + sut.getStage(presenter, request) + + val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.PROTOCOL_PART_I) + verify(exactly = 1) { presenter.prepareSuccessView(expectedResponse) } + } + + @Test + fun `should return PICOC stage when it is not filled`() { + val protocolDto = protocolFactory.protocolDto( + systematicStudy = systematicStudyId, + goal = "A goal", + justification = "A justification", + picoc = PicocDto( + population = "P", + intervention = null, + control = null, + outcome = null, + context = null + ) + ) + + every { protocolRepository.findById(systematicStudyId) } returns protocolDto + every { studyReviewRepository.findAllFromReview(systematicStudyId) } returns emptyList() + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.ROB) } returns emptyList() + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.EXTRACTION) } returns emptyList() + + val request = RequestModel(researcherId, systematicStudyId) + + sut.getStage(presenter, request) + + val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.PICOC) + verify(exactly = 1) { presenter.prepareSuccessView(expectedResponse) } + } + + @Test + fun `should return PROTOCOL_PART_II stage when its fields are empty`() { + val protocolDto = protocolFactory.protocolDto( + systematicStudy = systematicStudyId, + goal = "A goal", + justification = "A justification", + picoc = PicocDto(population = "P", intervention = "I", control = "C", outcome = "O", context = "C"), + studiesLanguages = emptySet(), + eligibilityCriteria = emptySet(), + informationSources = emptySet(), + keywords = emptySet() + ) + + every { protocolRepository.findById(systematicStudyId) } returns protocolDto + every { studyReviewRepository.findAllFromReview(systematicStudyId) } returns emptyList() + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.ROB) } returns emptyList() + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.EXTRACTION) } returns emptyList() + + val request = RequestModel(researcherId, systematicStudyId) + + sut.getStage(presenter, request) + + val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.PROTOCOL_PART_II) + verify(exactly = 1) { presenter.prepareSuccessView(expectedResponse) } + } + + @Test + fun `should return PROTOCOL_PART_III stage when its conditions are not met`() { + val protocolDto = protocolFactory.protocolDto( + systematicStudy = systematicStudyId, + goal = "A goal", + justification = "A justification", + picoc = PicocDto(population = "P", intervention = "I", control = "C", outcome = "O", context = "C"), + keywords = setOf("test"), + extractionQuestions = emptySet() + ) + + every { protocolRepository.findById(systematicStudyId) } returns protocolDto + every { studyReviewRepository.findAllFromReview(systematicStudyId) } returns emptyList() + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.ROB) } returns emptyList() + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.EXTRACTION) } returns emptyList() + + val request = RequestModel(researcherId, systematicStudyId) + + sut.getStage(presenter, request) + + val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.PROTOCOL_PART_III) + verify(exactly = 1) { presenter.prepareSuccessView(expectedResponse) } + } + + @Test + fun `should return IDENTIFICATION stage when no studies have been submitted`() { + val protocolDto = createFullProtocolDto() + val questions = listOf( + questionFactory.generateTextualDto() + ) + + every { protocolRepository.findById(systematicStudyId) } returns protocolDto + every { studyReviewRepository.findAllFromReview(systematicStudyId) } returns emptyList() + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.ROB) } returns questions + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.EXTRACTION) } returns questions + + val request = RequestModel(researcherId, systematicStudyId) + + sut.getStage(presenter, request) + + val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.IDENTIFICATION) + verify(exactly = 1) { presenter.prepareSuccessView(expectedResponse) } + } + + @Test + fun `should return SELECTION stage when studies exist but none are included`() { + val protocolDto = createFullProtocolDto() + val studies = listOf( + createFullStudyReviewDto(selectionStatus = "", extractionStatus = "") + ) + val questions = listOf( + questionFactory.generateTextualDto() + ) + + every { protocolRepository.findById(systematicStudyId) } returns protocolDto + every { studyReviewRepository.findAllFromReview(systematicStudyId) } returns studies + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.ROB) } returns questions + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.EXTRACTION) } returns questions + + val request = RequestModel(researcherId, systematicStudyId) + + sut.getStage(presenter, request) + + val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.SELECTION) + verify(exactly = 1) { presenter.prepareSuccessView(expectedResponse) } + } + + @Test + fun `should return EXTRACTION stage when studies are included but none are extracted`() { + val protocolDto = createFullProtocolDto() + val studies = listOf( + createFullStudyReviewDto(selectionStatus = "INCLUDED", extractionStatus = "") + ) + val questions = listOf( + questionFactory.generateTextualDto() + ) + + every { protocolRepository.findById(systematicStudyId) } returns protocolDto + every { studyReviewRepository.findAllFromReview(systematicStudyId) } returns studies + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.ROB) } returns questions + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.EXTRACTION) } returns questions + + val request = RequestModel(researcherId, systematicStudyId) + + sut.getStage(presenter, request) + + val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.EXTRACTION) + verify(exactly = 1) { presenter.prepareSuccessView(expectedResponse) } + } + + @Test + fun `should return GRAPHICS stage when studies have been extracted`() { + val protocolDto = createFullProtocolDto() + val studies = listOf( + createFullStudyReviewDto(selectionStatus = "INCLUDED", extractionStatus = "INCLUDED") + ) + val questions = listOf( + questionFactory.generateTextualDto() + ) + + every { protocolRepository.findById(systematicStudyId) } returns protocolDto + every { studyReviewRepository.findAllFromReview(systematicStudyId) } returns studies + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.ROB) } returns questions + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.EXTRACTION) } returns questions + + val request = RequestModel(researcherId, systematicStudyId) + + sut.getStage(presenter, request) + + val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.GRAPHICS) + verify(exactly = 1) { presenter.prepareSuccessView(expectedResponse) } + } + } + + + private fun createFullProtocolDto() = protocolFactory.protocolDto( + systematicStudy = systematicStudyId, + goal = "A goal", + justification = "A justification", + picoc = PicocDto(population = "P", intervention = "I", control = "C", outcome = "O", context = "C"), + studiesLanguages = setOf("English"), + eligibilityCriteria = setOf( + CriterionDto("test description", "INCLUSION"), + CriterionDto("test description", "EXCLUSION") + ), + informationSources = setOf("IEEE"), + keywords = setOf("test"), + sourcesSelectionCriteria = "criteria", + searchMethod = "method", + selectionProcess = "process", + extractionQuestions = setOf(UUID.randomUUID()), + robQuestions = setOf(UUID.randomUUID()), + researchQuestions = setOf("RQ1?"), + analysisAndSynthesisProcess = "process" + ) + + private fun createFullStudyReviewDto(selectionStatus: String, extractionStatus: String) = studyReviewFactory.generateDto( + studyReviewId = 1L, + systematicStudyId = systematicStudyId, + searchSessionId = UUID.randomUUID(), + type = "type", + title = "title", + year = 2025, + authors = "authors", + venue = "venue", + abstract = "abstract", + keywords = emptySet(), + references = emptyList(), + doi = "doi", + sources = emptySet(), + criteria = emptySet(), + formAnswers = emptyMap(), + robAnswers = emptyMap(), + comments = "comments", + readingPriority = "HIGH", + extractionStatus = extractionStatus, + selectionStatus = selectionStatus, + score = 10 + ) +} diff --git a/review/src/test/kotlin/br/all/application/search/create/CreateSearchSessionServiceImplTest.kt b/review/src/test/kotlin/br/all/application/search/create/CreateSearchSessionServiceImplTest.kt index cc4a200c3..42a8eec8a 100644 --- a/review/src/test/kotlin/br/all/application/search/create/CreateSearchSessionServiceImplTest.kt +++ b/review/src/test/kotlin/br/all/application/search/create/CreateSearchSessionServiceImplTest.kt @@ -1,8 +1,7 @@ +package br.all.application.search.create import br.all.application.protocol.repository.ProtocolRepository import br.all.application.review.repository.SystematicStudyRepository -import br.all.application.search.create.CreateSearchSessionServiceImpl -import br.all.application.search.create.CreateSearchSessionPresenter import br.all.application.search.util.TestDataFactory import br.all.application.search.repository.SearchSessionRepository import br.all.application.shared.exceptions.EntityNotFoundException @@ -23,9 +22,12 @@ import io.mockk.junit5.MockKExtension import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.extension.ExtendWith import kotlin.test.Test +@Tag("UnitTest") +@Tag("ServiceTest") @ExtendWith(MockKExtension::class) class CreateSearchSessionServiceImplTest { diff --git a/review/src/test/kotlin/br/all/application/search/find/FindAllSearchSessionsBySourceServiceImplTest.kt b/review/src/test/kotlin/br/all/application/search/find/FindAllSearchSessionsBySourceServiceImplTest.kt index 43fc2453b..3ed9029cf 100644 --- a/review/src/test/kotlin/br/all/application/search/find/FindAllSearchSessionsBySourceServiceImplTest.kt +++ b/review/src/test/kotlin/br/all/application/search/find/FindAllSearchSessionsBySourceServiceImplTest.kt @@ -17,9 +17,12 @@ import io.mockk.verify import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +@Tag("UnitTest") +@Tag("ServiceTest") @ExtendWith(MockKExtension::class) class FindAllSearchSessionsBySourceServiceImplTest { diff --git a/review/src/test/kotlin/br/all/application/search/find/FindAllSearchSessionsServiceImplTest.kt b/review/src/test/kotlin/br/all/application/search/find/FindAllSearchSessionsServiceImplTest.kt index 6efa7829d..2ebb67cba 100644 --- a/review/src/test/kotlin/br/all/application/search/find/FindAllSearchSessionsServiceImplTest.kt +++ b/review/src/test/kotlin/br/all/application/search/find/FindAllSearchSessionsServiceImplTest.kt @@ -16,9 +16,12 @@ import io.mockk.verify import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +@Tag("UnitTest") +@Tag("ServiceTest") @ExtendWith(MockKExtension::class) class FindAllSearchSessionsServiceImplTest { diff --git a/review/src/test/kotlin/br/all/application/search/find/FindSearchSessionServiceImplTest.kt b/review/src/test/kotlin/br/all/application/search/find/FindSearchSessionServiceImplTest.kt index 19ce02703..fae5ee1dc 100644 --- a/review/src/test/kotlin/br/all/application/search/find/FindSearchSessionServiceImplTest.kt +++ b/review/src/test/kotlin/br/all/application/search/find/FindSearchSessionServiceImplTest.kt @@ -17,9 +17,12 @@ import io.mockk.verify import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +@Tag("UnitTest") +@Tag("ServiceTest") @ExtendWith(MockKExtension::class) class FindSearchSessionServiceImplTest { @MockK(relaxUnitFun = true) diff --git a/review/src/test/kotlin/br/all/application/search/update/UpdateSearchSessionServiceImplTest.kt b/review/src/test/kotlin/br/all/application/search/update/UpdateSearchSessionServiceImplTest.kt index 43a701154..217307515 100644 --- a/review/src/test/kotlin/br/all/application/search/update/UpdateSearchSessionServiceImplTest.kt +++ b/review/src/test/kotlin/br/all/application/search/update/UpdateSearchSessionServiceImplTest.kt @@ -16,9 +16,12 @@ import io.mockk.junit5.MockKExtension import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +@Tag("UnitTest") +@Tag("ServiceTest") @ExtendWith(MockKExtension::class) class UpdateSearchSessionServiceImplTest { diff --git a/review/src/test/kotlin/br/all/application/shared/presenter/PreconditionCheckerTest.kt b/review/src/test/kotlin/br/all/application/shared/presenter/PreconditionCheckerTest.kt index 9bb78ed4f..c69609d0a 100644 --- a/review/src/test/kotlin/br/all/application/shared/presenter/PreconditionCheckerTest.kt +++ b/review/src/test/kotlin/br/all/application/shared/presenter/PreconditionCheckerTest.kt @@ -11,9 +11,11 @@ import io.mockk.every import io.mockk.mockk import io.mockk.verify import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import java.util.* +@Tag("UnitTest") class PreconditionCheckerTest { private lateinit var presenter: GenericPresenter<*> diff --git a/review/src/test/kotlin/br/all/application/study/update/BatchAnswerQuestionServiceImplTest.kt b/review/src/test/kotlin/br/all/application/study/update/BatchAnswerQuestionServiceImplTest.kt new file mode 100644 index 000000000..829b8f8b1 --- /dev/null +++ b/review/src/test/kotlin/br/all/application/study/update/BatchAnswerQuestionServiceImplTest.kt @@ -0,0 +1,240 @@ +package br.all.application.study.update + +import br.all.application.question.repository.QuestionRepository +import br.all.application.review.repository.SystematicStudyRepository +import br.all.application.study.repository.StudyReviewRepository +import br.all.application.study.update.implementation.BatchAnswerQuestionServiceImpl +import br.all.application.study.update.interfaces.AnswerQuestionService +import br.all.application.study.update.interfaces.BatchAnswerQuestionPresenter +import br.all.application.study.util.TestDataFactory +import br.all.application.user.CredentialsService +import br.all.application.util.PreconditionCheckerMockingNew +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.extension.ExtendWith +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@Tag("UnitTest") +@Tag("ServiceTest") +@ExtendWith(MockKExtension::class) +class BatchAnswerQuestionServiceImplTest { + + @MockK(relaxed = true) + private lateinit var studyReviewRepository: StudyReviewRepository + + @MockK + private lateinit var systematicStudyRepository: SystematicStudyRepository + + @MockK + private lateinit var questionRepository: QuestionRepository + + @MockK + private lateinit var credentialsService: CredentialsService + + @MockK(relaxed = true) + private lateinit var presenter: BatchAnswerQuestionPresenter + + private lateinit var sut: BatchAnswerQuestionServiceImpl + private lateinit var factory: TestDataFactory + private lateinit var preconditionCheckerMocking: PreconditionCheckerMockingNew + private lateinit var questionId: UUID + + @BeforeEach + fun setUp() { + factory = TestDataFactory() + preconditionCheckerMocking = PreconditionCheckerMockingNew( + presenter, + credentialsService, + systematicStudyRepository, + factory.researcherId, + factory.systematicStudyId + ) + sut = BatchAnswerQuestionServiceImpl( + studyReviewRepository, + questionRepository, + systematicStudyRepository, + credentialsService + ) + questionId = UUID.randomUUID() + } + + @Nested + @DisplayName("When successfully answering questions") + inner class WhenSuccessfullyAnsweringQuestions { + + @Test + fun `should successfully answer multiple questions of different types`() { + val reviewDto = factory.generateDto() + val context = "EXTRACTION" + + val textualQId = UUID.randomUUID() + val textualQDto = factory.generateQuestionTextualDto(textualQId, factory.systematicStudyId, questionContext = context) + val textualAnswer = factory.answerDetail(questionId = textualQId, type = "TEXTUAL", answer = "Valid textual answer") + + val numberedQId = UUID.randomUUID() + val numberedQDto = factory.generateQuestionNumberedScaleDto(numberedQId, factory.systematicStudyId, questionContext = context, higher = 5, lower = 1) + val numberedAnswer = factory.answerDetail(questionId = numberedQId, type = "NUMBERED_SCALE", answer = 5) + + val pickListQId = UUID.randomUUID() + val pickListQDto = factory.generateQuestionPickListDto(pickListQId, factory.systematicStudyId, options = listOf("A", "B"), questionContext = context) + val pickListAnswer = factory.answerDetail(questionId = pickListQId, type = "PICK_LIST", answer = "A") + + val request = factory.batchAnswerRequest(listOf(textualAnswer, numberedAnswer, pickListAnswer)) + + preconditionCheckerMocking.makeEverythingWork() + + every { studyReviewRepository.findById(request.systematicStudyId, request.studyReviewId) } returns reviewDto + every { questionRepository.findById(request.systematicStudyId, textualQId) } returns textualQDto + every { questionRepository.findById(request.systematicStudyId, numberedQId) } returns numberedQDto + every { questionRepository.findById(request.systematicStudyId, pickListQId) } returns pickListQDto + + sut.batchAnswerQuestion(presenter, request, context) + + verify(exactly = 1) { studyReviewRepository.saveOrUpdate(any()) } + verify(exactly = 1) { + presenter.prepareSuccessView(withArg { response -> + assertEquals(3, response.succeededAnswers.size) + assertTrue(response.failedAnswers.isEmpty()) + assertEquals(3, response.totalAnswered) + assertTrue(response.succeededAnswers.containsAll(listOf(textualQId, numberedQId, pickListQId))) + }) + } + } + + @Test + fun `should handle a mix of successful and failed answers`() { + val reviewDto = factory.generateDto() + val context = "ROB" + + val successQId = UUID.randomUUID() + val successQDto = factory.generateQuestionTextualDto(successQId, factory.systematicStudyId, questionContext = context) + val successAnswer = factory.answerDetail(questionId = successQId, type = "TEXTUAL", answer = "This will work") + + val notFoundQId = UUID.randomUUID() + val notFoundAnswer = factory.answerDetail(questionId = notFoundQId, type = "TEXTUAL", answer = "This will fail") + + val wrongContextQId = UUID.randomUUID() + val wrongContextQDto = factory.generateQuestionTextualDto(wrongContextQId, factory.systematicStudyId, questionContext = "EXTRACTION") + val wrongContextAnswer = factory.answerDetail(questionId = wrongContextQId, type = "TEXTUAL", answer = "Wrong context") + + val request = factory.batchAnswerRequest(listOf(successAnswer, notFoundAnswer, wrongContextAnswer)) + + preconditionCheckerMocking.makeEverythingWork() + + every { studyReviewRepository.findById(request.systematicStudyId, request.studyReviewId) } returns reviewDto + every { questionRepository.findById(request.systematicStudyId, successQId) } returns successQDto + every { questionRepository.findById(request.systematicStudyId, notFoundQId) } returns null + every { questionRepository.findById(request.systematicStudyId, wrongContextQId) } returns wrongContextQDto + + sut.batchAnswerQuestion(presenter, request, context) + + verify(exactly = 1) { studyReviewRepository.saveOrUpdate(any()) } + verify(exactly = 1) { + presenter.prepareSuccessView(withArg { response -> + assertEquals(1, response.succeededAnswers.size) + assertEquals(2, response.failedAnswers.size) + assertEquals(1, response.totalAnswered) + assertTrue(response.succeededAnswers.contains(successQId)) + assertTrue(response.failedAnswers.any { it.questionId == notFoundQId }) + assertTrue(response.failedAnswers.any { it.questionId == wrongContextQId }) + }) + } + } + } + + @Nested + @DisplayName("When failing to answer questions") + inner class WhenFailingToAnswerQuestions { + @Test + fun `should create a failed answer entry for a question with conflicting types`() { + val reviewDto = factory.generateDto() + val context = "ROB" + val questionId = UUID.randomUUID() + val questionDto = factory.generateQuestionTextualDto(questionId, factory.systematicStudyId, questionContext = context) + + val answerDetail = factory.answerDetail(questionId, "PICK_LIST", "Some answer") + val request = factory.batchAnswerRequest(listOf(answerDetail)) + + preconditionCheckerMocking.makeEverythingWork() + every { studyReviewRepository.findById(any(), any()) } returns reviewDto + every { questionRepository.findById(any(), questionId) } returns questionDto + + sut.batchAnswerQuestion(presenter, request, context) + + verify(exactly = 1) { + presenter.prepareSuccessView(withArg { response -> + assertTrue(response.succeededAnswers.isEmpty()) + assertEquals(1, response.failedAnswers.size) + assertEquals(questionId, response.failedAnswers.first().questionId) + assertTrue("Type mismatch" in response.failedAnswers.first().reason) + }) + } + } + + @Test + fun `should create a failed answer entry when answer value type is incompatible`() { + val reviewDto = factory.generateDto() + val context = "EXTRACTION" + val questionId = UUID.randomUUID() + val questionDto = factory.generateQuestionTextualDto(questionId, factory.systematicStudyId, questionContext = context) + + val answerDetail = factory.answerDetail(questionId, "TEXTUAL", 12345) + val request = factory.batchAnswerRequest(listOf(answerDetail)) + + preconditionCheckerMocking.makeEverythingWork() + + every { studyReviewRepository.findById(any(), any()) } returns reviewDto + every { questionRepository.findById(any(), questionId) } returns questionDto + + sut.batchAnswerQuestion(presenter, request, context) + + verify(exactly = 1) { + presenter.prepareSuccessView(withArg { response -> + assertTrue(response.succeededAnswers.isEmpty()) + assertEquals(1, response.failedAnswers.size) + assertEquals(questionId, response.failedAnswers.first().questionId) + assertTrue("is not compatible with question type" in response.failedAnswers.first().reason) + }) + } + } + + @Test + fun `should create a failed answer entry for unsupported labeled scale answer type`() { + val reviewDto = factory.generateDto() + val context = "ROB" + val questionId = UUID.randomUUID() + val labelDto = AnswerQuestionService.LabelDto( + "LabelTest", + 1 + ) + val questionDto = factory.generateQuestionLabeledScaleDto(questionId, factory.systematicStudyId, questionContext = context, labelDto = labelDto) + + val answerDetail = factory.answerDetail(questionId, "LABELED_SCALE", "invalid answer format") + val request = factory.batchAnswerRequest(listOf(answerDetail)) + + preconditionCheckerMocking.makeEverythingWork() + every { studyReviewRepository.findById(any(), any()) } returns reviewDto + every { questionRepository.findById(any(), questionId) } returns questionDto + + sut.batchAnswerQuestion(presenter, request, context) + + verify(exactly = 1) { + presenter.prepareSuccessView(withArg { response -> + assertTrue(response.succeededAnswers.isEmpty()) + assertEquals(1, response.failedAnswers.size) + assertEquals(questionId, response.failedAnswers.first().questionId) + assertTrue("Unsupported answer type for 'LABELED_SCALE'" in response.failedAnswers.first().reason) + }) + } + } + } +} \ No newline at end of file diff --git a/review/src/test/kotlin/br/all/application/study/util/TestDataFactory.kt b/review/src/test/kotlin/br/all/application/study/util/TestDataFactory.kt index ad2554d20..63640d153 100644 --- a/review/src/test/kotlin/br/all/application/study/util/TestDataFactory.kt +++ b/review/src/test/kotlin/br/all/application/study/util/TestDataFactory.kt @@ -220,6 +220,27 @@ class TestDataFactory { QuestionContextEnum.valueOf(questionContext), ) + fun batchAnswerRequest( + answers: List, + researcherId: UUID = this.researcherId, + systematicStudyId: UUID = this.systematicStudyId, + studyReviewId: Long = this.studyReviewId, + ) = BatchAnswerQuestionService.RequestModel( + userId = researcherId, + systematicStudyId = systematicStudyId, + studyReviewId = studyReviewId, + answers = answers + ) + + fun answerDetail( + questionId: UUID, + type: String, + answer: Any, + ) = BatchAnswerQuestionService.RequestModel.AnswerDetail( + questionId = questionId, + type = type, + answer = answer + ) operator fun component1() = researcherId operator fun component2() = studyReviewId diff --git a/review/src/test/kotlin/br/all/architecture/DomainModuleTest.kt b/review/src/test/kotlin/br/all/architecture/DomainModuleTest.kt index f985969ae..1d41e1d00 100644 --- a/review/src/test/kotlin/br/all/architecture/DomainModuleTest.kt +++ b/review/src/test/kotlin/br/all/architecture/DomainModuleTest.kt @@ -6,8 +6,9 @@ import com.tngtech.archunit.junit.AnalyzeClasses import com.tngtech.archunit.junit.ArchTest import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes +import org.junit.jupiter.api.Tag - +@Tag("ArchitectureTest") @AnalyzeClasses(packages = ["br.all"], importOptions = [ImportOption.DoNotIncludeTests::class]) class DomainModuleTest { diff --git a/review/src/test/kotlin/br/all/domain/model/search/SearchSessionTest.kt b/review/src/test/kotlin/br/all/domain/model/search/SearchSessionTest.kt index edb3eada2..dcb7de383 100644 --- a/review/src/test/kotlin/br/all/domain/model/search/SearchSessionTest.kt +++ b/review/src/test/kotlin/br/all/domain/model/search/SearchSessionTest.kt @@ -3,6 +3,7 @@ package br.all.domain.model.search import br.all.domain.model.protocol.SearchSource import br.all.domain.model.user.ResearcherId import br.all.domain.model.review.SystematicStudyId +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import java.time.LocalDateTime @@ -10,6 +11,7 @@ import java.util.* import kotlin.test.assertEquals +@Tag("UnitTest") class SearchSessionTest { diff --git a/review/src/test/kotlin/br/all/domain/model/study/DoiTest.kt b/review/src/test/kotlin/br/all/domain/model/study/DoiTest.kt index d0845449c..6655ecc1c 100644 --- a/review/src/test/kotlin/br/all/domain/model/study/DoiTest.kt +++ b/review/src/test/kotlin/br/all/domain/model/study/DoiTest.kt @@ -1,8 +1,10 @@ package br.all.domain.model.study import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Tag import kotlin.test.Test +@Tag("UnitTest") class DoiTest { @Test diff --git a/review/src/test/kotlin/br/all/domain/model/study/StudyReviewTest.kt b/review/src/test/kotlin/br/all/domain/model/study/StudyReviewTest.kt index 1a6b27d17..1cfacf2e8 100644 --- a/review/src/test/kotlin/br/all/domain/model/study/StudyReviewTest.kt +++ b/review/src/test/kotlin/br/all/domain/model/study/StudyReviewTest.kt @@ -7,10 +7,12 @@ import br.all.domain.model.search.SearchSessionID import br.all.domain.shared.utils.paragraph import br.all.domain.shared.utils.year import io.github.serpro69.kfaker.Faker +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.assertAll import java.util.* import kotlin.test.* +@Tag("UnitTest") class StudyReviewTest { private val faker = Faker() diff --git a/review/src/test/kotlin/br/all/domain/services/BibtexConverterServiceTest.kt b/review/src/test/kotlin/br/all/domain/services/BibtexConverterServiceTest.kt index 7cce5feba..7b226fa5d 100644 --- a/review/src/test/kotlin/br/all/domain/services/BibtexConverterServiceTest.kt +++ b/review/src/test/kotlin/br/all/domain/services/BibtexConverterServiceTest.kt @@ -8,6 +8,8 @@ import java.util.* import kotlin.test.assertEquals import kotlin.test.assertTrue +@Tag("UnitTest") +@Tag("ServiceTest") class BibtexConverterServiceTest { private lateinit var sut: BibtexConverterService diff --git a/review/src/test/kotlin/br/all/domain/services/ConverterFactoryServiceTest.kt b/review/src/test/kotlin/br/all/domain/services/ConverterFactoryServiceTest.kt index 3964710f2..0242ecd11 100644 --- a/review/src/test/kotlin/br/all/domain/services/ConverterFactoryServiceTest.kt +++ b/review/src/test/kotlin/br/all/domain/services/ConverterFactoryServiceTest.kt @@ -5,10 +5,13 @@ import br.all.domain.model.search.SearchSessionID import br.all.domain.services.RisTestData.testInput as risInput import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import java.util.* import kotlin.test.assertEquals +@Tag("IntegrationTest") +@Tag("ServiceTest") class ConverterFactoryServiceTest { private lateinit var sut: ConverterFactoryService private lateinit var bibtexConverterService: BibtexConverterService diff --git a/review/src/test/kotlin/br/all/domain/services/ReviewSimilarityServiceTest.kt b/review/src/test/kotlin/br/all/domain/services/ReviewSimilarityServiceTest.kt index d008b73ab..72993f0fd 100644 --- a/review/src/test/kotlin/br/all/domain/services/ReviewSimilarityServiceTest.kt +++ b/review/src/test/kotlin/br/all/domain/services/ReviewSimilarityServiceTest.kt @@ -16,6 +16,7 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue @Tag("IntegrationTest") +@Tag("ServiceTest") class ReviewSimilarityServiceTest { private lateinit var sut: ReviewSimilarityService diff --git a/review/src/test/kotlin/br/all/domain/services/RisConverterServiceTest.kt b/review/src/test/kotlin/br/all/domain/services/RisConverterServiceTest.kt index e3ecaa20d..dc1eda98d 100644 --- a/review/src/test/kotlin/br/all/domain/services/RisConverterServiceTest.kt +++ b/review/src/test/kotlin/br/all/domain/services/RisConverterServiceTest.kt @@ -9,6 +9,8 @@ import java.util.* import kotlin.test.assertEquals import kotlin.test.assertTrue +@Tag("UnitTest") +@Tag("ServiceTest") class RisConverterServiceTest { private lateinit var sut: RisConverterService private lateinit var idGeneratorService: IdGeneratorService diff --git a/review/src/test/kotlin/br/all/domain/services/ScoreCalculatorServiceTest.kt b/review/src/test/kotlin/br/all/domain/services/ScoreCalculatorServiceTest.kt index ef35805fa..228c2f31d 100644 --- a/review/src/test/kotlin/br/all/domain/services/ScoreCalculatorServiceTest.kt +++ b/review/src/test/kotlin/br/all/domain/services/ScoreCalculatorServiceTest.kt @@ -11,6 +11,7 @@ import java.util.* import kotlin.test.assertEquals @Tag("IntegrationTest") +@Tag("ServiceTest") class ScoreCalculatorServiceTest { private lateinit var sut: ScoreCalculatorService diff --git a/review/src/test/kotlin/br/all/domain/shared/ddd/EmailTest.kt b/review/src/test/kotlin/br/all/domain/shared/ddd/EmailTest.kt index 05f63ee8b..55959a529 100644 --- a/review/src/test/kotlin/br/all/domain/shared/ddd/EmailTest.kt +++ b/review/src/test/kotlin/br/all/domain/shared/ddd/EmailTest.kt @@ -1,6 +1,7 @@ package br.all.domain.shared.ddd import br.all.domain.shared.valueobject.Email +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.api.assertThrows @@ -8,6 +9,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNotEquals import kotlin.test.assertTrue +@Tag("UnitTest") class EmailTest { @Test @@ -59,19 +61,6 @@ class EmailTest { exception.message?.contains("Wrong Email format")?.let { assertTrue(it) } } - - @Test - fun `should not accept email with two equal TLDs`() { - val exception = assertThrows { Email("email@ifsp.com.com") } - exception.message?.contains("Wrong Email format")?.let { assertTrue(it) } - } - - @Test - fun `should not accept email with two equal subdomains`() { - val exception = assertThrows { Email("email@ifsp.ifsp.com") } - exception.message?.contains("Wrong Email format")?.let { assertTrue(it) } - } - @Test fun `email with incorrect format should include error message`() { val exception = assertThrows { Email("invalid-email-format") } @@ -102,7 +91,7 @@ class EmailTest { @Test fun `invalid email with special characters should throw an exception`() { - val exception = assertThrows { Email("user!name@example.com") } + val exception = assertThrows { Email("user name@example.com") } exception.message?.contains("Wrong Email format")?.let { assertTrue(it) } } @@ -149,7 +138,7 @@ class EmailTest { @Test fun `valid email with domain under maximum length should not throw an exception`() { - assertDoesNotThrow { Email("user@" + "a".repeat(251) + ".com") } + assertDoesNotThrow { Email("user@" + "a".repeat(240) + ".com") } } @Test @@ -157,6 +146,4 @@ class EmailTest { val exception = assertThrows { Email("user@domain@com") } exception.message?.contains("Wrong Email format")?.let { assertTrue(it) } } - - } diff --git a/review/src/test/kotlin/br/all/domain/shared/ddd/EntityTest.kt b/review/src/test/kotlin/br/all/domain/shared/ddd/EntityTest.kt index cc3e586c9..bbf2ae3b7 100644 --- a/review/src/test/kotlin/br/all/domain/shared/ddd/EntityTest.kt +++ b/review/src/test/kotlin/br/all/domain/shared/ddd/EntityTest.kt @@ -1,8 +1,11 @@ package br.all.domain.shared.ddd import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Tags import org.junit.jupiter.api.Test +@Tag("UnitTest") class EntityTest{ @JvmInline diff --git a/review/src/test/kotlin/br/all/domain/shared/ddd/LanguageTest.kt b/review/src/test/kotlin/br/all/domain/shared/ddd/LanguageTest.kt index aa1ae7d8b..cb82af57a 100644 --- a/review/src/test/kotlin/br/all/domain/shared/ddd/LanguageTest.kt +++ b/review/src/test/kotlin/br/all/domain/shared/ddd/LanguageTest.kt @@ -1,11 +1,13 @@ package br.all.domain.shared.ddd import br.all.domain.shared.valueobject.Language +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNotEquals +@Tag("UnitTest") class LanguageTest { @Test diff --git a/review/src/test/kotlin/br/all/domain/shared/ddd/NotificationTest.kt b/review/src/test/kotlin/br/all/domain/shared/ddd/NotificationTest.kt index 404ac1d06..37ed02b9b 100644 --- a/review/src/test/kotlin/br/all/domain/shared/ddd/NotificationTest.kt +++ b/review/src/test/kotlin/br/all/domain/shared/ddd/NotificationTest.kt @@ -1,8 +1,10 @@ package br.all.domain.shared.ddd import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test +@Tag("UnitTest") class NotificationTest { @Test fun `Should notification with no errors present no error message`(){ diff --git a/review/src/test/kotlin/br/all/domain/shared/ddd/ProtocolIdTest.kt b/review/src/test/kotlin/br/all/domain/shared/ddd/ProtocolIdTest.kt index 29153d030..facc535a6 100644 --- a/review/src/test/kotlin/br/all/domain/shared/ddd/ProtocolIdTest.kt +++ b/review/src/test/kotlin/br/all/domain/shared/ddd/ProtocolIdTest.kt @@ -3,11 +3,13 @@ package br.all.domain.shared.ddd import br.all.domain.model.protocol.ProtocolId import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import java.util.* -internal class ProtocolIdTest{ +@Tag("UnitTest") +class ProtocolIdTest{ @Test fun `valid ProtocolId`() { val uuid = UUID.randomUUID() diff --git a/review/src/test/kotlin/br/all/domain/shared/ddd/TextTest.kt b/review/src/test/kotlin/br/all/domain/shared/ddd/TextTest.kt index e77cfb994..29df084e3 100644 --- a/review/src/test/kotlin/br/all/domain/shared/ddd/TextTest.kt +++ b/review/src/test/kotlin/br/all/domain/shared/ddd/TextTest.kt @@ -5,6 +5,7 @@ import org.junit.jupiter.api.* import kotlin.test.assertEquals import kotlin.test.assertNotEquals +@Tag("UnitTest") class TextTest { @Test diff --git a/review/src/test/kotlin/br/all/domain/shared/utils/DOITest.kt b/review/src/test/kotlin/br/all/domain/shared/utils/DOITest.kt index ac3109ab1..ec2a9d83c 100644 --- a/review/src/test/kotlin/br/all/domain/shared/utils/DOITest.kt +++ b/review/src/test/kotlin/br/all/domain/shared/utils/DOITest.kt @@ -2,6 +2,7 @@ package br.all.domain.shared.utils import br.all.domain.shared.valueobject.DOI +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.api.assertThrows @@ -9,7 +10,8 @@ import kotlin.test.assertEquals import kotlin.test.assertNotEquals import kotlin.test.assertTrue -internal class DOITest { +@Tag("UnitTest") +class DOITest { @Test fun `valid DOI should not throw an exception`() { assertDoesNotThrow { DOI("10.1590/1089-6891v16i428131") } diff --git a/review/src/test/kotlin/br/all/domain/shared/utils/PhraseTest.kt b/review/src/test/kotlin/br/all/domain/shared/utils/PhraseTest.kt index 8e242cacd..6b3878563 100644 --- a/review/src/test/kotlin/br/all/domain/shared/utils/PhraseTest.kt +++ b/review/src/test/kotlin/br/all/domain/shared/utils/PhraseTest.kt @@ -1,10 +1,12 @@ package br.all.domain.shared.utils +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.api.assertThrows import kotlin.test.assertEquals +@Tag("UnitTest") class PhraseTest { @Test fun `Should accept words with only lowercase letters`() { diff --git a/review/src/test/kotlin/br/all/infrastructure/ProtocolRepositoryImplTest.kt b/review/src/test/kotlin/br/all/infrastructure/ProtocolRepositoryImplTest.kt index 5c1269c24..b281a2cc8 100644 --- a/review/src/test/kotlin/br/all/infrastructure/ProtocolRepositoryImplTest.kt +++ b/review/src/test/kotlin/br/all/infrastructure/ProtocolRepositoryImplTest.kt @@ -18,8 +18,8 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import java.util.* -@Tag("UnitTests") -@Tag("ServiceTests") +@Tag("UnitTest") +@Tag("ServiceTest") @ExtendWith(MockKExtension::class) class ProtocolRepositoryImplTest { @MockK diff --git a/scas/pom.xml b/scas/pom.xml index 14a1e5fb8..b8ddb39a2 100644 --- a/scas/pom.xml +++ b/scas/pom.xml @@ -81,7 +81,7 @@ org.jetbrains.kotlin kotlin-stdlib - 1.9.23 + 1.9.0 br.all diff --git a/test-actions.txt b/test-actions.txt deleted file mode 100644 index af8ea0e99..000000000 --- a/test-actions.txt +++ /dev/null @@ -1,2 +0,0 @@ -this is test push -testing push 2 \ No newline at end of file diff --git a/web/pom.xml b/web/pom.xml index 4d14b3ac6..5a2c513f7 100644 --- a/web/pom.xml +++ b/web/pom.xml @@ -6,7 +6,7 @@ org.springframework.boot spring-boot-starter-parent - 3.1.2 + 3.4.4 @@ -18,7 +18,7 @@ 17 - 1.8.22 + 1.9.0 @@ -38,10 +38,11 @@ org.springframework.boot spring-boot-starter-web + org.springdoc springdoc-openapi-starter-webmvc-ui - 2.3.0 + 2.8.9 org.springframework.boot diff --git a/web/src/main/kotlin/br/all/protocol/controller/ProtocolController.kt b/web/src/main/kotlin/br/all/protocol/controller/ProtocolController.kt index 4bf028a1e..8ef4aef3a 100644 --- a/web/src/main/kotlin/br/all/protocol/controller/ProtocolController.kt +++ b/web/src/main/kotlin/br/all/protocol/controller/ProtocolController.kt @@ -1,8 +1,10 @@ package br.all.protocol.controller import br.all.application.protocol.find.FindProtocolService +import br.all.application.protocol.find.GetProtocolStageService import br.all.application.protocol.update.UpdateProtocolService import br.all.protocol.presenter.RestfulFindProtocolPresenter +import br.all.protocol.presenter.RestfulGetProtocolStagePresenter import br.all.protocol.presenter.RestfulUpdateProtocolPresenter import br.all.protocol.requests.PutRequest import br.all.security.service.AuthenticationInfoService @@ -17,12 +19,14 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import java.util.* import br.all.application.protocol.find.FindProtocolService.RequestModel as FindOneRequestModel +import br.all.application.protocol.find.GetProtocolStageService.RequestModel as FindStageRequestModel @RestController @RequestMapping("/systematic-study/{systematicStudyId}/protocol") class ProtocolController( private val findProtocolService: FindProtocolService, private val updateProtocolService: UpdateProtocolService, + private val getProtocolStageService: GetProtocolStageService, private val authenticationInfoService: AuthenticationInfoService, private val linksFactory: LinksFactory ) { @@ -86,4 +90,38 @@ class ProtocolController( return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) } + @GetMapping("/stage") + @Operation(summary = "Get the current stage of the systematic review protocol") + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Success getting the protocol stage", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = GetProtocolStageService.ResponseModel::class) + )]), + ApiResponse( + responseCode = "401", + description = "Fail getting the protocol stage - unauthenticated collaborator", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "403", + description = "Fail getting the protocol stage - unauthorized collaborator", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "404", + description = "Fail getting the protocol stage - nonexistent protocol or systematic study", + content = [Content(schema = Schema(hidden = true))] + ), + ]) + fun getStage( + @PathVariable systematicStudyId: UUID + ) : ResponseEntity<*> { + val presenter = RestfulGetProtocolStagePresenter(linksFactory) + val userId = authenticationInfoService.getAuthenticatedUserId() + val request = FindStageRequestModel(userId, systematicStudyId) + + getProtocolStageService.getStage(presenter, request) + return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) + } } diff --git a/web/src/main/kotlin/br/all/protocol/controller/ProtocolServicesConfiguration.kt b/web/src/main/kotlin/br/all/protocol/controller/ProtocolServicesConfiguration.kt index 70923399e..11efb480f 100644 --- a/web/src/main/kotlin/br/all/protocol/controller/ProtocolServicesConfiguration.kt +++ b/web/src/main/kotlin/br/all/protocol/controller/ProtocolServicesConfiguration.kt @@ -1,8 +1,10 @@ package br.all.protocol.controller import br.all.application.protocol.find.FindProtocolServiceImpl +import br.all.application.protocol.find.GetProtocolStageServiceImpl import br.all.application.protocol.repository.ProtocolRepository import br.all.application.protocol.update.UpdateProtocolServiceImpl +import br.all.application.question.repository.QuestionRepository import br.all.application.review.repository.SystematicStudyRepository import br.all.application.study.repository.StudyReviewRepository import br.all.application.user.CredentialsService @@ -27,4 +29,13 @@ class ProtocolServicesConfiguration { studyReviewRepository: StudyReviewRepository, scoreCalculatorService: ScoreCalculatorService ) = UpdateProtocolServiceImpl(protocolRepository, systematicStudyRepository, credentialsService, studyReviewRepository, scoreCalculatorService) + + @Bean + fun getProtocolStageService( + protocolRepository: ProtocolRepository, + systematicStudyRepository: SystematicStudyRepository, + studyReviewRepository: StudyReviewRepository, + credentialsService: CredentialsService, + questionRepository: QuestionRepository + ) = GetProtocolStageServiceImpl(protocolRepository, systematicStudyRepository, studyReviewRepository, credentialsService, questionRepository) } diff --git a/web/src/main/kotlin/br/all/protocol/presenter/RestfulGetProtocolStagePresenter.kt b/web/src/main/kotlin/br/all/protocol/presenter/RestfulGetProtocolStagePresenter.kt new file mode 100644 index 000000000..760cb5f3f --- /dev/null +++ b/web/src/main/kotlin/br/all/protocol/presenter/RestfulGetProtocolStagePresenter.kt @@ -0,0 +1,32 @@ +package br.all.protocol.presenter + +import br.all.application.protocol.find.GetProtocolStagePresenter +import br.all.application.protocol.find.GetProtocolStageService.ResponseModel +import br.all.application.protocol.find.GetProtocolStageService.ProtocolStage +import br.all.shared.error.createErrorResponseFrom +import br.all.utils.LinksFactory +import org.springframework.hateoas.RepresentationModel +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import java.util.UUID + +class RestfulGetProtocolStagePresenter( + private val linksFactory: LinksFactory +) : GetProtocolStagePresenter { + var responseEntity: ResponseEntity<*>? = null + + override fun prepareSuccessView(response: ResponseModel) { + val viewModel = ViewModel(response.userId, response.systematicStudyId, response.currentStage) + responseEntity = ResponseEntity.status(HttpStatus.OK).body(viewModel) + } + + override fun prepareFailView(throwable: Throwable) = run { responseEntity = createErrorResponseFrom(throwable) } + + override fun isDone() = responseEntity != null + + data class ViewModel( + val researcherId: UUID, + val systematicStudyId: UUID, + val currentStage: ProtocolStage, + ) : RepresentationModel() +} \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/report/controller/ReportController.kt b/web/src/main/kotlin/br/all/report/controller/ReportController.kt index 7168c0f06..be2394a3a 100644 --- a/web/src/main/kotlin/br/all/report/controller/ReportController.kt +++ b/web/src/main/kotlin/br/all/report/controller/ReportController.kt @@ -3,6 +3,7 @@ package br.all.report.controller import br.all.application.report.find.service.* import br.all.report.presenter.* import br.all.security.service.AuthenticationInfoService +import br.all.report.presenter.RestfulFindStudyReviewCriteriaPresenter import br.all.utils.LinksFactory import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.media.Content @@ -27,6 +28,7 @@ class ReportController( private val authorNetworkService: AuthorNetworkService, private val findKeywordsService: FindKeywordsService, private val findStudiesByStageService: FindStudiesByStageService, + private val findStudyReviewCriteriaService: FindStudyReviewCriteriaService, private val exportProtocolService: ExportProtocolService, private val findAnswerService: FindAnswerServiceImpl, private val studiesFunnelService: StudiesFunnelService, @@ -350,4 +352,32 @@ class ReportController( findAnswerService.find(presenter, request) return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) } + + @GetMapping("/study-review/{studyReview}/criteria") + @Operation(summary = "Return inclusion and exclusion criteria of study review") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", description = "Success getting criteria of a review", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = FindStudyReviewCriteriaService.ResponseModel::class) + )] + ), + ApiResponse(responseCode = "401", description = "Fail getting criteria of a review - unauthenticated user", + content = [Content(schema = Schema(hidden = true))]), + ApiResponse(responseCode = "403", description = "Fail getting criteria of a review - unauthorized user", + content = [Content(schema = Schema(hidden = true))]), + ] + ) + fun findCriteria( + @PathVariable systematicStudyId: UUID, + @PathVariable studyReview: Long + ): ResponseEntity<*> { + val presenter = RestfulFindStudyReviewCriteriaPresenter(linksFactory) + val userId = authenticationInfoService.getAuthenticatedUserId() + val request = FindStudyReviewCriteriaService.RequestModel(userId, systematicStudyId, studyReview) + findStudyReviewCriteriaService.findCriteria(presenter, request) + return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) + } } \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/report/controller/ReportControllerConfiguration.kt b/web/src/main/kotlin/br/all/report/controller/ReportControllerConfiguration.kt index 5712b44cf..2edc0fc1c 100644 --- a/web/src/main/kotlin/br/all/report/controller/ReportControllerConfiguration.kt +++ b/web/src/main/kotlin/br/all/report/controller/ReportControllerConfiguration.kt @@ -137,4 +137,17 @@ class ReportControllerConfiguration { systematicStudyRepository, questionRepository ) + + @Bean + fun findStudyReviewCriteriaService( + systematicStudyRepository: SystematicStudyRepository, + studyReviewRepository: StudyReviewRepository, + credentialsService: CredentialsService, + protocolRepository: ProtocolRepository, + ) = FindStudyReviewCriteriaServiceImpl( + systematicStudyRepository, + studyReviewRepository, + credentialsService, + protocolRepository + ) } \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/report/presenter/RestfulFindStudyReviewCriteriaPresenter.kt b/web/src/main/kotlin/br/all/report/presenter/RestfulFindStudyReviewCriteriaPresenter.kt new file mode 100644 index 000000000..017ed9d37 --- /dev/null +++ b/web/src/main/kotlin/br/all/report/presenter/RestfulFindStudyReviewCriteriaPresenter.kt @@ -0,0 +1,37 @@ +package br.all.report.presenter + +import br.all.application.report.find.presenter.FindStudyReviewCriteriaPresenter +import br.all.application.report.find.service.FindStudyReviewCriteriaService +import br.all.shared.error.createErrorResponseFrom +import br.all.utils.LinksFactory +import org.springframework.hateoas.RepresentationModel +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.http.ResponseEntity.status + +class RestfulFindStudyReviewCriteriaPresenter( + private val linksFactory: LinksFactory +): FindStudyReviewCriteriaPresenter { + var responseEntity: ResponseEntity<*>? = null + + override fun prepareSuccessView(response: FindStudyReviewCriteriaService.ResponseModel) { + val restfulResponse = ViewModel( + response.exclusionCriteria, + response.inclusionCriteria + ) + + val selfRef = linksFactory.findStudyReviewCriteria(response.systematicStudyId, response.studyReviewId) + + restfulResponse.add(selfRef) + responseEntity = status(HttpStatus.OK).body(restfulResponse) + } + + override fun prepareFailView(throwable: Throwable)= run { responseEntity = createErrorResponseFrom(throwable) } + + override fun isDone() = responseEntity != null + + private data class ViewModel( + val exclusionCriteria: Set, + val inclusionCriteria: Set, + ) : RepresentationModel() +} \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/study/controller/StudyReviewController.kt b/web/src/main/kotlin/br/all/study/controller/StudyReviewController.kt index 11670b2ba..f2005b0aa 100644 --- a/web/src/main/kotlin/br/all/study/controller/StudyReviewController.kt +++ b/web/src/main/kotlin/br/all/study/controller/StudyReviewController.kt @@ -21,6 +21,9 @@ import org.springframework.web.bind.annotation.* import java.util.* import br.all.application.study.find.service.FindAllStudyReviewsBySourceService.RequestModel as FindAllBySourceRequest import br.all.application.study.find.service.FindStudyReviewService.RequestModel as FindOneRequest +import br.all.application.study.update.interfaces.BatchAnswerQuestionService +import br.all.study.presenter.RestfulBatchAnswerQuestionPresenter +import br.all.study.requests.PatchBatchAnswerQuestionStudyReviewRequest @RestController @RequestMapping("/api/v1/systematic-study/{systematicStudy}") @@ -35,8 +38,10 @@ class StudyReviewController( private val updateSelectionService: UpdateStudyReviewSelectionService, private val updateExtractionService: UpdateStudyReviewExtractionService, private val updateReadingPriorityService: UpdateStudyReviewPriorityService, + private val removeCriteriaService: RemoveCriteriaService, private val markAsDuplicatedService: MarkAsDuplicatedService, private val answerQuestionService: AnswerQuestionService, + private val batchAnswerQuestionService: BatchAnswerQuestionService, private val authenticationInfoService: AuthenticationInfoService, private val linksFactory: LinksFactory @@ -441,6 +446,86 @@ class StudyReviewController( return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) } + @PatchMapping("/study-review/{studyReview}/batch-riskOfBias-answers") + @Operation(summary = "Update a batch of answers for risk of bias questions") + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Success updating a batch of answers to risk of bias questions", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = BatchAnswerQuestionService.ResponseModel::class) + )] + ), + ApiResponse( + responseCode = "400", + description = "Fail updating answers - invalid payload", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "401", + description = "Fail updating answers - unauthenticated user", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "403", + description = "Fail updating answers - unauthorized user", + content = [Content(schema = Schema(hidden = true))] + ), + ] + ) + fun batchRiskOfBiasAnswers( + @PathVariable systematicStudy: UUID, + @PathVariable studyReview: Long, + @RequestBody requestBody: PatchBatchAnswerQuestionStudyReviewRequest + ): ResponseEntity<*> { + val presenter = RestfulBatchAnswerQuestionPresenter(linksFactory) + val userId = authenticationInfoService.getAuthenticatedUserId() + val request = requestBody.toRequestModel(userId, systematicStudy, studyReview) + + batchAnswerQuestionService.batchAnswerQuestion(presenter, request, "ROB") + return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) + } + + @PatchMapping("/study-review/{studyReview}/batch-extraction-answers") + @Operation(summary = "Update a batch of answers for extraction questions") + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Success updating a batch of answers to extraction questions", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = BatchAnswerQuestionService.ResponseModel::class) + )] + ), + ApiResponse( + responseCode = "400", + description = "Fail updating answers - invalid payload", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "401", + description = "Fail updating answers - unauthenticated user", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "403", + description = "Fail updating answers - unauthorized user", + content = [Content(schema = Schema(hidden = true))] + ), + ] + ) + fun batchExtractionAnswers( + @PathVariable systematicStudy: UUID, + @PathVariable studyReview: Long, + @RequestBody requestBody: PatchBatchAnswerQuestionStudyReviewRequest + ): ResponseEntity<*> { + val presenter = RestfulBatchAnswerQuestionPresenter(linksFactory) + val userId = authenticationInfoService.getAuthenticatedUserId() + val request = requestBody.toRequestModel(userId, systematicStudy, studyReview) + + batchAnswerQuestionService.batchAnswerQuestion(presenter, request, "EXTRACTION") + return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) + } + @PatchMapping("/study-review/{referenceStudyId}/duplicated") @Operation(summary = "Mark multiple existing studies as duplicated in the systematic study") @ApiResponses( @@ -481,4 +566,39 @@ class StudyReviewController( markAsDuplicatedService.markAsDuplicated(presenter, request) return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) } + + @PatchMapping("/study-review/remove-criteria/{studyReviewId}") + @Operation(summary = "Remove a criteria from study review") + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Success removing criteria of study review", + content = [Content(schema = Schema(hidden = true))]), + ApiResponse( + responseCode = "400", + description = "Fail removing criteria of study review - invalid status", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "401", + description = "Fail removing criteria of study review - unauthenticated user", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "403", + description = "Fail removing criteria of study review - unauthorized user", + content = [Content(schema = Schema(hidden = true))] + ), + ] + ) + fun removeCriterion( + @PathVariable systematicStudy: UUID, + @PathVariable studyReviewId: Long, + @RequestBody patchRequest: RemoveCriteriaRequest + ): ResponseEntity<*> { + val presenter = RestfulRemoveCriteriaPresenter(linksFactory) + val userId = authenticationInfoService.getAuthenticatedUserId() + val request = patchRequest.toRequestModel(userId, systematicStudy, studyReviewId) + removeCriteriaService.removeCriteria(presenter, request) + return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) + } } \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/study/controller/StudyReviewServicesConfiguration.kt b/web/src/main/kotlin/br/all/study/controller/StudyReviewServicesConfiguration.kt index 4c89d9144..ea5d2acd6 100644 --- a/web/src/main/kotlin/br/all/study/controller/StudyReviewServicesConfiguration.kt +++ b/web/src/main/kotlin/br/all/study/controller/StudyReviewServicesConfiguration.kt @@ -138,4 +138,28 @@ class StudyReviewServicesConfiguration { systematicStudyRepository, credentialsService ) + + @Bean + fun batchAnswerQuestionService( + studyReviewRepository: StudyReviewRepository, + questionRepository: QuestionRepository, + systematicStudyRepository: SystematicStudyRepository, + credentialsService: CredentialsService + ) = BatchAnswerQuestionServiceImpl( + studyReviewRepository, + questionRepository, + systematicStudyRepository, + credentialsService + ) + + @Bean + fun removeCriteriaService( + studyReviewRepository: StudyReviewRepository, + systematicStudyRepository: SystematicStudyRepository, + credentialsService: CredentialsService, + ) = RemoveCriteriaServiceImpl( + studyReviewRepository, + systematicStudyRepository, + credentialsService + ) } \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/study/presenter/RestfulBatchAnswerQuestionPresenter.kt b/web/src/main/kotlin/br/all/study/presenter/RestfulBatchAnswerQuestionPresenter.kt new file mode 100644 index 000000000..7cc8f06b2 --- /dev/null +++ b/web/src/main/kotlin/br/all/study/presenter/RestfulBatchAnswerQuestionPresenter.kt @@ -0,0 +1,48 @@ +package br.all.study.presenter + +import br.all.application.study.update.interfaces.BatchAnswerQuestionPresenter +import br.all.application.study.update.interfaces.BatchAnswerQuestionService.ResponseModel +import br.all.application.study.update.interfaces.BatchAnswerQuestionService.FailedAnswer +import br.all.shared.error.createErrorResponseFrom +import br.all.utils.LinksFactory +import org.springframework.hateoas.RepresentationModel +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import java.util.UUID + +class RestfulBatchAnswerQuestionPresenter( + private val linksFactory: LinksFactory +): BatchAnswerQuestionPresenter { + var responseEntity: ResponseEntity<*>?= null + + override fun prepareSuccessView(response: ResponseModel) { + val viewModel = ViewModel( + systematicStudyId = response.systematicStudyId, + studyReviewId = response.studyReviewId, + succeededAnswers = response.succeededAnswers, + failedAnswers = response.failedAnswers, + totalAnswered = response.totalAnswered + ) + + val link = linksFactory.findStudy(response.systematicStudyId, response.studyReviewId) + viewModel.add(link) + + responseEntity = ResponseEntity.status(HttpStatus.OK).body(viewModel) + } + + override fun prepareFailView(throwable: Throwable) { + responseEntity = createErrorResponseFrom(throwable) + } + + override fun isDone(): Boolean { + return responseEntity != null + } + + data class ViewModel( + val systematicStudyId: UUID, + val studyReviewId: Long, + val succeededAnswers: List, + val failedAnswers: List, + val totalAnswered: Int + ) : RepresentationModel() +} \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/study/presenter/RestfulFindAllStudyReviewsPresenter.kt b/web/src/main/kotlin/br/all/study/presenter/RestfulFindAllStudyReviewsPresenter.kt index 16b363b89..6aab812d7 100644 --- a/web/src/main/kotlin/br/all/study/presenter/RestfulFindAllStudyReviewsPresenter.kt +++ b/web/src/main/kotlin/br/all/study/presenter/RestfulFindAllStudyReviewsPresenter.kt @@ -4,11 +4,8 @@ import br.all.application.study.find.presenter.FindAllStudyReviewsPresenter import br.all.application.study.find.service.FindAllStudyReviewsService.ResponseModel import br.all.application.study.repository.StudyReviewDto import br.all.shared.error.createErrorResponseFrom -import br.all.study.controller.StudyReviewController -import br.all.study.requests.PostStudyReviewRequest import br.all.utils.LinksFactory import org.springframework.hateoas.RepresentationModel -import org.springframework.hateoas.server.mvc.linkTo import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.stereotype.Component @@ -25,10 +22,13 @@ class RestfulFindAllStudyReviewsPresenter( val restfulResponse = ViewModel(response.systematicStudyId, response.studyReviews.size, response.studyReviews) val selfRef = linksFactory.findAllStudies(response.systematicStudyId) - val allBySource = linksFactory.findAllStudiesBySource(response.systematicStudyId, "") + val sources = getSources(response.studyReviews) + for (source in sources) { + restfulResponse.add(linksFactory.findAllStudiesBySource(response.systematicStudyId, source)) + } val createStudyReview = linksFactory.createStudy(response.systematicStudyId) - restfulResponse.add(selfRef, allBySource, createStudyReview) + restfulResponse.add(selfRef, createStudyReview) responseEntity = ResponseEntity.status(HttpStatus.OK).body(restfulResponse) } @@ -36,6 +36,8 @@ class RestfulFindAllStudyReviewsPresenter( override fun isDone() = responseEntity != null + fun getSources(studies: List): List = studies.flatMap { it.searchSources }.distinct() + private data class ViewModel ( val systematicStudyId : UUID, val size: Int, diff --git a/web/src/main/kotlin/br/all/study/presenter/RestfulRemoveCriteriaPresenter.kt b/web/src/main/kotlin/br/all/study/presenter/RestfulRemoveCriteriaPresenter.kt new file mode 100644 index 000000000..438284947 --- /dev/null +++ b/web/src/main/kotlin/br/all/study/presenter/RestfulRemoveCriteriaPresenter.kt @@ -0,0 +1,60 @@ +package br.all.study.presenter + +import br.all.application.study.update.interfaces.RemoveCriteriaPresenter +import br.all.application.study.update.interfaces.RemoveCriteriaService +import br.all.shared.error.createErrorResponseFrom +import br.all.study.requests.RemoveCriteriaRequest +import br.all.utils.LinksFactory +import org.springframework.hateoas.RepresentationModel +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity + +class RestfulRemoveCriteriaPresenter( + private val linksFactory: LinksFactory, +): RemoveCriteriaPresenter { + var responseEntity: ResponseEntity<*>? = null + + override fun prepareSuccessView(response: RemoveCriteriaService.ResponseModel) { + val restfulResponse = ViewModel( + response.inclusionCriteria, + response.exclusionCriteria, + ) + + response.inclusionCriteria.forEach { + restfulResponse.add(linksFactory.removeStudyReviewCriteria + ( + response.systematicStudyId, + response.studyId, + RemoveCriteriaRequest( + criteria = listOf(it) + ) + ) + ) + } + + response.exclusionCriteria.forEach { + restfulResponse.add(linksFactory.removeStudyReviewCriteria + ( + response.systematicStudyId, + response.studyId, + RemoveCriteriaRequest( + criteria = listOf(it) + ) + ) + ) + } + + val protocol = linksFactory.findProtocol(response.systematicStudyId) + restfulResponse.add(protocol) + responseEntity = ResponseEntity.status(HttpStatus.OK).body(restfulResponse) + } + + override fun prepareFailView(throwable: Throwable) = run {responseEntity = createErrorResponseFrom(throwable) } + + override fun isDone() = responseEntity != null + + private data class ViewModel( + val inclusionCriteria: List, + val exclusionCriteria: List, + ) : RepresentationModel() +} \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/study/requests/PatchBatchAnswerQuestionStudyReviewRequest.kt b/web/src/main/kotlin/br/all/study/requests/PatchBatchAnswerQuestionStudyReviewRequest.kt new file mode 100644 index 000000000..110711493 --- /dev/null +++ b/web/src/main/kotlin/br/all/study/requests/PatchBatchAnswerQuestionStudyReviewRequest.kt @@ -0,0 +1,34 @@ +package br.all.study.requests + +import br.all.application.study.update.interfaces.BatchAnswerQuestionService +import java.util.UUID + +class PatchBatchAnswerQuestionStudyReviewRequest( + val answers: List +) { + data class AnswerPayload( + val questionId: UUID, + val type: String, + val answer: Any + ) + + fun toRequestModel(userId: UUID, + systematicStudyId: UUID, + studyReviewId: Long + ): BatchAnswerQuestionService.RequestModel { + val serviceAnswers = this.answers.map { + BatchAnswerQuestionService.RequestModel.AnswerDetail( + questionId = it.questionId, + type = it.type, + answer = it.answer + ) + } + + return BatchAnswerQuestionService.RequestModel( + userId = userId, + systematicStudyId = systematicStudyId, + studyReviewId = studyReviewId, + answers = serviceAnswers + ) + } +} \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/study/requests/RemoveCriteriaRequest.kt b/web/src/main/kotlin/br/all/study/requests/RemoveCriteriaRequest.kt new file mode 100644 index 000000000..391995f94 --- /dev/null +++ b/web/src/main/kotlin/br/all/study/requests/RemoveCriteriaRequest.kt @@ -0,0 +1,15 @@ +package br.all.study.requests + +import br.all.application.study.update.interfaces.RemoveCriteriaService +import java.util.* + +data class RemoveCriteriaRequest ( + val criteria: List, +) { + fun toRequestModel(userId: UUID, systematicStudyId: UUID, studyReviewId: Long) = RemoveCriteriaService.RequestModel ( + userId, + systematicStudyId, + studyReviewId, + criteria + ) +} diff --git a/web/src/main/kotlin/br/all/utils/LinksFactory.kt b/web/src/main/kotlin/br/all/utils/LinksFactory.kt index ce4765c8b..205d590f6 100644 --- a/web/src/main/kotlin/br/all/utils/LinksFactory.kt +++ b/web/src/main/kotlin/br/all/utils/LinksFactory.kt @@ -12,6 +12,7 @@ import br.all.study.controller.StudyReviewController import br.all.study.requests.PatchDuplicatedStudiesRequest import br.all.study.requests.PatchStatusStudyReviewRequest import br.all.study.requests.PostStudyReviewRequest +import br.all.study.requests.RemoveCriteriaRequest import org.springframework.hateoas.Link import org.springframework.hateoas.server.mvc.linkTo import org.springframework.stereotype.Component @@ -316,5 +317,21 @@ class LinksFactory { questionId ) }.withRel("find-answer").withType("GET") -} + fun findStudyReviewCriteria(systematicStudyId: UUID, studyReviewId: Long): Link = + linkTo { + findCriteria( + systematicStudyId, + studyReviewId + ) + }.withRel("find-study-criteria").withType("GET") + + fun removeStudyReviewCriteria(systematicStudyId: UUID, studyReviewId: Long, request: RemoveCriteriaRequest): Link = + linkTo { + removeCriterion( + systematicStudyId, + studyReviewId, + request + ) + }.withRel("remove-criteria").withType("PATCH") +} diff --git a/web/src/main/resources/application.yaml b/web/src/main/resources/application.yaml index d5956b6bc..6e9a2abe7 100644 --- a/web/src/main/resources/application.yaml +++ b/web/src/main/resources/application.yaml @@ -21,7 +21,6 @@ spring: properties: hibernate: format_sql: true - database-platform: org.hibernate.dialect.PostgreSQLDialect jwt: key: "dasdasdadadasdasdadadasdasdadadasdasdadadasdasdada" diff --git a/web/src/test/kotlin/br/all/protocol/controller/ProtocolControllerTest.kt b/web/src/test/kotlin/br/all/protocol/controller/ProtocolControllerTest.kt index 317efff83..b99006f85 100644 --- a/web/src/test/kotlin/br/all/protocol/controller/ProtocolControllerTest.kt +++ b/web/src/test/kotlin/br/all/protocol/controller/ProtocolControllerTest.kt @@ -1,5 +1,6 @@ package br.all.protocol.controller +import br.all.application.protocol.repository.CriterionDto import br.all.infrastructure.protocol.MongoProtocolRepository import br.all.infrastructure.review.MongoSystematicStudyRepository import br.all.infrastructure.review.SystematicStudyDocument @@ -23,6 +24,7 @@ import br.all.review.shared.TestDataFactory as SystematicStudyTestDataFactory @SpringBootTest @AutoConfigureMockMvc @Tag("IntegrationTest") +@Tag("ControllerTest") @DisplayName("Protocol Controller Integration Tests") class ProtocolControllerTest( @Autowired private val protocolRepository: MongoProtocolRepository, @@ -63,6 +65,12 @@ class ProtocolControllerTest( private fun getUrl(systematicStudy: UUID = factory.protocol) = "/systematic-study/$systematicStudy/protocol" + private fun putUrl(systematicStudyId: UUID = factory.protocol) = + "/systematic-study/$systematicStudyId/protocol" + + private fun getStage(systematicStudy: UUID = factory.protocol) = + "/systematic-study/$systematicStudy/protocol/stage" + @Nested @DisplayName("When getting protocols") inner class WhenGettingProtocols { @@ -124,9 +132,6 @@ class ProtocolControllerTest( } } - private fun putUrl(systematicStudyId: UUID = factory.protocol) = - "/systematic-study/$systematicStudyId/protocol" - @Nested @DisplayName("When putting protocols") inner class WhenPuttingProtocols { @@ -153,7 +158,11 @@ class ProtocolControllerTest( @Test fun `should update an existing protocol without deleting existing collection-type variables`() { - val document = factory.createProtocolDocument() + val document = factory.createProtocolDocument( + keywords = setOf("keyword1", "keyword2"), + robQuestions = setOf(UUID.randomUUID(), UUID.randomUUID()), + selectionCriteria = setOf(CriterionDto("desc", "INCLUSION")) + ) val json = factory.validPutRequest() protocolRepository.save(document) @@ -249,4 +258,43 @@ class ProtocolControllerTest( } } } + + @Nested + @DisplayName("When getting protocol stage") + inner class WhenGettingProtocolStage { + @Test + fun `should return protocol stage with 200 status when protocol exists`() { + val document = factory.createProtocolDocument() + protocolRepository.save(document) + + mockMvc.perform(get(getStage()) + .with(SecurityMockMvcRequestPostProcessors.user(user)) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.systematicStudyId").value(factory.protocol.toString())) + .andExpect(jsonPath("$.currentStage").exists()) + } + + @Test + fun `should return 404 when protocol does not exist`() { + mockMvc.perform(get(getStage()) + .with(SecurityMockMvcRequestPostProcessors.user(user)) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.message").exists()) + .andExpect(jsonPath("$.detail").exists()) + } + + @Test + fun `should not authorize researchers that are not collaborators to get protocol stage`() { + testHelperService.testForUnauthorizedUser(mockMvc, get(getStage())) + } + + @Test + fun `should not allow unauthenticated users to get protocol stage`() { + testHelperService.testForUnauthenticatedUser(mockMvc, get(getStage())) + } + } } \ No newline at end of file diff --git a/web/src/test/kotlin/br/all/protocol/persistence/MongoProtocolRepositoryTest.kt b/web/src/test/kotlin/br/all/protocol/persistence/MongoProtocolRepositoryTest.kt index fee36a63e..1c8f03b11 100644 --- a/web/src/test/kotlin/br/all/protocol/persistence/MongoProtocolRepositoryTest.kt +++ b/web/src/test/kotlin/br/all/protocol/persistence/MongoProtocolRepositoryTest.kt @@ -10,6 +10,7 @@ import org.springframework.boot.test.context.SpringBootTest @SpringBootTest @Tag("IntegrationTest") +@Tag("RepositoryTest") class MongoProtocolRepositoryTest( @Autowired private val sut: MongoProtocolRepository, ) { diff --git a/web/src/test/kotlin/br/all/protocol/shared/TestDataFactory.kt b/web/src/test/kotlin/br/all/protocol/shared/TestDataFactory.kt index a0da57f75..0a47a7b1e 100644 --- a/web/src/test/kotlin/br/all/protocol/shared/TestDataFactory.kt +++ b/web/src/test/kotlin/br/all/protocol/shared/TestDataFactory.kt @@ -62,6 +62,14 @@ class TestDataFactory { picoc, ) + fun createCriteria( + type: String, + description: String, + ) = CriterionDto( + type = type, + description = description, + ) + private fun eligibilityCriteria() = List(5) { CriterionDto(faker.paragraph(5), faker.random.randomValue(listOf("INCLUSION", "EXCLUSION"))) }.toSet() diff --git a/web/src/test/kotlin/br/all/question/controller/ExtractionQuestionControllerTest.kt b/web/src/test/kotlin/br/all/question/controller/ExtractionQuestionControllerTest.kt index 87fb4da8a..f9b441db8 100644 --- a/web/src/test/kotlin/br/all/question/controller/ExtractionQuestionControllerTest.kt +++ b/web/src/test/kotlin/br/all/question/controller/ExtractionQuestionControllerTest.kt @@ -22,6 +22,7 @@ import java.util.* @SpringBootTest @AutoConfigureMockMvc @Tag("IntegrationTest") +@Tag("ControllerTest") class ExtractionQuestionControllerTest( @Autowired val repository: MongoQuestionRepository, @Autowired val systematicStudyRepository: MongoSystematicStudyRepository, diff --git a/web/src/test/kotlin/br/all/question/controller/RiskOfBiasQuestionControllerTest.kt b/web/src/test/kotlin/br/all/question/controller/RiskOfBiasQuestionControllerTest.kt index 71f5f4ff0..2ea33c5e5 100644 --- a/web/src/test/kotlin/br/all/question/controller/RiskOfBiasQuestionControllerTest.kt +++ b/web/src/test/kotlin/br/all/question/controller/RiskOfBiasQuestionControllerTest.kt @@ -22,6 +22,7 @@ import java.util.* @SpringBootTest @AutoConfigureMockMvc @Tag("IntegrationTest") +@Tag("ControllerTest") class RiskOfBiasQuestionControllerTest( @Autowired val repository: MongoQuestionRepository, @Autowired val systematicStudyRepository: MongoSystematicStudyRepository, diff --git a/web/src/test/kotlin/br/all/question/persistence/MongoQuestionRepositoryTest.kt b/web/src/test/kotlin/br/all/question/persistence/MongoQuestionRepositoryTest.kt index 53c818e05..78c7ddb5c 100644 --- a/web/src/test/kotlin/br/all/question/persistence/MongoQuestionRepositoryTest.kt +++ b/web/src/test/kotlin/br/all/question/persistence/MongoQuestionRepositoryTest.kt @@ -13,6 +13,8 @@ import org.springframework.boot.test.context.SpringBootTest import java.util.UUID @SpringBootTest +@Tag("IntegrationTest") +@Tag("RepositoryTest") class MongoQuestionRepositoryTest( @Autowired private val sut: MongoQuestionRepository, ) { diff --git a/web/src/test/kotlin/br/all/report/controller/ReportControllerTest.kt b/web/src/test/kotlin/br/all/report/controller/ReportControllerTest.kt index 382b3dbed..8c3cb88c4 100644 --- a/web/src/test/kotlin/br/all/report/controller/ReportControllerTest.kt +++ b/web/src/test/kotlin/br/all/report/controller/ReportControllerTest.kt @@ -27,10 +27,10 @@ import br.all.review.shared.TestDataFactory as SystematicStudyTestDataFactory import br.all.study.utils.TestDataFactory as StudyReviewTestDataFactory import br.all.protocol.shared.TestDataFactory as ProtocolTestDataFactory - @SpringBootTest @AutoConfigureMockMvc @Tag("IntegrationTest") +@Tag("ControllerTest") @DisplayName("Report Controller Integration Tests") class ReportControllerTest( @Autowired private val studyReviewRepository: MongoStudyReviewRepository, @@ -118,6 +118,9 @@ class ReportControllerTest( private fun studiesFunnelUrl() = "$baseReportUrl/studies-funnel" + private fun findStudyReviewCriteria(studyReviewId: Long) = + "$baseReportUrl/study-review/$studyReviewId/criteria" + private fun findKeywordsUrl(filter: String?) = if (filter.isNullOrBlank()) "$baseReportUrl/keywords" @@ -694,4 +697,42 @@ class ReportControllerTest( } } } + + @Nested + @DisplayName("When finding criteria of a study review") + inner class WhenFindingCriteriaOfAStudyReview { + @Test + fun `should return 200 and find correctly the criteria`() { + val systematicStudyId = systematicStudy.id + // o intelli j tava reclamando que o valor do id era sempre o mesmo + // então é por isso que tem esse negócio ai, qualquer coisa só + // coloca val studyReviewId = 1000L + val seed = System.currentTimeMillis() + val studyReviewId = (seed * 31) % Long.MAX_VALUE + + + val studyReview = studyReviewDataFactory.reviewDocument( + criteria = setOf("criteria 1", "criteria 2"), + systematicStudyId = systematicStudyId, + studyReviewId = studyReviewId, + ) + studyReviewRepository.save(studyReview) + + val criteria1 = protocolDataFactory.createCriteria("INCLUSION", "criteria 1") + + val criteria2 = protocolDataFactory.createCriteria("EXCLUSION", "criteria 2") + + val protocol = protocolDataFactory.createProtocolDocument( + id = systematicStudyId, + selectionCriteria = setOf(criteria1, criteria2) + ) + protocolRepository.save(protocol) + + mockMvc.perform( + get(findStudyReviewCriteria(studyReviewId = studyReviewId)) + .with(SecurityMockMvcRequestPostProcessors.user(user)) + ) + .andExpect(status().isOk) + } + } } diff --git a/web/src/test/kotlin/br/all/review/controller/SystematicStudyControllerTest.kt b/web/src/test/kotlin/br/all/review/controller/SystematicStudyControllerTest.kt index facd73d72..43224dc85 100644 --- a/web/src/test/kotlin/br/all/review/controller/SystematicStudyControllerTest.kt +++ b/web/src/test/kotlin/br/all/review/controller/SystematicStudyControllerTest.kt @@ -23,6 +23,7 @@ import java.util.* @SpringBootTest @AutoConfigureMockMvc @Tag("IntegrationTest") +@Tag("ControllerTest") class SystematicStudyControllerTest( @Autowired private val repository: MongoSystematicStudyRepository, @Autowired private val testHelperService: TestHelperService, diff --git a/web/src/test/kotlin/br/all/review/persistence/MongoSystematicStudyRepositoryTest.kt b/web/src/test/kotlin/br/all/review/persistence/MongoSystematicStudyRepositoryTest.kt index 1a81d5f4a..c8c8cf4a6 100644 --- a/web/src/test/kotlin/br/all/review/persistence/MongoSystematicStudyRepositoryTest.kt +++ b/web/src/test/kotlin/br/all/review/persistence/MongoSystematicStudyRepositoryTest.kt @@ -11,6 +11,7 @@ import java.util.* @SpringBootTest @Tag("IntegrationTest") +@Tag("RepositoryTest") class MongoSystematicStudyRepositoryTest( @Autowired private val sut: MongoSystematicStudyRepository, ) { diff --git a/web/src/test/kotlin/br/all/review/shared/TestDataFactory.kt b/web/src/test/kotlin/br/all/review/shared/TestDataFactory.kt index 0f7fe576d..bf5ded6e2 100644 --- a/web/src/test/kotlin/br/all/review/shared/TestDataFactory.kt +++ b/web/src/test/kotlin/br/all/review/shared/TestDataFactory.kt @@ -1,14 +1,12 @@ package br.all.review.shared -import br.all.application.user.repository.UserAccountDto import br.all.infrastructure.review.SystematicStudyDocument import io.github.serpro69.kfaker.Faker -import java.time.LocalDateTime import java.util.* class TestDataFactory { private val faker = Faker() - val researcherId: UUID = UUID.randomUUID() + private val researcherId: UUID = UUID.randomUUID() val systematicStudyId: UUID = UUID.randomUUID() val ownerId: UUID = UUID.randomUUID() diff --git a/web/src/test/kotlin/br/all/search/controller/SearchSessionControllerTest.kt b/web/src/test/kotlin/br/all/search/controller/SearchSessionControllerTest.kt index 2aef8a289..1d3fe1a22 100644 --- a/web/src/test/kotlin/br/all/search/controller/SearchSessionControllerTest.kt +++ b/web/src/test/kotlin/br/all/search/controller/SearchSessionControllerTest.kt @@ -29,6 +29,8 @@ import java.util.* @SpringBootTest @AutoConfigureMockMvc +@Tag("IntegrationTest") +@Tag("ControllerTest") class SearchSessionControllerTest( @Autowired val repository: MongoSearchSessionRepository, @Autowired val systematicStudyRepository: MongoSystematicStudyRepository, diff --git a/web/src/test/kotlin/br/all/search/persistence/MongoSearchSessionRepositoryTest.kt b/web/src/test/kotlin/br/all/search/persistence/MongoSearchSessionRepositoryTest.kt index fbeba7400..2f62a0fe3 100644 --- a/web/src/test/kotlin/br/all/search/persistence/MongoSearchSessionRepositoryTest.kt +++ b/web/src/test/kotlin/br/all/search/persistence/MongoSearchSessionRepositoryTest.kt @@ -8,6 +8,8 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import java.util.* @SpringBootTest +@Tag("IntegrationTest") +@Tag("RepositoryTest") class MongoSearchSessionRepositoryTest( @Autowired private val sut: MongoSearchSessionRepository, ) { diff --git a/web/src/test/kotlin/br/all/shared/TestHelperService.kt b/web/src/test/kotlin/br/all/shared/TestHelperService.kt index fd86578cb..bf6d40fac 100644 --- a/web/src/test/kotlin/br/all/shared/TestHelperService.kt +++ b/web/src/test/kotlin/br/all/shared/TestHelperService.kt @@ -4,6 +4,7 @@ import br.all.application.user.repository.UserAccountDto import br.all.application.user.repository.UserAccountRepository import br.all.security.service.ApplicationUser import io.github.serpro69.kfaker.Faker +import org.junit.jupiter.api.Tag import org.springframework.http.MediaType import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors import org.springframework.stereotype.Service @@ -14,6 +15,7 @@ import java.time.LocalDateTime import java.util.* @Service +@Tag("UnitTest") class TestHelperService( private val repository: UserAccountRepository, ) { diff --git a/web/src/test/kotlin/br/all/study/controller/StudyReviewControllerTest.kt b/web/src/test/kotlin/br/all/study/controller/StudyReviewControllerTest.kt index aab3d409f..7a2233ff9 100644 --- a/web/src/test/kotlin/br/all/study/controller/StudyReviewControllerTest.kt +++ b/web/src/test/kotlin/br/all/study/controller/StudyReviewControllerTest.kt @@ -9,8 +9,12 @@ import br.all.infrastructure.study.StudyReviewIdGeneratorService import br.all.security.service.ApplicationUser import br.all.shared.TestHelperService import br.all.study.utils.TestDataFactory +import org.hamcrest.Matchers.containsString +import org.hamcrest.Matchers.hasItem +import org.hamcrest.Matchers.hasSize import org.junit.jupiter.api.* import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc @@ -27,6 +31,8 @@ import br.all.review.shared.TestDataFactory as SystematicStudyTestDataFactory @SpringBootTest @AutoConfigureMockMvc +@Tag("IntegrationTest") +@Tag("ControllerTest") class StudyReviewControllerTest( @Autowired val repository: MongoStudyReviewRepository, @Autowired val systematicStudyRepository: MongoSystematicStudyRepository, @@ -69,6 +75,12 @@ class StudyReviewControllerTest( fun answerExtractionQuestion(studyReviewId: Long) = "/api/v1/systematic-study/$systematicStudyId/study-review/${studyReviewId}/extraction-answer" + fun batchAnswerRiskOfBiasQuestion(studyReviewId: Long) = + "/api/v1/systematic-study/$systematicStudyId/study-review/${studyReviewId}/batch-riskOfBias-answers" + + fun batchAnswerExtractionQuestion(studyReviewId: Long) = + "/api/v1/systematic-study/$systematicStudyId/study-review/${studyReviewId}/batch-extraction-answers" + @BeforeEach fun setUp() { repository.deleteAll() @@ -637,6 +649,167 @@ class StudyReviewControllerTest( } } + @Nested + @DisplayName("When batch answering ROB questions in a review") + inner class BatchAnswerRobQuestionsTests( + @Autowired val questionRepository: MongoQuestionRepository + ) { + @Test + @DisplayName("should save valid answers, ignore invalid ones, and return 200 with a detailed report") + fun `should handle partial success correctly`() { + val studyId = idService.next() + val studyReview = factory.reviewDocument(systematicStudyId, studyId) + repository.insert(studyReview) + + val validQId1 = UUID.randomUUID() + val validQId2 = UUID.randomUUID() + val invalidQId = UUID.randomUUID() + + val question1 = factory.generateRobQuestionTextualDto(validQId1, systematicStudyId) + val question2 = factory.generateRobQuestionTextualDto(validQId2, systematicStudyId) + val question3 = factory.generateRobQuestionNumberScaleDto(invalidQId, systematicStudyId) + + questionRepository.insert(question1) + questionRepository.insert(question2) + questionRepository.insert(question3) + + val jsonPayload = """ + { + "answers": [ + { "questionId": "$validQId1", "type": "TEXTUAL", "answer": "First valid answer" }, + { "questionId": "$validQId2", "type": "TEXTUAL", "answer": "Second valid answer" }, + { "questionId": "$invalidQId", "type": "NUMBERED_SCALE", "answer": "This shouldn't be a string" } + ] + } + """.trimIndent() + + mockMvc.perform( + patch(batchAnswerRiskOfBiasQuestion(studyId)) + .with(SecurityMockMvcRequestPostProcessors.user(user)) + .contentType(MediaType.APPLICATION_JSON).content(jsonPayload) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.totalAnswered").value(2)) + .andExpect(jsonPath("$.succeededAnswers", hasSize(2))) + .andExpect(jsonPath("$.succeededAnswers", hasItem(validQId1.toString()))) + .andExpect(jsonPath("$.succeededAnswers", hasItem(validQId2.toString()))) + .andExpect(jsonPath("$.failedAnswers", hasSize(1))) + .andExpect(jsonPath("$.failedAnswers[0].questionId").value(invalidQId.toString())) + .andExpect(jsonPath("$.failedAnswers[0].reason", containsString("not compatible with question type 'NUMBERED_SCALE'"))) + + val updatedReview = repository.findById(StudyReviewId(systematicStudyId, studyId)).get() + + assertEquals(3, updatedReview.qualityAnswers.size) + assertEquals("First valid answer", updatedReview.qualityAnswers[validQId1]) + assertEquals("Second valid answer", updatedReview.qualityAnswers[validQId2]) + assertNull(updatedReview.qualityAnswers[invalidQId]) + } + + @Test + @DisplayName("should return 401 for unauthenticated users") + fun `should not update if user is unauthenticated`() { + testHelperService.testForUnauthenticatedUser(mockMvc, patch(batchAnswerRiskOfBiasQuestion(1L))) + } + + @Test + @DisplayName("should return 403 for unauthorized users") + fun `should not update if user is unauthorized`() { + val jsonPayload = """{ "answers": [] }""" + testHelperService.testForUnauthorizedUser( + mockMvc, + patch(batchAnswerRiskOfBiasQuestion(1L)).content(jsonPayload) + ) + } + } + + @Nested + @DisplayName("When batch answering Extraction questions in a review") + inner class BatchAnswerExtractionQuestionsTests( + @Autowired val questionRepository: MongoQuestionRepository + ) { + @Test + @DisplayName("should save all valid answers and return 200 with no failures") + fun `should handle full success correctly`() { + val studyId = idService.next() + val studyReview = factory.reviewDocument(systematicStudyId, studyId) + repository.insert(studyReview) + + val textQId = UUID.randomUUID() + val pickListQId = UUID.randomUUID() + + val question1 = factory.generateExtractionQuestionTextualDto(textQId, systematicStudyId = systematicStudyId) + val question2 = factory.generateExtractionQuestionPickListDto(pickListQId, systematicStudyId = systematicStudyId) + + questionRepository.insert(question1) + questionRepository.insert(question2) + + val jsonPayload = """ + { + "answers": [ + { "questionId": "$textQId", "type": "TEXTUAL", "answer": "Another valid answer" }, + { "questionId": "$pickListQId", "type": "PICK_LIST", "answer": "Option A" } + ] + } + """.trimIndent() + + mockMvc.perform( + patch(batchAnswerExtractionQuestion(studyId)) + .with(SecurityMockMvcRequestPostProcessors.user(user)) + .contentType(MediaType.APPLICATION_JSON).content(jsonPayload) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.totalAnswered").value(2)) + .andExpect(jsonPath("$.succeededAnswers", hasSize(2))) + .andExpect(jsonPath("$.failedAnswers", hasSize(0))) + + val updatedReview = repository.findById(StudyReviewId(systematicStudyId, studyId)).get() + assertEquals(3, updatedReview.formAnswers.size) + assertEquals("Another valid answer", updatedReview.formAnswers[textQId]) + assertEquals("Option A", updatedReview.formAnswers[pickListQId]) + } + + @Test + @DisplayName("should not save answers if the question context is wrong") + fun `should fail on context mismatch`() { + val studyId = idService.next() + repository.insert(factory.reviewDocument(systematicStudyId, studyId)) + + val extractionQId = UUID.randomUUID() + questionRepository.insert(factory.generateExtractionQuestionTextualDto(extractionQId, systematicStudyId = systematicStudyId)) + + val jsonPayload = """ + { "answers": [{ "questionId": "$extractionQId", "type": "TEXTUAL", "answer": "some answer" }] } + """ + + mockMvc.perform( + patch(batchAnswerRiskOfBiasQuestion(studyId)) + .with(SecurityMockMvcRequestPostProcessors.user(user)) + .contentType(MediaType.APPLICATION_JSON).content(jsonPayload) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.totalAnswered").value(0)) + .andExpect(jsonPath("$.failedAnswers", hasSize(1))) + .andExpect(jsonPath("$.failedAnswers[0].questionId").value(extractionQId.toString())) + .andExpect(jsonPath("$.failedAnswers[0].reason", containsString("Should answer question with the context: ROB"))) + } + + @Test + @DisplayName("should return 401 for unauthenticated users") + fun `should not update if user is unauthenticated`() { + testHelperService.testForUnauthenticatedUser(mockMvc, patch(batchAnswerExtractionQuestion(1L))) + } + + @Test + @DisplayName("should return 403 for unauthorized users") + fun `should not update if user is unauthorized`() { + val jsonPayload = """{ "answers": [] }""" + testHelperService.testForUnauthorizedUser( + mockMvc, + patch(batchAnswerExtractionQuestion(1L)).content(jsonPayload) + ) + } + } + @Nested @DisplayName("When marking a study review as duplicated") inner class MarkingAsDuplicatedTests { diff --git a/web/src/test/kotlin/br/all/study/persistence/MongoStudyReviewRepositoryTest.kt b/web/src/test/kotlin/br/all/study/persistence/MongoStudyReviewRepositoryTest.kt index 1278542e3..1ebed0a45 100644 --- a/web/src/test/kotlin/br/all/study/persistence/MongoStudyReviewRepositoryTest.kt +++ b/web/src/test/kotlin/br/all/study/persistence/MongoStudyReviewRepositoryTest.kt @@ -8,12 +8,15 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import java.util.UUID @SpringBootTest +@Tag("IntegrationTest") +@Tag("RepositoryTest") class MongoStudyReviewRepositoryTest ( @Autowired private val sut: MongoStudyReviewRepository, @Autowired private val idService: StudyReviewIdGeneratorService diff --git a/web/src/test/kotlin/br/all/study/utils/TestDataFactory.kt b/web/src/test/kotlin/br/all/study/utils/TestDataFactory.kt index e98f932f5..d72a0dc5c 100644 --- a/web/src/test/kotlin/br/all/study/utils/TestDataFactory.kt +++ b/web/src/test/kotlin/br/all/study/utils/TestDataFactory.kt @@ -164,6 +164,27 @@ class TestDataFactory { QuestionContextEnum.ROB ) + fun generateRobQuestionNumberScaleDto( + questionId: UUID, + systematicStudyId: UUID = this.systematicStudyId, + code: String = faker.lorem.words(), + description: String = faker.lorem.words(), + lower: Int = 1, + higher: Int = 5 + ) = + QuestionDocument( + questionId, + systematicStudyId, + code, + description, + "NUMBERED_SCALE", + null, + higher, + lower, + null, + QuestionContextEnum.ROB + ) + fun generateExtractionQuestionTextualDto( questionId: UUID, systematicStudyId: UUID = this.systematicStudyId, @@ -182,4 +203,24 @@ class TestDataFactory { null, QuestionContextEnum.EXTRACTION ) + + fun generateExtractionQuestionPickListDto( + questionId: UUID, + systematicStudyId: UUID = this.systematicStudyId, + code: String = faker.lorem.words(), + description: String = faker.lorem.words(), + options: List = listOf("Option A", "Option B", "Option C") + ) = + QuestionDocument( + questionId, + systematicStudyId, + code, + description, + "PICK_LIST", + null, + null, + null, + options, + QuestionContextEnum.EXTRACTION + ) } \ No newline at end of file