diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml index 200eae3fa..4da075285 100644 --- a/.idea/dictionaries/project.xml +++ b/.idea/dictionaries/project.xml @@ -1,6 +1,7 @@ + pickmany picoc scas diff --git a/account/pom.xml b/account/pom.xml index f89bc2c3f..a28147f92 100644 --- a/account/pom.xml +++ b/account/pom.xml @@ -65,5 +65,10 @@ 1.0-SNAPSHOT compile + + io.swagger.core.v3 + swagger-annotations-jakarta + 2.2.31 + \ No newline at end of file diff --git a/account/src/main/kotlin/br/all/application/user/create/RegisterUserAccountService.kt b/account/src/main/kotlin/br/all/application/user/create/RegisterUserAccountService.kt index fb959eab1..335618bbf 100644 --- a/account/src/main/kotlin/br/all/application/user/create/RegisterUserAccountService.kt +++ b/account/src/main/kotlin/br/all/application/user/create/RegisterUserAccountService.kt @@ -1,5 +1,6 @@ package br.all.application.user.create +import io.swagger.v3.oas.annotations.media.Schema import java.util.UUID interface RegisterUserAccountService { @@ -7,6 +8,7 @@ interface RegisterUserAccountService { fun register(presenter: RegisterUserAccountPresenter, request: RequestModel) data class RequestModel( + val name: String, val username: String, val password: String, val email: String, @@ -14,6 +16,7 @@ interface RegisterUserAccountService { val affiliation: String, ) + @Schema(name = "RegisterUserAccountResponseModel", description = "Response model for Register User Account Service") data class ResponseModel( val id: UUID, val username: String, diff --git a/account/src/main/kotlin/br/all/application/user/create/RegisterUserAccountServiceImpl.kt b/account/src/main/kotlin/br/all/application/user/create/RegisterUserAccountServiceImpl.kt index 5fafd6c42..34e2e41e2 100644 --- a/account/src/main/kotlin/br/all/application/user/create/RegisterUserAccountServiceImpl.kt +++ b/account/src/main/kotlin/br/all/application/user/create/RegisterUserAccountServiceImpl.kt @@ -5,12 +5,21 @@ import br.all.application.user.create.RegisterUserAccountService.ResponseModel import br.all.application.user.repository.UserAccountRepository import br.all.application.user.repository.toDto import br.all.domain.shared.exception.UniquenessViolationException +import br.all.domain.shared.service.PasswordEncoderPort import br.all.domain.shared.user.Email +import br.all.domain.shared.user.Name +import br.all.domain.shared.user.Text +import br.all.domain.shared.user.Username import br.all.domain.user.* -class RegisterUserAccountServiceImpl(private val repository: UserAccountRepository) : RegisterUserAccountService { - - override fun register(presenter: RegisterUserAccountPresenter, request: RequestModel) { +class RegisterUserAccountServiceImpl( + private val repository: UserAccountRepository, + private val encoder: PasswordEncoderPort +) : RegisterUserAccountService { + override fun register( + presenter: RegisterUserAccountPresenter, + request: RequestModel + ) { if (repository.existsByEmail(request.email)) { presenter.prepareFailView(UniquenessViolationException("The email ${request.email} is already registered.")) } @@ -20,14 +29,16 @@ class RegisterUserAccountServiceImpl(private val repository: UserAccountReposito if (presenter.isDone()) return val userAccountId = UserAccountId() + val name = Name(request.name) val username = Username(request.username) val email = Email(request.email) val country = Text(request.country) val userAccount = UserAccount( id = userAccountId, + name = name, username = username, - password = request.password, + password = encoder.encode(request.password), email = email, country = country, affiliation = request.affiliation, diff --git a/account/src/main/kotlin/br/all/application/user/find/RetrieveUserProfileService.kt b/account/src/main/kotlin/br/all/application/user/find/RetrieveUserProfileService.kt index 2016e2c86..f859455c5 100644 --- a/account/src/main/kotlin/br/all/application/user/find/RetrieveUserProfileService.kt +++ b/account/src/main/kotlin/br/all/application/user/find/RetrieveUserProfileService.kt @@ -1,4 +1,5 @@ package br.all.application.user.find +import io.swagger.v3.oas.annotations.media.Schema import java.util.UUID interface RetrieveUserProfileService { @@ -8,8 +9,10 @@ interface RetrieveUserProfileService { val userId: UUID ) + @Schema(name = "RetrieveUserProfileService", description = "Response model for Retrieve User Profile Service") data class ResponseModel( val userId: UUID, + val name: String, val username: String, val email: String, val affiliation: String, diff --git a/account/src/main/kotlin/br/all/application/user/find/RetrieveUserProfileServiceImpl.kt b/account/src/main/kotlin/br/all/application/user/find/RetrieveUserProfileServiceImpl.kt index f382bc86a..146fe29c3 100644 --- a/account/src/main/kotlin/br/all/application/user/find/RetrieveUserProfileServiceImpl.kt +++ b/account/src/main/kotlin/br/all/application/user/find/RetrieveUserProfileServiceImpl.kt @@ -5,32 +5,26 @@ import br.all.application.user.find.RetrieveUserProfileService.ResponseModel import br.all.application.user.repository.UserAccountRepository class RetrieveUserProfileServiceImpl( - private val userAccountRepository: UserAccountRepository + private val repository: UserAccountRepository ) : RetrieveUserProfileService { override fun retrieveData( presenter: RetrieveUserProfilePresenter, request: RequestModel ) { - val userProfile = userAccountRepository.loadUserProfileById(request.userId) - if (userProfile == null) { + val userAccount = repository.loadFullUserAccountById(request.userId) + if (userAccount == null) { presenter.prepareFailView(NoSuchElementException("User with id ${request.userId} doesn't exist!")) return } - val userCredentials = userAccountRepository.loadCredentialsById(request.userId) - if (userCredentials == null) { - presenter.prepareFailView(NoSuchElementException("Account credentials with id ${request.userId} doesn't exist!")) - return - } - - val profile = ResponseModel( - userId = userProfile.id, - username = userCredentials.username, - email = userProfile.email, - affiliation = userProfile.affiliation, - country = userProfile.country, - authorities = userCredentials.authorities + userId = userAccount.id, + name = userAccount.name, + username = userAccount.username, + email = userAccount.email, + affiliation = userAccount.affiliation, + country = userAccount.country, + authorities = userAccount.authorities ) presenter.prepareSuccessView(profile) diff --git a/account/src/main/kotlin/br/all/application/user/repository/UserAccountDto.kt b/account/src/main/kotlin/br/all/application/user/repository/UserAccountDto.kt index c9939393f..11c14e9ef 100644 --- a/account/src/main/kotlin/br/all/application/user/repository/UserAccountDto.kt +++ b/account/src/main/kotlin/br/all/application/user/repository/UserAccountDto.kt @@ -5,6 +5,7 @@ import java.util.* data class UserAccountDto( val id: UUID, + val name: String, val username: String, val password: String, val email: String, diff --git a/account/src/main/kotlin/br/all/application/user/repository/UserAccountMapper.kt b/account/src/main/kotlin/br/all/application/user/repository/UserAccountMapper.kt index dcc0754db..0de5c8eeb 100644 --- a/account/src/main/kotlin/br/all/application/user/repository/UserAccountMapper.kt +++ b/account/src/main/kotlin/br/all/application/user/repository/UserAccountMapper.kt @@ -4,6 +4,7 @@ import br.all.domain.user.UserAccount fun UserAccount.toDto() = UserAccountDto( id.value(), + name.value, accountCredentials.username.value, accountCredentials.password, email.email, @@ -17,4 +18,3 @@ fun UserAccount.toDto() = UserAccountDto( accountCredentials.isCredentialsNonExpired, accountCredentials.isEnabled ) - diff --git a/account/src/main/kotlin/br/all/application/user/repository/UserAccountRepository.kt b/account/src/main/kotlin/br/all/application/user/repository/UserAccountRepository.kt index d14aa597a..c90caf229 100644 --- a/account/src/main/kotlin/br/all/application/user/repository/UserAccountRepository.kt +++ b/account/src/main/kotlin/br/all/application/user/repository/UserAccountRepository.kt @@ -12,4 +12,6 @@ interface UserAccountRepository { fun existsByUsername(username: String): Boolean fun deleteById(id: UUID) fun loadUserProfileById(id: UUID): UserProfileDto? + fun loadFullUserAccountById(id: UUID): UserAccountDto? + fun updatePassword(id: UUID, newHashedPassword: String) } \ No newline at end of file diff --git a/account/src/main/kotlin/br/all/application/user/repository/UserProfileDto.kt b/account/src/main/kotlin/br/all/application/user/repository/UserProfileDto.kt index 1060041c4..0858c20d9 100644 --- a/account/src/main/kotlin/br/all/application/user/repository/UserProfileDto.kt +++ b/account/src/main/kotlin/br/all/application/user/repository/UserProfileDto.kt @@ -4,6 +4,7 @@ import java.util.UUID data class UserProfileDto( val id: UUID, + val name: String, val email: String, val country: String, val affiliation: String, diff --git a/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordPresenter.kt b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordPresenter.kt new file mode 100644 index 000000000..7e1db8a90 --- /dev/null +++ b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordPresenter.kt @@ -0,0 +1,6 @@ +package br.all.application.user.update + +import br.all.application.user.update.ChangeAccountPasswordService.ResponseModel +import br.all.domain.shared.presenter.GenericPresenter + +interface ChangeAccountPasswordPresenter : GenericPresenter \ No newline at end of file diff --git a/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt new file mode 100644 index 000000000..4e02c3a11 --- /dev/null +++ b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt @@ -0,0 +1,20 @@ +package br.all.application.user.update + +import io.swagger.v3.oas.annotations.media.Schema +import java.util.UUID + +interface ChangeAccountPasswordService { + fun changePassword(presenter: ChangeAccountPasswordPresenter, request: RequestModel) + + data class RequestModel( + val userId: UUID, + val oldPassword: String, + val newPassword: String, + val confirmPassword: String + ) + + @Schema(name = "ChangeAccountPasswordServiceResponseModel", description = "Response model for Change Account Password Service") + data class ResponseModel( + val userId: UUID + ) +} \ No newline at end of file diff --git a/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImpl.kt b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImpl.kt new file mode 100644 index 000000000..cca8ef7d3 --- /dev/null +++ b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImpl.kt @@ -0,0 +1,47 @@ +package br.all.application.user.update + +import br.all.application.user.repository.UserAccountRepository +import br.all.domain.shared.service.PasswordEncoderPort + +class ChangeAccountPasswordServiceImpl( + private val repository: UserAccountRepository, + private val encoder: PasswordEncoderPort +) : ChangeAccountPasswordService { + override fun changePassword( + presenter: ChangeAccountPasswordPresenter, + request: ChangeAccountPasswordService.RequestModel + ) { + val userCredentials = repository.loadCredentialsById(request.userId) + if (userCredentials == null) { + presenter.prepareFailView(NoSuchElementException("User with id ${request.userId} doesn't exist!")) + return + } + + if (!encoder.matches(request.oldPassword, userCredentials.password)) { + presenter.prepareFailView(IllegalArgumentException("Invalid old password provided!")) + return + } + + if (request.newPassword != request.confirmPassword) { + presenter.prepareFailView(IllegalArgumentException("Confirm password does not match new password!")) + return + } + + if (encoder.matches(request.newPassword, userCredentials.password)) { + presenter.prepareFailView(IllegalArgumentException("New password cannot be the same as the old password!")) + return + } + + val newHashedPassword = encoder.encode(request.newPassword) + + try { + repository.updatePassword(request.userId, newHashedPassword) + presenter.prepareSuccessView(ChangeAccountPasswordService.ResponseModel( + request.userId, + )) + } catch (e: Exception) { + presenter.prepareFailView(e) + } + } + +} \ No newline at end of file diff --git a/account/src/main/kotlin/br/all/application/user/update/PatchUserProfilePresenter.kt b/account/src/main/kotlin/br/all/application/user/update/PatchUserProfilePresenter.kt new file mode 100644 index 000000000..3e93382e5 --- /dev/null +++ b/account/src/main/kotlin/br/all/application/user/update/PatchUserProfilePresenter.kt @@ -0,0 +1,6 @@ +package br.all.application.user.update + +import br.all.domain.shared.presenter.GenericPresenter +import br.all.application.user.update.PatchUserProfileService.ResponseModel + +interface PatchUserProfilePresenter : GenericPresenter \ No newline at end of file diff --git a/account/src/main/kotlin/br/all/application/user/update/PatchUserProfileService.kt b/account/src/main/kotlin/br/all/application/user/update/PatchUserProfileService.kt new file mode 100644 index 000000000..6c51da928 --- /dev/null +++ b/account/src/main/kotlin/br/all/application/user/update/PatchUserProfileService.kt @@ -0,0 +1,33 @@ +package br.all.application.user.update + +import io.swagger.v3.oas.annotations.media.Schema +import java.util.UUID + +interface PatchUserProfileService { + fun patchProfile(presenter: PatchUserProfilePresenter, request: RequestModel) + + data class RequestModel( + val userId: UUID, + val name: String, + val email: String, + val affiliation: String, + val country: String + ) + + @Schema(name = "PatchUserProfileServiceResponseModel", description = "Response model for Patch User Profile Service") + data class ResponseModel( + val userId: UUID, + val name: String, + val username: String, + val email: String, + val affiliation: String, + val country: String, + val invalidEntries: List + ) + + data class InvalidEntry( + val field: String, + val entry: String, + val message: String + ) +} \ No newline at end of file diff --git a/account/src/main/kotlin/br/all/application/user/update/PatchUserProfileServiceImpl.kt b/account/src/main/kotlin/br/all/application/user/update/PatchUserProfileServiceImpl.kt new file mode 100644 index 000000000..8dc91faff --- /dev/null +++ b/account/src/main/kotlin/br/all/application/user/update/PatchUserProfileServiceImpl.kt @@ -0,0 +1,71 @@ +package br.all.application.user.update + +import br.all.application.user.repository.UserAccountRepository +import br.all.application.user.update.PatchUserProfileService.RequestModel +import br.all.application.user.update.PatchUserProfileService.ResponseModel +import br.all.application.user.update.PatchUserProfileService.InvalidEntry +import br.all.domain.shared.user.Email +import br.all.domain.shared.user.Name +import br.all.domain.shared.user.Text + +class PatchUserProfileServiceImpl( + private val repository: UserAccountRepository +) : PatchUserProfileService { + override fun patchProfile( + presenter: PatchUserProfilePresenter, + request: RequestModel + ) { + val userAccount = repository.loadFullUserAccountById(request.userId) + if (userAccount == null) { + presenter.prepareFailView(NoSuchElementException("User with id ${request.userId} doesn't exist!")) + return + } + + val invalidEntries = mutableListOf() + + val updatedName = validateField("name", request.name, userAccount.name, invalidEntries) { Name(it).value } + val updatedEmail = validateField("email", request.email, userAccount.email, invalidEntries) { Email(it).email } + val updatedCountry = validateField("country", request.country, userAccount.country, invalidEntries) { Text(it).value } + val updatedAffiliation = validateField("affiliation", request.affiliation, userAccount.affiliation, invalidEntries) { Text(it).value } + + val updatedUserAccount = userAccount.copy( + name = updatedName, + email = updatedEmail, + country = updatedCountry, + affiliation = updatedAffiliation + ) + + val responseModel = ResponseModel( + userId = request.userId, + name = updatedName, + username = userAccount.username, + email = updatedEmail, + affiliation = updatedAffiliation, + country = updatedCountry, + invalidEntries = invalidEntries + ) + + repository.save(updatedUserAccount) + presenter.prepareSuccessView(responseModel) + } + + private fun validateField( + fieldName: String, + newValue: String, + currentValue: String, + errors: MutableList, + validationLogic: (String) -> String + ): String { + return try { + validationLogic(newValue) + } catch (e: Exception) { + errors.add(InvalidEntry( + field = fieldName, + entry = newValue, + message = e.message ?: "Invalid $fieldName format" + )) + + currentValue + } + } +} \ No newline at end of file diff --git a/account/src/main/kotlin/br/all/domain/user/AccountCredentials.kt b/account/src/main/kotlin/br/all/domain/user/AccountCredentials.kt index 07f2379a8..36c62af8a 100644 --- a/account/src/main/kotlin/br/all/domain/user/AccountCredentials.kt +++ b/account/src/main/kotlin/br/all/domain/user/AccountCredentials.kt @@ -1,5 +1,7 @@ package br.all.domain.user +import br.all.domain.shared.user.Username + class AccountCredentials ( val username: Username, val password: String, diff --git a/account/src/main/kotlin/br/all/domain/user/UserAccount.kt b/account/src/main/kotlin/br/all/domain/user/UserAccount.kt index 61ca66dc2..51184e8d9 100644 --- a/account/src/main/kotlin/br/all/domain/user/UserAccount.kt +++ b/account/src/main/kotlin/br/all/domain/user/UserAccount.kt @@ -2,11 +2,15 @@ package br.all.domain.user import br.all.domain.shared.ddd.Entity import br.all.domain.shared.user.Email +import br.all.domain.shared.user.Name +import br.all.domain.shared.user.Text +import br.all.domain.shared.user.Username import java.time.LocalDateTime import java.util.UUID class UserAccount( id: UserAccountId, + var name: Name, val createdAt: LocalDateTime = LocalDateTime.now(), var email: Email, var country: Text, diff --git a/account/src/main/kotlin/br/all/infrastructure/user/JpaAccountCredentialsRepository.kt b/account/src/main/kotlin/br/all/infrastructure/user/JpaAccountCredentialsRepository.kt index 2abea1e29..c2b55ecad 100644 --- a/account/src/main/kotlin/br/all/infrastructure/user/JpaAccountCredentialsRepository.kt +++ b/account/src/main/kotlin/br/all/infrastructure/user/JpaAccountCredentialsRepository.kt @@ -13,7 +13,7 @@ interface JpaAccountCredentialsRepository : JpaRepository() every { presenter.prepareSuccessView(capture(responseSlot)) } returns Unit @@ -55,12 +53,13 @@ class RetrieveUserProfileServiceImplTest { verify(exactly = 0) { presenter.prepareFailView(any()) } val capturedResponse = responseSlot.captured - assertEquals(userProfile.id, capturedResponse.userId) - assertEquals(userCredentials.username, capturedResponse.username) - assertEquals(userProfile.email, capturedResponse.email) - assertEquals(userProfile.affiliation, capturedResponse.affiliation) - assertEquals(userProfile.country, capturedResponse.country) - assertEquals(userCredentials.authorities, capturedResponse.authorities) + assertEquals(userAccountDto.id, capturedResponse.userId) + assertEquals(userAccountDto.name, capturedResponse.name) + assertEquals(userAccountDto.username, capturedResponse.username) + assertEquals(userAccountDto.email, capturedResponse.email) + assertEquals(userAccountDto.affiliation, capturedResponse.affiliation) + assertEquals(userAccountDto.country, capturedResponse.country) + assertEquals(userAccountDto.authorities, capturedResponse.authorities) } @Test @@ -68,7 +67,7 @@ class RetrieveUserProfileServiceImplTest { val userId = UUID.randomUUID() val request = RetrieveUserProfileService.RequestModel(userId = userId) - every { userAccountRepository.loadUserProfileById(userId) } returns null + every { repository.loadFullUserAccountById(userId) } returns null val exceptionSlot = slot() every { presenter.prepareFailView(capture(exceptionSlot)) } returns Unit @@ -81,25 +80,5 @@ class RetrieveUserProfileServiceImplTest { val capturedException = exceptionSlot.captured assertEquals("User with id $userId doesn't exist!", capturedException.message) } - - @Test - fun `should prepare fail view when user credentials are not found`() { - val userProfile = factory.userProfile() - val request = RetrieveUserProfileService.RequestModel(userId = userProfile.id) - - every { userAccountRepository.loadUserProfileById(request.userId) } returns userProfile - every { userAccountRepository.loadCredentialsById(request.userId) } returns null - - val exceptionSlot = slot() - every { presenter.prepareFailView(capture(exceptionSlot)) } returns Unit - - sut.retrieveData(presenter, request) - - verify(exactly = 0) { presenter.prepareSuccessView(any()) } - verify(exactly = 1) { presenter.prepareFailView(any()) } - - val capturedException = exceptionSlot.captured - assertEquals("Account credentials with id ${request.userId} doesn't exist!", capturedException.message) - } } } diff --git a/account/src/test/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImplTest.kt b/account/src/test/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImplTest.kt new file mode 100644 index 000000000..07d2b3a90 --- /dev/null +++ b/account/src/test/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImplTest.kt @@ -0,0 +1,181 @@ +package br.all.application.user.update + +import br.all.application.user.repository.UserAccountRepository +import br.all.application.user.utils.TestDataFactory +import br.all.domain.shared.service.PasswordEncoderPort +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.slot +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 +import java.util.* +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@Tag("UnitTest") +@Tag("ServiceTest") +@ExtendWith(MockKExtension::class) +class ChangeAccountPasswordServiceImplTest { + + @MockK(relaxUnitFun = true) + private lateinit var repository: UserAccountRepository + + @MockK + private lateinit var encoder: PasswordEncoderPort + + @MockK(relaxUnitFun = true) + private lateinit var presenter: ChangeAccountPasswordPresenter + + private lateinit var sut: ChangeAccountPasswordServiceImpl + private lateinit var factory: TestDataFactory + + @BeforeEach + fun setup() { + sut = ChangeAccountPasswordServiceImpl(repository, encoder) + factory = TestDataFactory() + } + + @Nested + @DisplayName("When changing a user's password") + inner class WhenChangingPassword { + + @Test + fun `should update password when all inputs are valid`() { + val userCredentials = factory.accountCredentials().copy(password = "oldHashedPassword") + val request = ChangeAccountPasswordService.RequestModel( + userId = userCredentials.id, + oldPassword = "plainOldPassword", + newPassword = "plainNewPassword", + confirmPassword = "plainNewPassword" + ) + val newHashedPassword = "newHashedPassword" + + every { repository.loadCredentialsById(userCredentials.id) } returns userCredentials + every { encoder.matches(request.oldPassword, userCredentials.password) } returns true + every { encoder.matches(request.newPassword, userCredentials.password) } returns false + every { encoder.encode(request.newPassword) } returns newHashedPassword + + val userIdSlot = slot() + val passwordSlot = slot() + every { repository.updatePassword(capture(userIdSlot), capture(passwordSlot)) } returns Unit + + val responseSlot = slot() + every { presenter.prepareSuccessView(capture(responseSlot)) } returns Unit + + sut.changePassword(presenter, request) + + verify(exactly = 1) { presenter.prepareSuccessView(any()) } + verify(exactly = 0) { presenter.prepareFailView(any()) } + verify(exactly = 1) { repository.updatePassword(userCredentials.id, newHashedPassword) } + + assertEquals(userCredentials.id, userIdSlot.captured) + assertEquals(newHashedPassword, passwordSlot.captured) + assertEquals(userCredentials.id, responseSlot.captured.userId) + } + + @Test + fun `should prepare fail view when user does not exist`() { + val nonExistentUserId = UUID.randomUUID() + val request = ChangeAccountPasswordService.RequestModel( + userId = nonExistentUserId, + oldPassword = "any", newPassword = "any", confirmPassword = "any" + ) + + every { repository.loadCredentialsById(nonExistentUserId) } returns null + + val exceptionSlot = slot() + every { presenter.prepareFailView(capture(exceptionSlot)) } returns Unit + + sut.changePassword(presenter, request) + + verify(exactly = 1) { presenter.prepareFailView(any()) } + verify(exactly = 0) { presenter.prepareSuccessView(any()) } + verify(exactly = 0) { repository.updatePassword(any(), any()) } + + assertIs(exceptionSlot.captured) + assertEquals("User with id $nonExistentUserId doesn't exist!", exceptionSlot.captured.message) + } + + @Test + fun `should prepare fail view when old password is incorrect`() { + val userCredentials = factory.accountCredentials().copy(password = "oldHashedPassword") + val request = ChangeAccountPasswordService.RequestModel( + userId = userCredentials.id, + oldPassword = "wrongOldPassword", + newPassword = "plainNewPassword", + confirmPassword = "plainNewPassword" + ) + + every { repository.loadCredentialsById(userCredentials.id) } returns userCredentials + every { encoder.matches(request.oldPassword, userCredentials.password) } returns false + + val exceptionSlot = slot() + every { presenter.prepareFailView(capture(exceptionSlot)) } returns Unit + + sut.changePassword(presenter, request) + + verify(exactly = 1) { presenter.prepareFailView(any()) } + verify(exactly = 0) { presenter.prepareSuccessView(any()) } + + assertIs(exceptionSlot.captured) + assertEquals("Invalid old password provided!", exceptionSlot.captured.message) + } + + @Test + fun `should prepare fail view when new password and confirmation do not match`() { + val userCredentials = factory.accountCredentials().copy(password = "oldHashedPassword") + val request = ChangeAccountPasswordService.RequestModel( + userId = userCredentials.id, + oldPassword = "plainOldPassword", + newPassword = "plainNewPassword", + confirmPassword = "doesNotMatch" + ) + + every { repository.loadCredentialsById(userCredentials.id) } returns userCredentials + every { encoder.matches(request.oldPassword, userCredentials.password) } returns true + + val exceptionSlot = slot() + every { presenter.prepareFailView(capture(exceptionSlot)) } returns Unit + + sut.changePassword(presenter, request) + + verify(exactly = 1) { presenter.prepareFailView(any()) } + verify(exactly = 0) { presenter.prepareSuccessView(any()) } + + assertIs(exceptionSlot.captured) + assertEquals("Confirm password does not match new password!", exceptionSlot.captured.message) + } + + @Test + fun `should prepare fail view when new password is the same as the old one`() { + val userCredentials = factory.accountCredentials().copy(password = "oldHashedPassword") + val request = ChangeAccountPasswordService.RequestModel( + userId = userCredentials.id, + oldPassword = "plainOldPassword", + newPassword = "plainOldPassword", + confirmPassword = "plainOldPassword" + ) + + every { repository.loadCredentialsById(userCredentials.id) } returns userCredentials + every { encoder.matches(request.oldPassword, userCredentials.password) } returns true + every { encoder.matches(request.newPassword, userCredentials.password) } returns true + + val exceptionSlot = slot() + every { presenter.prepareFailView(capture(exceptionSlot)) } returns Unit + + sut.changePassword(presenter, request) + + verify(exactly = 1) { presenter.prepareFailView(any()) } + verify(exactly = 0) { presenter.prepareSuccessView(any()) } + + assertIs(exceptionSlot.captured) + assertEquals("New password cannot be the same as the old password!", exceptionSlot.captured.message) + } + } +} diff --git a/account/src/test/kotlin/br/all/application/user/update/PatchUserProfileServiceImplTest.kt b/account/src/test/kotlin/br/all/application/user/update/PatchUserProfileServiceImplTest.kt new file mode 100644 index 000000000..7b28de501 --- /dev/null +++ b/account/src/test/kotlin/br/all/application/user/update/PatchUserProfileServiceImplTest.kt @@ -0,0 +1,155 @@ +package br.all.application.user.update + +import br.all.application.user.repository.UserAccountDto +import br.all.application.user.repository.UserAccountRepository +import br.all.application.user.utils.TestDataFactory +import com.mongodb.assertions.Assertions.assertTrue +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.slot +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 + +@Tag("UnitTest") +@Tag("ServiceTest") +@ExtendWith(MockKExtension::class) +class PatchUserProfileServiceImplTest { + + @MockK(relaxUnitFun = true) + private lateinit var repository: UserAccountRepository + + @MockK(relaxUnitFun = true) + private lateinit var presenter: PatchUserProfilePresenter + + private lateinit var sut: PatchUserProfileServiceImpl + private lateinit var factory: TestDataFactory + + @BeforeEach + fun setup() { + sut = PatchUserProfileServiceImpl(repository) + factory = TestDataFactory() + } + + @Nested + @DisplayName("When patching a user profile") + inner class WhenPatchingUserProfile { + + @Test + fun `should update all fields and prepare success view when all entries are valid`() { + val existingUser = factory.userAccountDto() + val request = PatchUserProfileService.RequestModel( + userId = existingUser.id, + name = "New Valid Name", + email = "new.valid@email.com", + affiliation = "New Affiliation", + country = "New Country" + ) + + every { repository.loadFullUserAccountById(existingUser.id) } returns existingUser + + val responseSlot = slot() + every { presenter.prepareSuccessView(capture(responseSlot)) } returns Unit + + val savedUserSlot = slot() + every { repository.save(capture(savedUserSlot)) } returns Unit + + sut.patchProfile(presenter, request) + + verify(exactly = 1) { presenter.prepareSuccessView(any()) } + verify(exactly = 0) { presenter.prepareFailView(any()) } + verify(exactly = 1) { repository.save(any()) } + + val capturedResponse = responseSlot.captured + assertEquals(request.name, capturedResponse.name) + assertEquals(request.email, capturedResponse.email) + assertEquals(request.affiliation, capturedResponse.affiliation) + assertEquals(request.country, capturedResponse.country) + assertTrue(capturedResponse.invalidEntries.isEmpty()) + + val capturedSavedUser = savedUserSlot.captured + assertEquals(request.name, capturedSavedUser.name) + assertEquals(request.email, capturedSavedUser.email) + + assertEquals(existingUser.id, capturedSavedUser.id) + assertEquals(existingUser.username, capturedSavedUser.username) + } + + @Test + fun `should update valid fields, collect errors, and prepare success view when some entries are invalid`() { + val existingUser = factory.userAccountDto() + val request = PatchUserProfileService.RequestModel( + userId = existingUser.id, + name = "Another Valid Name", + email = "invalid-email", + affiliation = "Another Valid Affiliation", + country = "" + ) + + every { repository.loadFullUserAccountById(existingUser.id) } returns existingUser + + val responseSlot = slot() + every { presenter.prepareSuccessView(capture(responseSlot)) } returns Unit + + val savedUserSlot = slot() + every { repository.save(capture(savedUserSlot)) } returns Unit + + sut.patchProfile(presenter, request) + + verify(exactly = 1) { presenter.prepareSuccessView(any()) } + verify(exactly = 0) { presenter.prepareFailView(any()) } + verify(exactly = 1) { repository.save(any()) } + + val capturedResponse = responseSlot.captured + assertEquals(2, capturedResponse.invalidEntries.size) + assertEquals("email", capturedResponse.invalidEntries[0].field) + assertEquals("invalid-email", capturedResponse.invalidEntries[0].entry) + assertEquals("country", capturedResponse.invalidEntries[1].field) + assertEquals("", capturedResponse.invalidEntries[1].entry) + + assertEquals(request.name, capturedResponse.name) + assertEquals(request.affiliation, capturedResponse.affiliation) + assertEquals(existingUser.email, capturedResponse.email) + assertEquals(existingUser.country, capturedResponse.country) + + val capturedSavedUser = savedUserSlot.captured + assertEquals(request.name, capturedSavedUser.name) + assertEquals(request.affiliation, capturedSavedUser.affiliation) + assertEquals(existingUser.email, capturedSavedUser.email) + assertEquals(existingUser.country, capturedSavedUser.country) + } + + @Test + fun `should prepare fail view when user does not exist`() { + val nonExistentUserId = UUID.randomUUID() + val request = PatchUserProfileService.RequestModel( + userId = nonExistentUserId, + name = "Any Name", + email = "any@email.com", + affiliation = "Any", + country = "Any" + ) + + every { repository.loadFullUserAccountById(nonExistentUserId) } returns null + + val exceptionSlot = slot() + every { presenter.prepareFailView(capture(exceptionSlot)) } returns Unit + + sut.patchProfile(presenter, request) + + verify(exactly = 0) { presenter.prepareSuccessView(any()) } + verify(exactly = 1) { presenter.prepareFailView(any()) } + verify(exactly = 0) { repository.save(any()) } + + val capturedException = exceptionSlot.captured + assertEquals("User with id $nonExistentUserId doesn't exist!", capturedException.message) + } + } +} \ No newline at end of file diff --git a/account/src/test/kotlin/br/all/application/user/utils/TestDataFactory.kt b/account/src/test/kotlin/br/all/application/user/utils/TestDataFactory.kt index 4bf2c37c3..a3aa561cb 100644 --- a/account/src/test/kotlin/br/all/application/user/utils/TestDataFactory.kt +++ b/account/src/test/kotlin/br/all/application/user/utils/TestDataFactory.kt @@ -2,8 +2,10 @@ package br.all.application.user.utils import br.all.application.user.create.RegisterUserAccountService import br.all.application.user.repository.AccountCredentialsDto +import br.all.application.user.repository.UserAccountDto import br.all.application.user.repository.UserProfileDto import io.github.serpro69.kfaker.Faker +import java.time.LocalDateTime import java.util.* class TestDataFactory { @@ -14,7 +16,8 @@ class TestDataFactory { password = faker.pearlJam.songs(), email = faker.internet.email(), country = faker.address.countryCode(), - affiliation = faker.lorem.words() + affiliation = faker.lorem.words(), + name = faker.name.neutralFirstName() ) fun accountCredentials() @@ -31,5 +34,23 @@ class TestDataFactory { email = faker.internet.email(), country = faker.address.countryCode(), affiliation = faker.leagueOfLegends.rank(), + name = faker.name.firstName() + ) + + fun userAccountDto() = UserAccountDto( + id = UUID.randomUUID(), + username = faker.name.firstName(), + name = faker.name.firstName(), + password = faker.pearlJam.songs(), + email = faker.internet.email(), + country = faker.address.countryCode(), + affiliation = faker.lorem.words(), + createdAt = LocalDateTime.now(), + authorities = setOf("USER"), + refreshToken = faker.leagueOfLegends.rank(), + isAccountNonExpired = false, + isAccountNonLocked = false, + isCredentialsNonExpired = false, + isEnabled = true, ) } \ No newline at end of file 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 1483e8d4f..29edabbc4 100644 --- a/account/src/test/kotlin/br/all/domain/user/UserAccountTest.kt +++ b/account/src/test/kotlin/br/all/domain/user/UserAccountTest.kt @@ -1,6 +1,9 @@ package br.all.domain.user import br.all.domain.shared.user.Email +import br.all.domain.shared.user.Name +import br.all.domain.shared.user.Text +import br.all.domain.shared.user.Username import io.github.serpro69.kfaker.Faker import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Tag @@ -53,6 +56,8 @@ class UserAccountTest{ Assertions.assertTrue(user.accountCredentials.isEnabled) } + // Although the user won't be able to change their username through the front-end, + // I think we should still let this test here. @Test fun `should change username in account credentials`(){ val user = createUser() @@ -71,6 +76,7 @@ class UserAccountTest{ private fun createUser( id: UUID = UUID.randomUUID(), + name: Name = Name(faker.name.name()), createdAt: LocalDateTime = LocalDateTime.now(), email: Email = Email(faker.internet.email()), country: Text = Text(faker.address.countryCode()), @@ -78,9 +84,10 @@ class UserAccountTest{ username: Username = Username(faker.name.firstName()), password: String = faker.pearlJam.songs(), authorities: Set = setOf(Authority.USER) - ) = UserAccount( - UserAccountId(id), - createdAt, email, country, affiliation, username, password, authorities + ) = UserAccount( + id = UserAccountId(id), + name = name, + createdAt = createdAt, email = email, country = country, affiliation = affiliation, username = username, password = password, authorities = authorities ) } \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/application/protocol/find/FindProtocolStageService.kt b/review/src/main/kotlin/br/all/application/protocol/find/FindProtocolStageService.kt index 09966a5e0..bc30d4088 100644 --- a/review/src/main/kotlin/br/all/application/protocol/find/FindProtocolStageService.kt +++ b/review/src/main/kotlin/br/all/application/protocol/find/FindProtocolStageService.kt @@ -19,14 +19,18 @@ interface FindProtocolStageService { ) enum class ProtocolStage { - PROTOCOL_PART_I, + GENERAL_DEFINITION, + RESEARCH_QUESTIONS, PICOC, - PROTOCOL_PART_II, - PROTOCOL_PART_III, + ELIGIBILITY_CRITERIA, + INFORMATION_SOURCES_AND_SEARCH_STRATEGY, + SELECTION_AND_EXTRACTION, + RISK_OF_BIAS, + ANALYSIS_AND_SYNTHESIS_METHOD, + IDENTIFICATION, SELECTION, EXTRACTION, - GRAPHICS, - FINALIZATION + GRAPHICS } } \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/application/protocol/find/FindProtocolStageServiceImpl.kt b/review/src/main/kotlin/br/all/application/protocol/find/FindProtocolStageServiceImpl.kt index c900bf90f..8a077f465 100644 --- a/review/src/main/kotlin/br/all/application/protocol/find/FindProtocolStageServiceImpl.kt +++ b/review/src/main/kotlin/br/all/application/protocol/find/FindProtocolStageServiceImpl.kt @@ -8,6 +8,7 @@ import br.all.application.protocol.find.FindProtocolStageService.ResponseModel import br.all.application.protocol.find.FindProtocolStageService.ProtocolStage import br.all.application.protocol.repository.ProtocolDto import br.all.application.question.repository.QuestionRepository +import br.all.application.review.repository.SystematicStudyDto import br.all.application.review.repository.fromDto import br.all.domain.shared.exception.EntityNotFoundException import br.all.application.shared.presenter.prepareIfFailsPreconditions @@ -27,40 +28,41 @@ class FindProtocolStageServiceImpl( override fun getStage(presenter: FindProtocolStagePresenter, request: RequestModel) { val user = credentialsService.loadCredentials(request.userId)?.toUser() val systematicStudyDto = systematicStudyRepository.findById(request.systematicStudyId) - val systematicStudy = systematicStudyDto?.let { SystematicStudy.fromDto(it) } + + val systematicStudy = runCatching { + systematicStudyDto?.let { SystematicStudy.fromDto(it) } + }.getOrNull() 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}" + if (protocolDto == null || systematicStudyDto == null) { + val message = "Protocol or Systematic Study not found for id ${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 robQuestionsCount = questionRepository.findAllBySystematicStudyId(systematicStudyDto.id, QuestionContextEnum.ROB).size + val extractionQuestionsCount = questionRepository.findAllBySystematicStudyId(systematicStudyDto.id, QuestionContextEnum.EXTRACTION).size val stage = evaluateStage( protocolDto, - totalStudiesCount, - includedStudiesCount, - extractedStudiesCount, - robQuestions, - extractionQuestions, + systematicStudyDto, + allStudies.size, + allStudies.count { it.selectionStatus == "INCLUDED" }, + allStudies.count { it.extractionStatus == "INCLUDED" }, + robQuestionsCount, + extractionQuestionsCount ) presenter.prepareSuccessView(ResponseModel(request.userId, request.systematicStudyId, stage)) } private fun evaluateStage( - dto: ProtocolDto, + protocolDto: ProtocolDto, + studyDto: SystematicStudyDto, totalStudiesCount: Int, includedStudiesCount: Int, extractedStudiesCount: Int, @@ -68,10 +70,17 @@ class FindProtocolStageServiceImpl( 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 + !isT1Complete(studyDto) -> ProtocolStage.GENERAL_DEFINITION + !isT2Complete(protocolDto) -> ProtocolStage.RESEARCH_QUESTIONS + + isPicocStarted(protocolDto) && !isT3Complete(protocolDto) -> ProtocolStage.PICOC + + !isT4Complete(protocolDto) -> ProtocolStage.ELIGIBILITY_CRITERIA + !isT5Complete(protocolDto) -> ProtocolStage.INFORMATION_SOURCES_AND_SEARCH_STRATEGY + !isT6Complete(protocolDto, extractionQuestionsCount) -> ProtocolStage.SELECTION_AND_EXTRACTION + + !isT8Complete(protocolDto) -> ProtocolStage.ANALYSIS_AND_SYNTHESIS_METHOD + totalStudiesCount == 0 -> ProtocolStage.IDENTIFICATION includedStudiesCount == 0 -> ProtocolStage.SELECTION extractedStudiesCount == 0 -> ProtocolStage.EXTRACTION @@ -79,51 +88,61 @@ class FindProtocolStageServiceImpl( } } - 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 isT1Complete(studyDto: SystematicStudyDto): Boolean { + val isStudyInfoComplete = studyDto.title.isNotBlank() && studyDto.description.isNotBlank() && studyDto.objectives.isNotBlank() + return isStudyInfoComplete } - 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) } + private fun isT2Complete(dto: ProtocolDto): Boolean { + return dto.researchQuestions.isNotEmpty() + } - val hasExtractionAndRob = robQuestionCount > 0 && extractionQuestionsCount > 0 + private fun isPicocStarted(dto: ProtocolDto): Boolean { + val picoc = dto.picoc ?: return false + return !picoc.population.isNullOrBlank() || !picoc.intervention.isNullOrBlank() || + !picoc.control.isNullOrBlank() || !picoc.outcome.isNullOrBlank() || !picoc.context.isNullOrBlank() + } - val hasDatabases = dto.informationSources.isNotEmpty() - val hasResearchQuestions = dto.researchQuestions.isNotEmpty() - val hasAnalysisProcess = !dto.analysisAndSynthesisProcess.isNullOrBlank() + private fun isT3Complete(dto: ProtocolDto): Boolean { + val picoc = dto.picoc ?: return false - return hasInclusionCriteria && hasExclusionCriteria && - hasExtractionAndRob && hasDatabases && - hasResearchQuestions && hasAnalysisProcess + return !picoc.population.isNullOrBlank() && + !picoc.intervention.isNullOrBlank() && + !picoc.control.isNullOrBlank() && + !picoc.outcome.isNullOrBlank() } - private fun picocStage(dto: ProtocolDto): Boolean { - val picoc = dto.picoc - if (picoc == null) return false + private fun isT4Complete(dto: ProtocolDto): Boolean { + val hasInclusion = dto.eligibilityCriteria.any { it.type.equals("INCLUSION", ignoreCase = true) } + val hasExclusion = dto.eligibilityCriteria.any { it.type.equals("EXCLUSION", ignoreCase = true) } - val picocIsStarted = !picoc.population.isNullOrBlank() || !picoc.intervention.isNullOrBlank() || - !picoc.control.isNullOrBlank() || !picoc.outcome.isNullOrBlank() || !picoc.context.isNullOrBlank() + val hasStudyType = !dto.studyTypeDefinition.isNullOrBlank() + val hasLanguage = dto.studiesLanguages.isNotEmpty() - if (picocIsStarted) { - val picocIsCompleted = !picoc.population.isNullOrBlank() && !picoc.intervention.isNullOrBlank() && - !picoc.control.isNullOrBlank() && !picoc.outcome.isNullOrBlank() && !picoc.context.isNullOrBlank() + return hasInclusion && hasExclusion && hasStudyType && hasLanguage + } - if (!picocIsCompleted) { - return true - } - } + private fun isT5Complete(dto: ProtocolDto): Boolean { + return !dto.sourcesSelectionCriteria.isNullOrBlank() && + dto.informationSources.isNotEmpty() && + !dto.searchMethod.isNullOrBlank() && + dto.keywords.isNotEmpty() && + !dto.searchString.isNullOrBlank() + } + + private fun isT6Complete(dto: ProtocolDto, extractionQuestionsCount: Int): Boolean { + return !dto.selectionProcess.isNullOrBlank() && + !dto.dataCollectionProcess.isNullOrBlank() && + extractionQuestionsCount > 0 + } + + // T7 - Risk Of Bias + // Não é necessária uma função 'isT7Complete' na lógica principal, pois + // a regra é: se não houver questões, o sistema avança para T8. + // A presença de questões (robQuestionCount > 0) simplesmente marca a tarefa como feita. - return false + private fun isT8Complete(dto: ProtocolDto): Boolean { + return !dto.analysisAndSynthesisProcess.isNullOrBlank() } -} +} \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/application/question/create/CreateQuestionService.kt b/review/src/main/kotlin/br/all/application/question/create/CreateQuestionService.kt index 67a344d61..29c77ec1f 100644 --- a/review/src/main/kotlin/br/all/application/question/create/CreateQuestionService.kt +++ b/review/src/main/kotlin/br/all/application/question/create/CreateQuestionService.kt @@ -5,7 +5,7 @@ import java.util.* interface CreateQuestionService { fun create(presenter: CreateQuestionPresenter, request: RequestModel) - enum class QuestionType{TEXTUAL, PICK_LIST, NUMBERED_SCALE, LABELED_SCALE} + enum class QuestionType{TEXTUAL, PICK_LIST, NUMBERED_SCALE, LABELED_SCALE, PICK_MANY} data class RequestModel( val userId: UUID, diff --git a/review/src/main/kotlin/br/all/application/question/create/CreateQuestionServiceImpl.kt b/review/src/main/kotlin/br/all/application/question/create/CreateQuestionServiceImpl.kt index 657309808..f1909fb52 100644 --- a/review/src/main/kotlin/br/all/application/question/create/CreateQuestionServiceImpl.kt +++ b/review/src/main/kotlin/br/all/application/question/create/CreateQuestionServiceImpl.kt @@ -37,7 +37,7 @@ class CreateQuestionServiceImpl( val type = request.questionType - if (type == PICK_LIST && request.options.isNullOrEmpty()) { + if ((type == PICK_LIST || type == PICK_MANY) && request.options.isNullOrEmpty()) { presenter.prepareFailView(IllegalArgumentException("Options must not be null or empty.")) return } @@ -54,14 +54,20 @@ class CreateQuestionServiceImpl( val questionId = QuestionId(generatedId) val builder = QuestionBuilder.with(questionId, SystematicStudyId(systematicStudyId), request.code, request.description) - val question = when (type) { - TEXTUAL -> builder.buildTextual() - PICK_LIST -> builder.buildPickList(request.options!!) - NUMBERED_SCALE -> builder.buildNumberScale(request.lower!!, request.higher!!) - LABELED_SCALE -> builder.buildLabeledScale(request.scales!!) - } - questionRepository.createOrUpdate(question.toDto(type, request.questionContext)) + try { + val question = when (type) { + TEXTUAL -> builder.buildTextual() + PICK_LIST -> builder.buildPickList(request.options!!) + PICK_MANY -> builder.buildPickMany(request.options!!) + NUMBERED_SCALE -> builder.buildNumberScale(request.lower!!, request.higher!!) + LABELED_SCALE -> builder.buildLabeledScale(request.scales!!) + } + questionRepository.createOrUpdate(question.toDto(type, request.questionContext)) + } catch (e: IllegalArgumentException) { + presenter.prepareFailView(e) + return + } presenter.prepareSuccessView(ResponseModel(request.userId, systematicStudyId, generatedId)) } diff --git a/review/src/main/kotlin/br/all/application/question/repository/QuestionMapper.kt b/review/src/main/kotlin/br/all/application/question/repository/QuestionMapper.kt index bde5cb447..b40bfe219 100644 --- a/review/src/main/kotlin/br/all/application/question/repository/QuestionMapper.kt +++ b/review/src/main/kotlin/br/all/application/question/repository/QuestionMapper.kt @@ -12,7 +12,7 @@ fun Question<*>.toDto(type: QuestionType, context: String) = QuestionDto( (this as? LabeledScale)?.scales?.mapValues { it.value.value }, (this as? NumberScale)?.higher, (this as? NumberScale)?.lower, - (this as? PickList)?.options, + (this as? PickList)?.options ?: (this as? PickMany)?.options, context = QuestionContextEnum.valueOf(context) ) @@ -26,6 +26,7 @@ fun Question.Companion.fromDto(dto: QuestionDto): Question<*> { return when { dto.questionType == "PICK_LIST" && dto.options != null -> builder.buildPickList(dto.options) + dto.questionType == "PICK_MANY" && dto.options != null -> builder.buildPickMany(dto.options) dto.questionType == "NUMBERED_SCALE" && dto.higher != null && dto.lower != null -> builder.buildNumberScale(dto.lower, dto.higher) @@ -36,4 +37,3 @@ fun Question.Companion.fromDto(dto: QuestionDto): Question<*> { println("Loaded question with context: ${dto.context}") } } - diff --git a/review/src/main/kotlin/br/all/application/question/update/services/UpdateQuestionServiceImpl.kt b/review/src/main/kotlin/br/all/application/question/update/services/UpdateQuestionServiceImpl.kt index 4b3bcd8bf..4bbe513ed 100644 --- a/review/src/main/kotlin/br/all/application/question/update/services/UpdateQuestionServiceImpl.kt +++ b/review/src/main/kotlin/br/all/application/question/update/services/UpdateQuestionServiceImpl.kt @@ -1,6 +1,8 @@ package br.all.application.question.update.services import br.all.application.question.create.CreateQuestionService.QuestionType +import br.all.application.question.create.CreateQuestionService.QuestionType.PICK_LIST +import br.all.application.question.create.CreateQuestionService.QuestionType.PICK_MANY import br.all.application.question.repository.QuestionRepository import br.all.application.question.repository.toDto import br.all.application.question.update.presenter.UpdateQuestionPresenter @@ -31,7 +33,7 @@ class UpdateQuestionServiceImpl( val type = request.questionType - if (type == QuestionType.PICK_LIST && request.options.isNullOrEmpty()) { + if ((type == PICK_LIST || type == PICK_MANY) && request.options.isNullOrEmpty()) { presenter.prepareFailView(IllegalArgumentException("Options must not be null or empty.")) return } @@ -47,14 +49,21 @@ class UpdateQuestionServiceImpl( val questionId = QuestionId(request.questionId) val builder = QuestionBuilder.with(questionId, SystematicStudyId(systematicStudyId), request.code, request.description) - val question = when (request.questionType) { - QuestionType.TEXTUAL -> builder.buildTextual() - QuestionType.PICK_LIST -> builder.buildPickList(request.options!!) - QuestionType.NUMBERED_SCALE -> builder.buildNumberScale(request.lower!!, request.higher!!) - QuestionType.LABELED_SCALE -> builder.buildLabeledScale(request.scales!!) + + try { + val question = when (request.questionType) { + QuestionType.TEXTUAL -> builder.buildTextual() + QuestionType.PICK_LIST -> builder.buildPickList(request.options!!) + QuestionType.PICK_MANY -> builder.buildPickMany(request.options!!) + QuestionType.NUMBERED_SCALE -> builder.buildNumberScale(request.lower!!, request.higher!!) + QuestionType.LABELED_SCALE -> builder.buildLabeledScale(request.scales!!) + } + questionRepository.createOrUpdate(question.toDto(type, request.questionContext)) + } catch (e: IllegalArgumentException) { + presenter.prepareFailView(e) + return } - questionRepository.createOrUpdate(question.toDto(type, request.questionContext)) presenter.prepareSuccessView(ResponseModel(userId, systematicStudyId, questionId.value)) } } diff --git a/review/src/main/kotlin/br/all/application/review/create/CreateSystematicStudyService.kt b/review/src/main/kotlin/br/all/application/review/create/CreateSystematicStudyService.kt index 1b3ec80e4..6cdea53a5 100644 --- a/review/src/main/kotlin/br/all/application/review/create/CreateSystematicStudyService.kt +++ b/review/src/main/kotlin/br/all/application/review/create/CreateSystematicStudyService.kt @@ -10,6 +10,7 @@ interface CreateSystematicStudyService { val title : String, val description : String, val collaborators : Set, + val objectives: String, ) data class ResponseModel( diff --git a/review/src/main/kotlin/br/all/application/review/repository/SystematicStudyDto.kt b/review/src/main/kotlin/br/all/application/review/repository/SystematicStudyDto.kt index 07a9a121a..6a1cb4033 100644 --- a/review/src/main/kotlin/br/all/application/review/repository/SystematicStudyDto.kt +++ b/review/src/main/kotlin/br/all/application/review/repository/SystematicStudyDto.kt @@ -8,4 +8,5 @@ data class SystematicStudyDto( val description : String, val owner : UUID, val collaborators : Set, + val objectives: String, ) diff --git a/review/src/main/kotlin/br/all/application/review/repository/SystematicStudyMapper.kt b/review/src/main/kotlin/br/all/application/review/repository/SystematicStudyMapper.kt index 025cda28a..d739ede39 100644 --- a/review/src/main/kotlin/br/all/application/review/repository/SystematicStudyMapper.kt +++ b/review/src/main/kotlin/br/all/application/review/repository/SystematicStudyMapper.kt @@ -12,6 +12,7 @@ fun SystematicStudy.toDto() = SystematicStudyDto( description, owner.value, collaborators.map { it.value }.toSet(), + objectives, ) fun SystematicStudy.Companion.fromRequestModel(id: UUID, requestModel: RequestModel) = SystematicStudy( @@ -19,6 +20,7 @@ fun SystematicStudy.Companion.fromRequestModel(id: UUID, requestModel: RequestMo requestModel.title, requestModel.description, ResearcherId(requestModel.userId), + objectives = requestModel.objectives, ) fun SystematicStudy.Companion.fromDto(dto: SystematicStudyDto) = SystematicStudy( @@ -29,4 +31,5 @@ fun SystematicStudy.Companion.fromDto(dto: SystematicStudyDto) = SystematicStudy dto.collaborators .map { ResearcherId(it) } .toMutableSet(), + dto.objectives, ) diff --git a/review/src/main/kotlin/br/all/application/review/update/services/UpdateSystematicStudyService.kt b/review/src/main/kotlin/br/all/application/review/update/services/UpdateSystematicStudyService.kt index 971d36f72..01a720e32 100644 --- a/review/src/main/kotlin/br/all/application/review/update/services/UpdateSystematicStudyService.kt +++ b/review/src/main/kotlin/br/all/application/review/update/services/UpdateSystematicStudyService.kt @@ -11,6 +11,7 @@ interface UpdateSystematicStudyService { val systematicStudy: UUID, val title: String?, val description: String?, + val objectives: String? ) data class ResponseModel( diff --git a/review/src/main/kotlin/br/all/application/review/update/services/UpdateSystematicStudyServiceImpl.kt b/review/src/main/kotlin/br/all/application/review/update/services/UpdateSystematicStudyServiceImpl.kt index 76de2a921..68fa2a93c 100644 --- a/review/src/main/kotlin/br/all/application/review/update/services/UpdateSystematicStudyServiceImpl.kt +++ b/review/src/main/kotlin/br/all/application/review/update/services/UpdateSystematicStudyServiceImpl.kt @@ -34,6 +34,7 @@ class UpdateSystematicStudyServiceImpl( val updated = SystematicStudy.fromDto(dto).apply { title = request.title ?: title description = request.description ?: description + objectives = request.objectives ?: objectives }.toDto() if (updated != dto) repository.saveOrUpdate(updated) 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 index 640a8a209..6de203d19 100644 --- 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 @@ -21,6 +21,7 @@ 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.PickMany import br.all.domain.model.question.Question import br.all.domain.model.question.QuestionContextEnum import br.all.domain.model.question.Textual @@ -114,6 +115,12 @@ class BatchAnswerQuestionServiceImpl( 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 == "PICK_MANY" && detail.answer is List<*> -> { + val stringList = detail.answer.map { + it as? String ?: throw IllegalArgumentException("All items in the answer list for PICK_MANY must be strings.") + } + (question as PickMany).answer(stringList) + } questionType == "NUMBERED_SCALE" && detail.answer is Int -> (question as NumberScale).answer(detail.answer) questionType == "LABELED_SCALE" -> { when (val answer = detail.answer) { diff --git a/review/src/main/kotlin/br/all/domain/model/question/PickMany.kt b/review/src/main/kotlin/br/all/domain/model/question/PickMany.kt new file mode 100644 index 000000000..00db9301b --- /dev/null +++ b/review/src/main/kotlin/br/all/domain/model/question/PickMany.kt @@ -0,0 +1,43 @@ +package br.all.domain.model.question + +import br.all.domain.model.review.SystematicStudyId +import br.all.domain.model.study.Answer +import br.all.domain.shared.ddd.Notification + +class PickMany( + id: QuestionId, + systematicStudyId: SystematicStudyId, + code: String, + description: String, + val options: List +) : Question>(id, systematicStudyId, code, description) { + + init { + val notification = validate() + require(notification.hasNoErrors()) { notification.message() } + } + + fun validate(): Notification { + val notification = Notification() + if (options.isEmpty()) + notification.addError("Can not create a picklist without a option to pick.") + + options.forEachIndexed { index, option -> + if (option.isBlank()) notification.addError("Option at index $index is empty or blank.") + } + return notification + } + + override fun answer(value: List): Answer> { + if (value.isEmpty()) throw IllegalArgumentException("Answer must not be empty.") + value.forEach { + if (it.isBlank()) throw IllegalArgumentException("Answer must not be blank.") + if (it !in options) throw IllegalArgumentException("Answer must be one of the valid options: $options") + } + return Answer(id.value(), value) + } + + override fun toString() = + "PickMany(QuestionId: $id, ProtocolId: $systematicStudyId, Code: $code, " + + "Description: $description, Options: $options)" +} \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/domain/model/question/QuestionBuilder.kt b/review/src/main/kotlin/br/all/domain/model/question/QuestionBuilder.kt index 4683ffc60..6f7a77466 100644 --- a/review/src/main/kotlin/br/all/domain/model/question/QuestionBuilder.kt +++ b/review/src/main/kotlin/br/all/domain/model/question/QuestionBuilder.kt @@ -24,6 +24,8 @@ class QuestionBuilder private constructor( fun buildNumberScale(lower: Int, higher: Int) = NumberScale(questionId, systematicStudyId, code, description, lower, higher) fun buildPickList(options: List) = PickList(questionId, systematicStudyId, code, description, options) + + fun buildPickMany(options: List) = PickMany(questionId, systematicStudyId, code, description, options) } diff --git a/review/src/main/kotlin/br/all/domain/model/review/SystematicStudy.kt b/review/src/main/kotlin/br/all/domain/model/review/SystematicStudy.kt index 69ce0e5c7..654c4df30 100644 --- a/review/src/main/kotlin/br/all/domain/model/review/SystematicStudy.kt +++ b/review/src/main/kotlin/br/all/domain/model/review/SystematicStudy.kt @@ -12,17 +12,26 @@ class SystematicStudy( description: String, owner: ResearcherId, collaborators: Set = emptySet(), + objectives: String, ) : Entity(id) { private val _collaborators = collaborators.toMutableSet() val collaborators get() = _collaborators.toSet() var owner = owner private set + + var objectives: String = objectives + set(value) { + require(value.isNotBlank()) { "Systematic study objective must not be blank." } + field = value + } + var title: String = title set(value) { require(value.isNotBlank()) { "Systematic study title must not be blank." } field = value } + var description = description set(value) { require(value.isNotBlank()) { "Systematic study description must not be blank." } @@ -40,6 +49,8 @@ class SystematicStudy( it.addError("Systematic Study title must not be blank!") if (description.isBlank()) it.addError("Systematic Study description must not be blank!") + if (objectives.isBlank()) + it.addError("Systematic Study objective must not be blank!") } fun addCollaborator(researcherId: ResearcherId) = _collaborators.add(researcherId) @@ -58,7 +69,7 @@ class SystematicStudy( } override fun toString() = "SystematicStudy(reviewId=$id, title='$title', description='$description', owner=$owner," + - " researchers=$_collaborators)" + " researchers=$_collaborators,"+" objectives=$objectives)" companion object } diff --git a/review/src/main/kotlin/br/all/domain/model/study/Doi.kt b/review/src/main/kotlin/br/all/domain/model/study/Doi.kt index 175fe677f..fff9b1761 100644 --- a/review/src/main/kotlin/br/all/domain/model/study/Doi.kt +++ b/review/src/main/kotlin/br/all/domain/model/study/Doi.kt @@ -13,7 +13,7 @@ data class Doi(val value: String) : ValueObject() { override fun validate(): Notification { val notification = Notification() if (value.isBlank()) notification.addError("DOI must not be blank.") - val regex = Regex("^https://doi\\.org/10\\.\\d{4,}/[\\w.-]+\$") + val regex = Regex("^https?://[\\w.-]*doi[\\w.-]*\\.org/10\\.\\d{4,}/.+$") if (!value.matches(regex)) notification.addError("Wrong DOI format: $value") return notification } diff --git a/review/src/main/kotlin/br/all/domain/model/study/Study.kt b/review/src/main/kotlin/br/all/domain/model/study/Study.kt index 737e86a82..a616b757e 100644 --- a/review/src/main/kotlin/br/all/domain/model/study/Study.kt +++ b/review/src/main/kotlin/br/all/domain/model/study/Study.kt @@ -22,11 +22,13 @@ data class Study( override fun validate(): Notification { val notification = Notification() - if (title.isBlank()) notification.addError("Title field must not be blank.") - if (authors.isBlank()) notification.addError("Authors field must not be blank.") - if (year == 0) notification.addError("Publication year must not be zero.") - if (venue.isBlank()) notification.addError("Journal field must not be blank.") - //if (abstract.isBlank()) notification.addError("Abstract field must not be blank.") + // every field can be blank now, the only thing that matter for bibTex is the ID + // and for Ris is the title, but it is already treated on their services +// if (title.isBlank()) notification.addError("Title field must not be blank.") +// if (authors.isBlank()) notification.addError("Authors field must not be blank.") +// if (year == 0) notification.addError("Publication year must not be zero.") +// if (venue.isBlank()) notification.addError("Journal field must not be blank.") +// if (abstract.isBlank()) notification.addError("Abstract field must not be blank.") return notification } } \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/domain/model/study/StudyType.kt b/review/src/main/kotlin/br/all/domain/model/study/StudyType.kt index e199cb6ee..004bc5a39 100644 --- a/review/src/main/kotlin/br/all/domain/model/study/StudyType.kt +++ b/review/src/main/kotlin/br/all/domain/model/study/StudyType.kt @@ -5,5 +5,5 @@ enum class StudyType { INBOOK, INCOLLECTION, INPROCEEDINGS, MANUAL, MASTERSTHESIS, MISC, PHDTHESIS, PROCEEDINGS, TECHREPORT, - UNPUBLISHED + UNPUBLISHED, UNKNOWN } \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt b/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt index 98575092a..99cf0db07 100644 --- a/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt +++ b/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt @@ -2,14 +2,22 @@ package br.all.domain.services import br.all.domain.model.review.SystematicStudyId import br.all.domain.model.search.SearchSessionID -import br.all.domain.model.study.* -import java.util.* +import br.all.domain.model.study.Doi +import br.all.domain.model.study.ExtractionStatus +import br.all.domain.model.study.ReadingPriority +import br.all.domain.model.study.SelectionStatus +import br.all.domain.model.study.Study +import br.all.domain.model.study.StudyReview +import br.all.domain.model.study.StudyReviewId +import br.all.domain.model.study.StudyType +import br.all.domain.shared.exception.bibtex.BibtexMissingRequiredFieldException +import br.all.domain.shared.exception.bibtex.BibtexParseException +import java.util.Locale class BibtexConverterService(private val studyReviewIdGeneratorService: IdGeneratorService) { private val authorTypes = listOf("author", "authors", "editor") - private val venueTypes = listOf("journal", "booktitle", "institution", - "organization", "publisher", "series", "school", "howpublished") + private val venueTypes = listOf("journal", "booktitle", "institution", "organization", "publisher", "series", "school", "howpublished") fun convertManyToStudyReview( systematicStudyId: SystematicStudyId, @@ -27,7 +35,6 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera fun convertToStudyReview(systematicStudyId: SystematicStudyId, searchSessionId: SearchSessionID, study: Study, source: MutableSet): StudyReview { val studyReviewId = StudyReviewId(studyReviewIdGeneratorService.next()) - return StudyReview( studyReviewId, systematicStudyId, @@ -56,16 +63,18 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera val validStudies = mutableListOf() val invalidEntries = mutableListOf() - bibtex.splitToSequence("@") + bibtex.split(Regex("(?=\\s*@)")) .map { it.trim() } .filter { it.isNotBlank() } .forEach { entry -> try { val study = convert(entry) validStudies.add(study) + } catch (e: BibtexParseException) { + val entryIdentifier = extractBibtexId(entry) ?: "starting with '${entry.take(40)}...'" + invalidEntries.add("Failed to parse entry '$entryIdentifier': ${e.message}") } catch (e: Exception) { - val entryName = extractEntryName(entry) - invalidEntries.add(entryName) + invalidEntries.add("An unexpected error occurred. Details: ${e.message}") } } return Pair(validStudies, invalidEntries) @@ -74,72 +83,78 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera fun convert(bibtexEntry: String): Study { require(bibtexEntry.isNotBlank()) { "BibTeX entry must not be blank." } + val bibtexId = extractBibtexId(bibtexEntry) + ?: throw BibtexMissingRequiredFieldException("BibTeX ID") + + val type = extractStudyType(bibtexEntry) val fieldMap = parseBibtexFields(bibtexEntry) - val title = fieldMap["title"] ?: "" + val title = fieldMap["title"]?.takeIf { it.isNotBlank() } ?: "" val year = fieldMap["year"]?.toIntOrNull() ?: 0 - val authors = getValueFromFieldMap(fieldMap, authorTypes) - val venue = getValueFromFieldMap(fieldMap, venueTypes) - val abstract = fieldMap["abstract"] ?: " " - val keywords = parseKeywords(fieldMap["keywords"]) + val authors = getValueFromFieldMap(fieldMap, authorTypes).takeIf { it.isNotBlank() } ?: "" + val venue = getValueFromFieldMap(fieldMap, venueTypes).takeIf { it.isNotBlank() } ?: "d" + + val abstract = fieldMap["abstract"] ?: "" + val keywords = parseKeywords(fieldMap["keywords"] ?: fieldMap["keyword"]) val references = parseReferences(fieldMap["references"]) + val doi = fieldMap["doi"]?.let { - val cleanDoi = it.replace(Regex("}"), "") - Doi("https://doi.org/$cleanDoi") + try { + val cleanDoi = it.replace(Regex("[{}]"), "").trim() + val fullUrl = if (cleanDoi.startsWith("http")) cleanDoi else "https://doi.org/$cleanDoi" + Doi(fullUrl) + } catch (e: Exception) { + null + } } - val type = extractStudyType(bibtexEntry) - return Study(type, title, year, authors, venue, abstract, keywords, references, doi) } private fun parseBibtexFields(bibtexEntry: String): Map { - val entry = bibtexEntry.replace("\n\t", " ") - val fields = entry.trim().split("\n").map { it.trim() } - val fieldMap = mutableMapOf() + val content = bibtexEntry.substringAfter('{', "").substringBeforeLast('}', "") + if (content.isBlank()) { + return emptyMap() + } - for (field in fields) { - val keyValuePair = field.split("=") - if (keyValuePair.size == 2) { - val key = keyValuePair[0].trim() - val value = cleanStringSurroundings(keyValuePair[1]) + val fieldMap = mutableMapOf() + val fieldSplitRegex = Regex(""",\s*(?=\w+\s*=)""") + + content.split(fieldSplitRegex).forEach { fieldString -> + val parts = fieldString.trim().split("=", limit = 2) + if (parts.size == 2) { + val key = parts[0].trim().lowercase(Locale.getDefault()) + val value = parts[1].trim() + .removeSurrounding("{", "}") + .removeSurrounding("\"", "\"") fieldMap[key] = value } } return fieldMap } - private fun cleanStringSurroundings(value: String): String { - return value.trim().removePrefix("{").removeSuffix("}") - .removeSuffix("},") - } - private fun getValueFromFieldMap(fieldMap: Map, keys: List): String { - for (key in keys) { - val value = fieldMap[key] - if (value != null) return value - } - return "" + return keys.firstNotNullOfOrNull { key -> fieldMap[key] } ?: "" } private fun parseKeywords(keywords: String?): Set { - return keywords?.split(",")?.map { it.trim() }?.toSet() ?: emptySet() + return keywords?.split("[,;]".toRegex())?.map { it.trim() }?.filter { it.isNotEmpty() }?.toSet() ?: emptySet() } private fun parseReferences(references: String?): List { - return references?.split(",")?.map { it.trim() } ?: emptyList() + return references?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList() } private fun extractStudyType(bibtexEntry: String): StudyType { - val entryTypeRegex = Regex("""\b(\w+)\{""") + val entryTypeRegex = Regex("""@(\w+)\s*\{""") val matchResult = entryTypeRegex.find(bibtexEntry) - val studyTypeName = matchResult?.groupValues?.get(1)?.uppercase(Locale.getDefault()) ?: "UNKNOWN" - return StudyType.valueOf(studyTypeName) + val studyTypeName = matchResult?.groupValues?.get(1)?.uppercase(Locale.getDefault()) ?: return StudyType.UNKNOWN + + return runCatching { StudyType.valueOf(studyTypeName) }.getOrDefault(StudyType.UNKNOWN) } - private fun extractEntryName(bibtexEntry: String): String { - val nameRegex = Regex("""\{(.*)}""", RegexOption.DOT_MATCHES_ALL) - val matchResult = nameRegex.find(bibtexEntry) - return matchResult?.groupValues?.get(1)?.trim() ?: "UNKNOWN" + private fun extractBibtexId(bibtexEntry: String): String? { + val keyRegex = Regex("""@\w+\s*\{(.*?)\s*,""", RegexOption.DOT_MATCHES_ALL) + return keyRegex.find(bibtexEntry)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() } } } \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt b/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt index 25b74c881..dde82d06f 100644 --- a/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt +++ b/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt @@ -3,10 +3,30 @@ package br.all.domain.services import br.all.domain.model.review.SystematicStudyId import br.all.domain.model.search.SearchSessionID import br.all.domain.model.study.* +import br.all.domain.shared.exception.ris.RisMissingRequiredFieldException +import br.all.domain.shared.exception.ris.RisParseException class RisConverterService(private val studyReviewIdGeneratorService: IdGeneratorService) { private val titleTypes = listOf("TI", "T1") + private val authorTypes = listOf("AU", "A1") + private val yearTypes = listOf("PY", "Y1") + private val venueTypes = listOf("JO", "T2", "JF", "JA") + + fun convertManyToStudyReview( + systematicStudyId: SystematicStudyId, + searchSessionId: SearchSessionID, + ris: String, + source: MutableSet, + ): Pair, List> { + require(ris.isNotBlank()) { "RIS input must not be blank." } + + val (validStudies, invalidEntries) = convertMany(ris) + val studyReviews = validStudies.map { study -> convertToStudyReview(systematicStudyId, searchSessionId, study, source) } + + return Pair(studyReviews, invalidEntries) + } + fun convertToStudyReview(systematicStudyId: SystematicStudyId, searchSessionId: SearchSessionID, study: Study, source: MutableSet): StudyReview { val studyReviewId = StudyReviewId(studyReviewIdGeneratorService.next()) @@ -34,203 +54,138 @@ class RisConverterService(private val studyReviewIdGeneratorService: IdGenerator ) } - fun convertManyToStudyReview( - systematicStudyId: SystematicStudyId, - searchSessionId: SearchSessionID, - ris: String, - source: MutableSet, - ): Pair, List> { - require(ris.isNotBlank()) { "convertManyToStudyReview: RIS must not be blank." } - - val (validStudies, invalidEntries) = convertMany(ris) - val studyReviews = validStudies.map { study -> convertToStudyReview(systematicStudyId, searchSessionId, study, source) } - - return Pair(studyReviews, invalidEntries) - } - private fun convertMany(ris: String): Pair, List> { val validStudies = mutableListOf() val invalidEntries = mutableListOf() - val regex = Regex( - "(?i)\\bTY\\b\\s*-.*?(?=(\\bTY\\b\\s*-|\\bER\\b\\s*-|\\z))", - RegexOption.DOT_MATCHES_ALL - ) - - val entries = regex.findAll(ris).map { it.value.trim() } + val entryRegex = Regex("""(^\s*TY\s*-.+?)(?=^\s*TY\s*-|\Z)""", setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL)) - entries.forEach { entry -> + entryRegex.findAll(ris).forEach { matchResult -> + val entry = matchResult.value.trim() try { - val study = convert(entry) - validStudies.add(study) + if (entry.isNotBlank()) { + val study = convert(entry) + validStudies.add(study) + } + } catch (e: RisParseException) { + val identifier = extractTitleForError(entry).takeIf { it != "Unknown Title" } + ?: "starting with '${entry.take(40)}...'" + invalidEntries.add("Failed to parse entry '$identifier': ${e.message}") } catch (e: Exception) { - val entryName = extractInvalidRis(entry) - invalidEntries.add(entryName) + invalidEntries.add("An unexpected error occurred. Details: ${e.message}") } } return Pair(validStudies, invalidEntries) } fun convert(ris: String): Study { - require(ris.isNotBlank()) { "convert: RIS must not be blank." } + require(ris.isNotBlank()) { "RIS entry must not be blank." } val fieldMap = parseRisFields(ris) - val venue = fieldMap["JO"] ?: "" + + val type = extractStudyType(fieldMap) + val primaryTitle = getValueFromFieldMap(fieldMap, titleTypes) val secondaryTitle = fieldMap["T2"] ?: "" - val year = fieldMap["PY"]?.toIntOrNull() - ?: fieldMap["Y1"]?.let { extractYear(it) } - ?: 0 - val authors = parseAuthors(fieldMap) - val type = extractStudyType(ris) - val abs = fieldMap["AB"] ?: "" + val title = "$primaryTitle $secondaryTitle".trim().ifBlank { "" } + + val year = extractYear(fieldMap) ?: 0 + val authors = parseAuthors(fieldMap).takeIf { it.isNotBlank() } ?: "" + + val venue = getValueFromFieldMap(fieldMap, venueTypes) + val abstract = fieldMap["AB"] ?: "" val keywords = parseKeywords(fieldMap["KW"]) val references = parseReferences(fieldMap["CR"]) - val doi = fieldMap["DO"]?.let { Doi("https://doi.org/$it") } - return Study(type, ("$primaryTitle $secondaryTitle").trim(), year, authors, venue, treatAbstract(abs), keywords, references, doi) + val doi = fieldMap["DO"]?.let { + try { + val cleanDoi = it.trim().removePrefix("https://doi.org/") + Doi("https://doi.org/$cleanDoi") + } catch (e: Exception) { + null + } + } + + return Study(type, title, year, authors, venue, abstract, keywords, references, doi) } - fun parseRisFields(ris: String): Map { - val fieldMap = mutableMapOf() + private fun parseRisFields(ris: String): Map { + val multiMap = mutableMapOf>() val lines = ris.trim().lines() + val tagRegex = Regex("""^([A-Z0-9]{2})\s*-\s?(.*)""") var currentKey: String? = null for (line in lines) { val trimmedLine = line.trim() - if (trimmedLine.contains(" - ")) { - val keyValuePair = trimmedLine.split(" - ", limit = 2) - if (keyValuePair.size == 2) { - currentKey = keyValuePair[0].trim() - val value = keyValuePair[1].trim() - if (currentKey == "AU" || currentKey == "A1") { - fieldMap[currentKey] = fieldMap.getOrDefault(currentKey, "") + value + "; " - } else if (currentKey == "KW"){ - fieldMap[currentKey] = fieldMap.getOrDefault(currentKey, "") + value + "; " - } else { - fieldMap[currentKey] = value - } - } + if (trimmedLine.isBlank()) continue + + val match = tagRegex.find(trimmedLine) + if (match != null) { + currentKey = match.groupValues[1] + val value = match.groupValues[2].trim() + multiMap.getOrPut(currentKey) { mutableListOf() }.add(value) } else if (currentKey != null) { - fieldMap[currentKey] = "${fieldMap[currentKey]} $trimmedLine".trim() + val values = multiMap[currentKey] + if (values != null && values.isNotEmpty()) { + val lastIndex = values.lastIndex + values[lastIndex] = values[lastIndex] + " " + trimmedLine + } } } - return fieldMap + + return multiMap.mapValues { (_, valueList) -> + valueList.joinToString(";") + } } - fun extractStudyType(risEntry: String): StudyType { - val entryTypeRegex = Regex("""(?m)^TY\s*-\s*(.+)$""") - val matchResult = entryTypeRegex.find(risEntry) - val studyTypeName = translateToStudyType(matchResult?.groupValues?.get(1) ?: "") - return studyTypeName + private fun extractStudyType(fieldMap: Map): StudyType { + val typeCode = fieldMap["TY"] ?: throw RisMissingRequiredFieldException("Entry Type (TY)") + return translateToStudyType(typeCode) } - private fun translateToStudyType(studyType: String): StudyType { - require(studyType.isNotBlank()) { "translateToStudyType: studytype must not be blank." } - val risMap = mapOf( - "ABST" to StudyType.ARTICLE, - "ADVS" to StudyType.MISC, - "AGGR" to StudyType.MISC, - "ANCIENT" to StudyType.MISC, - "ART" to StudyType.MISC, - "BILL" to StudyType.MISC, - "BLOG" to StudyType.MISC, - "BOOK" to StudyType.BOOK, - "CASE" to StudyType.MISC, - "CHAP" to StudyType.INBOOK, - "CHART" to StudyType.MISC, - "CLSWK" to StudyType.MISC, - "COMP" to StudyType.MISC, - "CONF" to StudyType.PROCEEDINGS, - "CPAPER" to StudyType.INPROCEEDINGS, - "CTLG" to StudyType.MISC, - "DATA" to StudyType.MISC, - "DBASE" to StudyType.MISC, - "DICT" to StudyType.MISC, - "EBOOK" to StudyType.BOOK, - "ECHAP" to StudyType.INBOOK, - "EDBOOK" to StudyType.BOOK, - "EJOUR" to StudyType.ARTICLE, - "ELEC" to StudyType.MISC, - "ENCYC" to StudyType.MISC, - "EQUA" to StudyType.MISC, - "FIGURE" to StudyType.MISC, - "GEN" to StudyType.MISC, - "GOVDOC" to StudyType.MISC, - "GRNT" to StudyType.MISC, - "HEAR" to StudyType.MISC, - "ICOMM" to StudyType.MISC, - "INPR" to StudyType.ARTICLE, - "INTV" to StudyType.MISC, - "JFULL" to StudyType.ARTICLE, - "JOUR" to StudyType.ARTICLE, - "LEGAL" to StudyType.MISC, - "MANSCPT" to StudyType.UNPUBLISHED, - "MAP" to StudyType.MISC, - "MGZN" to StudyType.ARTICLE, - "MPCT" to StudyType.MISC, - "MULTI" to StudyType.MISC, - "MUSIC" to StudyType.MISC, - "NEWS" to StudyType.ARTICLE, - "PAMP" to StudyType.BOOKLET, - "PAT" to StudyType.MISC, - "PCOMM" to StudyType.MISC, - "POD" to StudyType.MISC, - "PRESS" to StudyType.MISC, - "RPRT" to StudyType.TECHREPORT, - "SER" to StudyType.MISC, - "SLIDE" to StudyType.MISC, - "SOUND" to StudyType.MISC, - "STAND" to StudyType.MISC, - "STAT" to StudyType.MISC, - "STD" to StudyType.MISC, - "THES" to StudyType.MASTERSTHESIS, - "UNBILL" to StudyType.MISC, - "UNPB" to StudyType.UNPUBLISHED, - "UNPD" to StudyType.UNPUBLISHED, - "VIDEO" to StudyType.MISC - ) - val st = risMap.getOrElse(studyType) { throw IllegalArgumentException() } - return st + private fun translateToStudyType(risType: String): StudyType { + return when (risType.trim().uppercase()) { + "JOUR", "EJOUR", "JFULL", "ABST", "INPR", "MGZN", "NEWS" -> StudyType.ARTICLE + "BOOK", "EBOOK", "EDBOOK" -> StudyType.BOOK + "CHAP", "ECHAP" -> StudyType.INBOOK + "CONF" -> StudyType.PROCEEDINGS + "CPAPER" -> StudyType.INPROCEEDINGS + "RPRT" -> StudyType.TECHREPORT + "THES" -> StudyType.MASTERSTHESIS + "PAMP" -> StudyType.BOOKLET + "MANSCPT", "UNPB", "UNPD" -> StudyType.UNPUBLISHED + "ADVS", "AGGR", "ANCIENT", "ART", "BILL", "BLOG", "CASE", "CHART", "CLSWK", "COMP", "CTLG", "DATA", "DBASE", "DICT", "ELEC", "ENCYC", "EQUA", "FIGURE", "GEN", "GOVDOC", "GRNT", "HEAR", "ICOMM", "INTV", "LEGAL", "MAP", "MPCT", "MULTI", "MUSIC", "PAT", "PCOMM", "POD", "PRESS", "SER", "SLIDE", "SOUND", "STAND", "STAT", "STD", "UNBILL", "VIDEO" -> StudyType.MISC + else -> StudyType.UNKNOWN + } } private fun getValueFromFieldMap(fieldMap: Map, keys: List): String { - for (key in keys) { - val value = fieldMap[key] - if (value != null) return value - } - return "" + return keys.firstNotNullOfOrNull { key -> fieldMap[key] } ?: "" } - private fun extractYear(y1: String): Int? { - val yearRegex = Regex("""\b\d{4}\b""") - return yearRegex.find(y1)?.value?.toIntOrNull() + private fun extractYear(fieldMap: Map): Int? { + val yearString = getValueFromFieldMap(fieldMap, yearTypes) + return yearString.let { Regex("""\b(\d{4})\b""").find(it)?.value?.toIntOrNull() } } private fun parseKeywords(keywords: String?): Set { - return keywords?.split(";")?.map { it.trim() }?.filter { it.isNotBlank() }?.toSet()?: emptySet() + return keywords?.split(';')?.map { it.trim() }?.filter { it.isNotBlank() }?.toSet() ?: emptySet() } private fun parseReferences(references: String?): List { - return references?.split(";")?.map { it.trim() }?.filter { it.isNotBlank() }?.toList()?: emptyList() + return references?.split(';')?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList() } private fun parseAuthors(fieldMap: Map): String { - val authorKeys = listOf("AU", "A1") - val authors = authorKeys.flatMap { key -> - fieldMap[key]?.split(";")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList() - } - return authors.joinToString(", ") - } - - private fun treatAbstract(abstract: String): String { - val AB = abstract.split("ER").first().trim() - return AB + val authors = getValueFromFieldMap(fieldMap, authorTypes) + return authors.split(';') + .map { it.trim() } + .filter { it.isNotBlank() } + .joinToString(separator = ", ") } - private fun extractInvalidRis(risEntry: String): String { - val risRegex = Regex("""TY\s*-\s*[\s\S]*?ER\s*-\s*""", RegexOption.MULTILINE) - val matchResult = risRegex.find(risEntry) - return matchResult?.value?.trim() ?: "UNKNOWN" + private fun extractTitleForError(risEntry: String): String { + val titleRegex = Regex("""^(?:TI|T1)\s*-\s*(.+)$""", RegexOption.MULTILINE) + return titleRegex.find(risEntry)?.groupValues?.get(1)?.trim() ?: "Unknown Title" } } \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/domain/shared/valueobject/Text.kt b/review/src/main/kotlin/br/all/domain/shared/valueobject/Text.kt deleted file mode 100644 index 0aebb109f..000000000 --- a/review/src/main/kotlin/br/all/domain/shared/valueobject/Text.kt +++ /dev/null @@ -1,29 +0,0 @@ -package br.all.domain.shared.valueobject - -import br.all.domain.shared.ddd.Notification -import br.all.domain.shared.ddd.ValueObject - -data class Text(val text: String) : ValueObject() { - - init { - val notification = validate() - require(notification.hasNoErrors()) { notification.message() } - } - - override fun validate(): Notification { - val notification = Notification() - - if (text.isEmpty()) notification.addError("Text must not be empty.") - if (text.isBlank()) notification.addError("Text must not be blank.") - - val pattern = Regex("^(?![!@#$%¨&*()_+='<>,.:;|/?`´^~{}\\[\\]\"-]+).*") - - - if(!pattern.matches(text)){ - notification.addError("Should be a valid text") - } - - return notification - } - -} \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/infrastructure/question/QuestionDbMapper.kt b/review/src/main/kotlin/br/all/infrastructure/question/QuestionDbMapper.kt index b0478ce56..4d275167d 100644 --- a/review/src/main/kotlin/br/all/infrastructure/question/QuestionDbMapper.kt +++ b/review/src/main/kotlin/br/all/infrastructure/question/QuestionDbMapper.kt @@ -3,27 +3,27 @@ package br.all.infrastructure.question import br.all.application.question.repository.QuestionDto fun QuestionDocument.toDto() = QuestionDto( - questionId, - systematicStudyId, - code, - description, - questionType, - scales, - higher, - lower, - options, - context + questionId = questionId, + systematicStudyId = systematicStudyId, + code = code, + description = description, + questionType = questionType, + scales = scales, + higher = higher, + lower = lower, + options = options, + context = context ) fun QuestionDto.toDocument() = QuestionDocument( - questionId, - systematicStudyId, - code, - description, - questionType, - scales, - higher, - lower, - options, - context + questionId = questionId, + systematicStudyId = systematicStudyId, + code = code, + description = description, + questionType = questionType, + scales = scales, + higher = higher, + lower = lower, + options = options, + context = context ) \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/infrastructure/review/SystematicStudyDbMapper.kt b/review/src/main/kotlin/br/all/infrastructure/review/SystematicStudyDbMapper.kt index 36368ba4d..6ea613b6c 100644 --- a/review/src/main/kotlin/br/all/infrastructure/review/SystematicStudyDbMapper.kt +++ b/review/src/main/kotlin/br/all/infrastructure/review/SystematicStudyDbMapper.kt @@ -8,6 +8,7 @@ fun SystematicStudyDto.toDocument() = SystematicStudyDocument( description, owner, collaborators, + objectives ) fun SystematicStudyDocument.toDto() = SystematicStudyDto( @@ -16,4 +17,5 @@ fun SystematicStudyDocument.toDto() = SystematicStudyDto( description, owner, collaborators, + objectives ) diff --git a/review/src/main/kotlin/br/all/infrastructure/review/SystematicStudyDocument.kt b/review/src/main/kotlin/br/all/infrastructure/review/SystematicStudyDocument.kt index 6e3e584c3..62414582c 100644 --- a/review/src/main/kotlin/br/all/infrastructure/review/SystematicStudyDocument.kt +++ b/review/src/main/kotlin/br/all/infrastructure/review/SystematicStudyDocument.kt @@ -11,4 +11,5 @@ data class SystematicStudyDocument( val description: String, val owner: UUID, val collaborators: Set = emptySet(), + val objectives: String, ) diff --git a/review/src/test/kotlin/br/all/application/protocol/find/FindProtocolStageServiceImplTest.kt b/review/src/test/kotlin/br/all/application/protocol/find/FindProtocolStageServiceImplTest.kt index d803ac572..07759b9c6 100644 --- a/review/src/test/kotlin/br/all/application/protocol/find/FindProtocolStageServiceImplTest.kt +++ b/review/src/test/kotlin/br/all/application/protocol/find/FindProtocolStageServiceImplTest.kt @@ -21,10 +21,13 @@ import java.util.UUID import kotlin.test.Test import br.all.application.protocol.find.FindProtocolStageService.RequestModel import br.all.application.protocol.find.FindProtocolStageService.ResponseModel -import br.all.application.protocol.find.FindProtocolStageService.ProtocolStage +import br.all.application.protocol.find.FindProtocolStageService.ProtocolStage // Assuming this enum is updated import br.all.application.protocol.repository.CriterionDto import br.all.application.protocol.repository.PicocDto +import br.all.application.protocol.repository.ProtocolDto import br.all.application.question.repository.QuestionRepository +import br.all.application.review.repository.SystematicStudyDto +import br.all.application.study.repository.StudyReviewDto import br.all.domain.model.question.QuestionContextEnum import io.mockk.verify @@ -80,125 +83,132 @@ class FindProtocolStageServiceImplTest { ) precondition.makeEverythingWork() + + every { systematicStudyRepository.findById(systematicStudyId) } returns createFullSystematicStudyDto() + + every { studyReviewRepository.findAllFromReview(any()) } returns emptyList() + every { questionRepository.findAllBySystematicStudyId(any(), any()) } returns emptyList() } @Nested - @DisplayName("When successfully getting protocol's current stage") + @DisplayName("When getting protocol 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 - ) + fun `should return GENERAL_DEFINITION stage (T1) when systematic study name is blank`() { + val incompleteStudyDto = createFullSystematicStudyDto(title = "") + val protocolDto = protocolFactory.protocolDto(systematicStudy = systematicStudyId) + every { systematicStudyRepository.findById(systematicStudyId) } returns incompleteStudyDto 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, RequestModel(researcherId, systematicStudyId)) + + val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.GENERAL_DEFINITION) + verify(exactly = 1) { presenter.prepareSuccessView(expectedResponse) } + } + + @Test + fun `should return GENERAL_DEFINITION stage (T1) when systematic study objectives is blank`() { + val incompleteStudyDto = createFullSystematicStudyDto(objectives = "") + val protocolDto = protocolFactory.protocolDto(systematicStudy = systematicStudyId) + + every { systematicStudyRepository.findById(systematicStudyId) } returns incompleteStudyDto + every { protocolRepository.findById(systematicStudyId) } returns protocolDto - sut.getStage(presenter, request) + sut.getStage(presenter, RequestModel(researcherId, systematicStudyId)) - val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.PROTOCOL_PART_I) + val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.GENERAL_DEFINITION) 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 - ) - ) + fun `should return RESEARCH_QUESTIONS stage (T2) when T1 is complete but no research questions exist`() { + val protocolDto = createFullProtocolDto(researchQuestions = 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, RequestModel(researcherId, systematicStudyId)) - sut.getStage(presenter, request) + val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.RESEARCH_QUESTIONS) + verify(exactly = 1) { presenter.prepareSuccessView(expectedResponse) } + } + + @Test + fun `should return PICOC stage (T3) when it is started but not complete`() { + val protocolDto = createFullProtocolDto( + picoc = PicocDto(population = "P", intervention = "I", control = null, outcome = null, context = null) + ) + + every { protocolRepository.findById(systematicStudyId) } returns protocolDto + + sut.getStage(presenter, RequestModel(researcherId, systematicStudyId)) 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() + fun `should return ELIGIBILITY_CRITERIA stage (T4) when T3 (PICOC) is skipped and T4 is incomplete`() { + val protocolDto = createFullProtocolDto( + picoc = PicocDto(null, null, null, null, null), + eligibilityCriteria = 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, RequestModel(researcherId, systematicStudyId)) - sut.getStage(presenter, request) - - val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.PROTOCOL_PART_II) + val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.ELIGIBILITY_CRITERIA) 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() - ) + fun `should return INFORMATION_SOURCES_AND_SEARCH_STRATEGY stage (T5) when T4 is complete but T5 is not`() { + val protocolDto = createFullProtocolDto(searchString = "") every { protocolRepository.findById(systematicStudyId) } returns protocolDto - every { studyReviewRepository.findAllFromReview(systematicStudyId) } returns emptyList() - every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.ROB) } returns emptyList() + + sut.getStage(presenter, RequestModel(researcherId, systematicStudyId)) + + val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.INFORMATION_SOURCES_AND_SEARCH_STRATEGY) + verify(exactly = 1) { presenter.prepareSuccessView(expectedResponse) } + } + + @Test + fun `should return SELECTION_AND_EXTRACTION stage (T6) when T5 is complete but T6 is not (no extraction questions)`() { + val protocolDto = createFullProtocolDto() + every { protocolRepository.findById(systematicStudyId) } returns protocolDto every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.EXTRACTION) } returns emptyList() - val request = RequestModel(researcherId, systematicStudyId) + sut.getStage(presenter, RequestModel(researcherId, systematicStudyId)) + + val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.SELECTION_AND_EXTRACTION) + verify(exactly = 1) { presenter.prepareSuccessView(expectedResponse) } + } + + @Test + fun `should return ANALYSIS_AND_SYNTHESIS_METHOD stage (T8) when T6 is complete but T8 is not`() { + val protocolDto = createFullProtocolDto(analysisAndSynthesisProcess = "") + every { protocolRepository.findById(systematicStudyId) } returns protocolDto + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.EXTRACTION) } returns listOf(questionFactory.generateTextualDto()) + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.ROB) } returns emptyList() - sut.getStage(presenter, request) + sut.getStage(presenter, RequestModel(researcherId, systematicStudyId)) - val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.PROTOCOL_PART_III) + val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.ANALYSIS_AND_SYNTHESIS_METHOD) verify(exactly = 1) { presenter.prepareSuccessView(expectedResponse) } } @Test - fun `should return IDENTIFICATION stage when no studies have been submitted`() { + fun `should return IDENTIFICATION stage when protocol is complete and no studies are imported`() { val protocolDto = createFullProtocolDto() - val questions = listOf( - questionFactory.generateTextualDto() - ) - every { protocolRepository.findById(systematicStudyId) } returns protocolDto + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.EXTRACTION) } returns listOf(questionFactory.generateTextualDto()) + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.ROB) } returns listOf(questionFactory.generateTextualDto()) 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) + sut.getStage(presenter, RequestModel(researcherId, systematicStudyId)) val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.IDENTIFICATION) verify(exactly = 1) { presenter.prepareSuccessView(expectedResponse) } @@ -207,116 +217,110 @@ class FindProtocolStageServiceImplTest { @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() - ) - + val studies = listOf(createFullStudyReviewDto(selectionStatus = "EXCLUDED")) every { protocolRepository.findById(systematicStudyId) } returns protocolDto + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.EXTRACTION) } returns listOf(questionFactory.generateTextualDto()) + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.ROB) } returns listOf(questionFactory.generateTextualDto()) 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) + sut.getStage(presenter, RequestModel(researcherId, systematicStudyId)) 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`() { + fun `should return EXTRACTION stage when studies are included but not extracted`() { val protocolDto = createFullProtocolDto() - val studies = listOf( - createFullStudyReviewDto(selectionStatus = "INCLUDED", extractionStatus = "") - ) - val questions = listOf( - questionFactory.generateTextualDto() - ) - + val studies = listOf(createFullStudyReviewDto(selectionStatus = "INCLUDED", extractionStatus = "PENDING")) every { protocolRepository.findById(systematicStudyId) } returns protocolDto + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.EXTRACTION) } returns listOf(questionFactory.generateTextualDto()) + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.ROB) } returns listOf(questionFactory.generateTextualDto()) 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) + sut.getStage(presenter, RequestModel(researcherId, systematicStudyId)) val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.EXTRACTION) verify(exactly = 1) { presenter.prepareSuccessView(expectedResponse) } } @Test - fun `should return GRAPHICS stage when studies have been extracted`() { + fun `should return GRAPHICS stage when at least one study has been fully extracted`() { val protocolDto = createFullProtocolDto() - val studies = listOf( - createFullStudyReviewDto(selectionStatus = "INCLUDED", extractionStatus = "INCLUDED") - ) - val questions = listOf( - questionFactory.generateTextualDto() - ) - + val studies = listOf(createFullStudyReviewDto(selectionStatus = "INCLUDED", extractionStatus = "INCLUDED")) every { protocolRepository.findById(systematicStudyId) } returns protocolDto + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.EXTRACTION) } returns listOf(questionFactory.generateTextualDto()) + every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.ROB) } returns listOf(questionFactory.generateTextualDto()) 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) + sut.getStage(presenter, RequestModel(researcherId, systematicStudyId)) val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.GRAPHICS) verify(exactly = 1) { presenter.prepareSuccessView(expectedResponse) } } } + private fun createFullSystematicStudyDto( + id: UUID = systematicStudyId, + title: String = "A complete systematic study", + description: String = "A complete description", + objectives: String = "A complete objective", + ) = SystematicStudyDto( + id = id, + title = title, + description = description, + owner = UUID.randomUUID(), + collaborators = emptySet(), + objectives = objectives, + ) - 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") + private fun createFullProtocolDto( + goal: String? = "A complete goal", + researchQuestions: Set = setOf("RQ1?"), + picoc: PicocDto? = PicocDto("P", "I", "C", "O", "Context"), + eligibilityCriteria: Set = setOf( + CriterionDto("Inclusion", "INCLUSION"), + CriterionDto("Exclusion", "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" - ) + studyTypeDefinition: String? = "Randomized Controlled Trial", + studiesLanguages: Set = setOf("English"), + sourcesSelectionCriteria: String? = "Selection criteria", + informationSources: Set = setOf("Scopus"), + searchMethod: String? = "A valid search method", + keywords: Set = setOf("keyword1"), + searchString: String? = "((keyword1) AND (keyword2))", + selectionProcess: String? = "A valid selection process", + dataCollectionProcess: String? = "A valid data collection process", + analysisAndSynthesisProcess: String? = "A final analysis process" + ): ProtocolDto { + val baseDto = protocolFactory.protocolDto( + systematicStudy = systematicStudyId, + goal = goal, + justification = "A justification", + researchQuestions = researchQuestions, + picoc = picoc, + eligibilityCriteria = eligibilityCriteria, + studiesLanguages = studiesLanguages, + sourcesSelectionCriteria = sourcesSelectionCriteria, + informationSources = informationSources, + searchMethod = searchMethod, + keywords = keywords, + selectionProcess = selectionProcess, + analysisAndSynthesisProcess = analysisAndSynthesisProcess + ) + return baseDto.copy( + studyTypeDefinition = studyTypeDefinition, + searchString = searchString, + dataCollectionProcess = dataCollectionProcess + ) + } - private fun createFullStudyReviewDto(selectionStatus: String, extractionStatus: String) = studyReviewFactory.generateDto( - studyReviewId = 1L, + private fun createFullStudyReviewDto( + selectionStatus: String, + extractionStatus: String = "PENDING" + ) = studyReviewFactory.generateDto( 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 + extractionStatus = extractionStatus, ) -} +} \ No newline at end of file diff --git a/review/src/test/kotlin/br/all/application/question/create/CreateQuestionServiceImplTest.kt b/review/src/test/kotlin/br/all/application/question/create/CreateQuestionServiceImplTest.kt index c4b539262..3d8b889cf 100644 --- a/review/src/test/kotlin/br/all/application/question/create/CreateQuestionServiceImplTest.kt +++ b/review/src/test/kotlin/br/all/application/question/create/CreateQuestionServiceImplTest.kt @@ -64,6 +64,7 @@ class CreateQuestionServiceImplTest { TEXTUAL -> factory.createTextualRequestModel() NUMBERED_SCALE -> factory.createNumberedScaleRequestModel() PICK_LIST -> factory.createPickListRequestModel() + PICK_MANY -> factory.createPickManyRequestModel() LABELED_SCALE -> factory.createLabeledScaleRequestModel() } @@ -104,6 +105,36 @@ class CreateQuestionServiceImplTest { } } + @Test + fun `should not be able to create numbered-scale if lower boundary is greater than higher boundary`() { + val request = factory.createNumberedScaleRequestModel(lower = 10, higher = 5) + preconditionCheckerMocking.makeEverythingWork() + every { uuidGeneratorService.next() } returns factory.question + + sut.create(presenter, request) + + verify { presenter.prepareFailView(any()) } + verify(exactly = 0) { repository.createOrUpdate(any()) } + } + + @ParameterizedTest + @EnumSource(value = QuestionType::class, names = ["PICK_LIST", "PICK_MANY"]) + fun `should not be able to create a question with a blank option in the list`(questionType: QuestionType) { + val blankOptions = listOf("Valid Option 1", " ", "Valid Option 2") + val request = when (questionType) { + PICK_LIST -> factory.createPickListRequestModel(options = blankOptions) + PICK_MANY -> factory.createPickManyRequestModel(options = blankOptions) + else -> throw IllegalStateException("Test configuration error") + } + preconditionCheckerMocking.makeEverythingWork() + every { uuidGeneratorService.next() } returns factory.question + + sut.create(presenter, request) + + verify { presenter.prepareFailView(any()) } + verify(exactly = 0) { repository.createOrUpdate(any()) } + } + @Test fun `should not be able to create picklist question if options is empty`() { val request = factory.createPickListRequestModel(options = emptyList()) @@ -119,6 +150,21 @@ class CreateQuestionServiceImplTest { } } + @Test + fun `should not be able to create pickmany question if options is empty`() { + val request = factory.createPickManyRequestModel(options = emptyList()) + val (_, _, question) = factory + + every { uuidGeneratorService.next() } returns question + preconditionCheckerMocking.makeEverythingWork() + + sut.create(presenter, request) + verify { + presenter.isDone() + presenter.prepareFailView(any()) + } + } + @Test fun `should not be able to create labeledScale question if scales is empty`() { val request = factory.createLabeledScaleRequestModel(scales = emptyMap()) diff --git a/review/src/test/kotlin/br/all/application/question/find/FindQuestionServiceImplTest.kt b/review/src/test/kotlin/br/all/application/question/find/FindQuestionServiceImplTest.kt index 1f09a1877..05a874799 100644 --- a/review/src/test/kotlin/br/all/application/question/find/FindQuestionServiceImplTest.kt +++ b/review/src/test/kotlin/br/all/application/question/find/FindQuestionServiceImplTest.kt @@ -62,6 +62,7 @@ class FindQuestionServiceImplTest { QuestionType.TEXTUAL -> factory.findOneTextualResponseModel() QuestionType.NUMBERED_SCALE -> factory.findOneNumberedScaleResponseModel() QuestionType.PICK_LIST -> factory.findOnePickListResponseModel() + QuestionType.PICK_MANY -> factory.findOnePickManyResponseModel() QuestionType.LABELED_SCALE -> factory.findOneLabeledScaleResponseModel() } diff --git a/review/src/test/kotlin/br/all/application/question/update/services/UpdateQuestionServiceImplTest.kt b/review/src/test/kotlin/br/all/application/question/update/services/UpdateQuestionServiceImplTest.kt index 17dd479eb..cdda6df44 100644 --- a/review/src/test/kotlin/br/all/application/question/update/services/UpdateQuestionServiceImplTest.kt +++ b/review/src/test/kotlin/br/all/application/question/update/services/UpdateQuestionServiceImplTest.kt @@ -5,11 +5,8 @@ import br.all.application.question.create.CreateQuestionService.QuestionType.* import br.all.application.question.repository.QuestionRepository import br.all.application.question.update.presenter.UpdateQuestionPresenter import br.all.application.question.util.TestDataFactory -import br.all.application.user.credentials.ResearcherCredentialsService import br.all.application.review.repository.SystematicStudyRepository -import br.all.application.search.repository.SearchSessionRepository import br.all.application.user.CredentialsService -import br.all.application.util.PreconditionCheckerMocking import br.all.application.util.PreconditionCheckerMockingNew import br.all.domain.services.UuidGeneratorService import io.mockk.every @@ -20,7 +17,11 @@ import io.mockk.verify import org.junit.jupiter.api.* import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.EnumSource +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream +import kotlin.collections.emptyList @Tag("UnitTest") @Tag("ServiceTest") @@ -73,6 +74,7 @@ class UpdateQuestionServiceImplTest { TEXTUAL -> factory.generateTextualDto() NUMBERED_SCALE -> factory.generateNumberedScaleDto() PICK_LIST -> factory.generatePickListDto() + PICK_MANY -> factory.generatePickManyDto() LABELED_SCALE -> factory.generateLabeledScaleDto() } val updatedDto = dto.copy(code = "new code") @@ -95,6 +97,7 @@ class UpdateQuestionServiceImplTest { TEXTUAL -> factory.generateTextualDto() NUMBERED_SCALE -> factory.generateNumberedScaleDto() PICK_LIST -> factory.generatePickListDto() + PICK_MANY -> factory.generatePickManyDto() LABELED_SCALE -> factory.generateLabeledScaleDto() } val updatedDto = dto.copy(description = "new description") @@ -125,6 +128,22 @@ class UpdateQuestionServiceImplTest { } } + @Test + fun `should successfully update a pickmany question list of options`() { + val dto = factory.generatePickManyDto() + val updatedDto = dto.copy(options = listOf("new option 1", " new option 2")) + val request = factory.updateQuestionRequestModel(updatedDto, PICK_MANY) + val response = factory.updateQuestionResponseModel() + + every { repository.findById(factory.systematicStudy, factory.question) } returns dto + sut.update(presenter, request) + + verify { + repository.createOrUpdate(updatedDto) + presenter.prepareSuccessView(response) + } + } + @Test fun `should successfully update a labeled-scale question map of labels`() { val dto = factory.generateLabeledScaleDto() @@ -171,7 +190,93 @@ class UpdateQuestionServiceImplTest { @Nested @Tag("InvalidClasses") @DisplayName("When not able to update a question") - inner class WhenNotAbleToUpdateAQuestion{ + inner class WhenNotAbleToUpdateAQuestion { + @BeforeEach + fun setUp() = run { preconditionCheckerMocking.makeEverythingWork() } + + @ParameterizedTest(name = "should fail when updating a PICK_LIST with {0} options") + @MethodSource("br.all.application.question.update.services.UpdateQuestionServiceImplTest#provideNullAndEmptyLists") + fun `should fail when updating a PICK_LIST with null or empty options`(options: List?) { + val dto = factory.generatePickListDto().copy(options = options) + val request = factory.updateQuestionRequestModel(dto, PICK_LIST) + + sut.update(presenter, request) + + verify { presenter.prepareFailView(any()) } + verify(exactly = 0) { repository.createOrUpdate(any()) } + } + + @ParameterizedTest(name = "should fail when updating a PICK_MANY with {0} options") + @MethodSource("br.all.application.question.update.services.UpdateQuestionServiceImplTest#provideNullAndEmptyLists") + fun `should fail when updating a PICK_MANY with null or empty options`(options: List?) { + val dto = factory.generatePickManyDto().copy(options = options) + val request = factory.updateQuestionRequestModel(dto, PICK_MANY) + + sut.update(presenter, request) + + verify { presenter.prepareFailView(any()) } + verify(exactly = 0) { repository.createOrUpdate(any()) } + } + + @Test + fun `should fail when updating a LABELED_SCALE with a null map of scales`() { + val dto = factory.generateLabeledScaleDto().copy(scales = null) + val request = factory.updateQuestionRequestModel(dto, LABELED_SCALE) + sut.update(presenter, request) + + verify { presenter.prepareFailView(any()) } + verify(exactly = 0) { repository.createOrUpdate(any()) } + } + + @Test + fun `should fail when updating a NUMBERED_SCALE with a null lower boundary`() { + val dto = factory.generateNumberedScaleDto().copy(lower = null) + val request = factory.updateQuestionRequestModel(dto, NUMBERED_SCALE) + + sut.update(presenter, request) + + verify { presenter.prepareFailView(any()) } + verify(exactly = 0) { repository.createOrUpdate(any()) } + } + + @Test + fun `should fail when updating a NUMBERED_SCALE where lower boundary is greater than higher boundary`() { + val dto = factory.generateNumberedScaleDto().copy(lower = 10, higher = 5) + val request = factory.updateQuestionRequestModel(dto, NUMBERED_SCALE) + + sut.update(presenter, request) + + verify { presenter.prepareFailView(any()) } + verify(exactly = 0) { repository.createOrUpdate(any()) } + } + + @ParameterizedTest + @EnumSource(value = QuestionType::class, names = ["PICK_LIST", "PICK_MANY"]) + @DisplayName("should fail when updating a PICK_LIST or PICK_MANY with a list with a blank option") + fun `should fail when an option in a list is blank`(questionType: QuestionType) { + val options = listOf("Valid Option", " ", "Another Valid") + val dto = when (questionType) { + PICK_LIST -> factory.generatePickListDto().copy(options = options) + PICK_MANY -> factory.generatePickManyDto().copy(options = options) + else -> throw IllegalStateException("Unsupported type for this test") + } + val request = factory.updateQuestionRequestModel(dto, questionType) + + sut.update(presenter, request) + + verify { presenter.prepareFailView(any()) } + verify(exactly = 0) { repository.createOrUpdate(any()) } + } + } + + companion object { + @JvmStatic + fun provideNullAndEmptyLists(): Stream { + return Stream.of( + Arguments.of(null), + Arguments.of(emptyList()), + ) + } } } \ No newline at end of file diff --git a/review/src/test/kotlin/br/all/application/question/util/TestDataFactory.kt b/review/src/test/kotlin/br/all/application/question/util/TestDataFactory.kt index f1bdb5c59..0e229a3c7 100644 --- a/review/src/test/kotlin/br/all/application/question/util/TestDataFactory.kt +++ b/review/src/test/kotlin/br/all/application/question/util/TestDataFactory.kt @@ -50,6 +50,22 @@ class TestDataFactory { context = QuestionContextEnum.EXTRACTION ) + fun generatePickManyDto( + questionId: UUID = question, + systematicStudyId: UUID = systematicStudy, + ) = QuestionDto( + questionId, + systematicStudyId, + code, + description, + "PICK_MANY", + null, + null, + null, + listOf(faker.lorem.words(), faker.lorem.words()), + context = QuestionContextEnum.EXTRACTION + ) + fun generateLabeledScaleDto( questionId: UUID = question, systematicStudyId: UUID = systematicStudy, @@ -139,6 +155,25 @@ class TestDataFactory { options ) + fun createPickManyRequestModel( + researcherId: UUID = researcher, + systematicStudyId: UUID = systematicStudy, + questionType: QuestionType = QuestionType.PICK_MANY, + options: List? = listOf(faker.lorem.words(), faker.lorem.words()), + context: String = QuestionContextEnum.EXTRACTION.toString(), + ) = RequestModel( + researcherId, + systematicStudyId, + context, + questionType, + code, + description, + null, + null, + null, + options + ) + fun createLabeledScaleRequestModel( researcherId: UUID = researcher, systematicStudyId: UUID = systematicStudy, @@ -202,6 +237,11 @@ class TestDataFactory { questionDto: QuestionDto = generatePickListDto() ) = Find.ResponseModel(researcherId, questionDto) + fun findOnePickManyResponseModel( + researcherId: UUID = this.researcher, + questionDto: QuestionDto = generatePickManyDto() + ) = Find.ResponseModel(researcherId, questionDto) + fun findOneLabeledScaleResponseModel( researcherId: UUID = this.researcher, questionDto: QuestionDto = generateLabeledScaleDto() diff --git a/review/src/test/kotlin/br/all/application/review/util/TestDataFactory.kt b/review/src/test/kotlin/br/all/application/review/util/TestDataFactory.kt index fd03b3921..a12ad5c15 100644 --- a/review/src/test/kotlin/br/all/application/review/util/TestDataFactory.kt +++ b/review/src/test/kotlin/br/all/application/review/util/TestDataFactory.kt @@ -30,12 +30,14 @@ class TestDataFactory { description: String = faker.lorem.words(), ownerId: UUID = researcher, collaborators: Set = emptySet(), + objectives: String = faker.appliance.equipment() ) = SystematicStudyDto( id, title, description, ownerId, mutableSetOf(ownerId).also { it.addAll(collaborators) }, + objectives, ) fun protocolDto(systematicStudyId: UUID = systematicStudy) = Protocol @@ -47,8 +49,9 @@ class TestDataFactory { researcherId: UUID = researcher, title: String = faker.book.title(), description: String = faker.lorem.words(), - collaborators: Set = emptySet() - ) = CreateRequestModel(researcherId, title, description, collaborators) + collaborators: Set = emptySet(), + objective: String = faker.appliance.equipment(), + ) = CreateRequestModel(researcherId, title, description, collaborators, objective) fun createResponseModel( researcherId: UUID = researcher, @@ -110,7 +113,8 @@ class TestDataFactory { systematicStudyId: UUID = systematicStudy, title: String? = null, description: String? = null, - ) = UpdateRequestModel(researcherId, systematicStudyId, title, description) + objectives: String? = null, + ) = UpdateRequestModel(researcherId, systematicStudyId, title, description, objectives) fun updateResponseModel( researcherId: UUID = this.researcher, 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 index c32915128..c8cd1ec5f 100644 --- a/review/src/test/kotlin/br/all/application/study/update/BatchAnswerQuestionServiceImplTest.kt +++ b/review/src/test/kotlin/br/all/application/study/update/BatchAnswerQuestionServiceImplTest.kt @@ -107,6 +107,149 @@ class BatchAnswerQuestionServiceImplTest { } } + @Test + fun `should successfully answer a single pick-many question`() { + val reviewDto = factory.generateDto() + + val pickManyQId = UUID.randomUUID() + val pickManyOptions = listOf("Java", "Kotlin", "Go", "Rust") + val pickManyQDto = factory.generateQuestionPickManyDto(pickManyQId, factory.systematicStudyId, options = pickManyOptions, questionContext = "EXTRACTION") + val pickManyAnswer = factory.answerDetail(questionId = pickManyQId, type = "PICK_MANY", answer = listOf("Kotlin", "Rust")) + + val request = factory.batchAnswerRequest(listOf(pickManyAnswer)) + + preconditionCheckerMocking.makeEverythingWork() + every { studyReviewRepository.findById(any(), any()) } returns reviewDto + every { questionRepository.findById(any(), pickManyQId) } returns pickManyQDto + + sut.batchAnswerQuestion(presenter, request) + + verify(exactly = 1) { studyReviewRepository.saveOrUpdate(any()) } + verify(exactly = 1) { + presenter.prepareSuccessView(withArg { response -> + assertEquals(1, response.succeededAnswers.size) + assertTrue(response.failedAnswers.isEmpty()) + assertEquals(pickManyQId, response.succeededAnswers.first()) + }) + } + } + + @Test + fun `should successfully answer three pick-many questions together`() { + val reviewDto = factory.generateDto() + + val q1Id = UUID.randomUUID() + val q1Dto = factory.generateQuestionPickManyDto(q1Id, factory.systematicStudyId, options = listOf("A", "B", "C"), questionContext = "EXTRACTION") + val a1 = factory.answerDetail(q1Id, "PICK_MANY", listOf("A", "C")) + + val q2Id = UUID.randomUUID() + val q2Dto = factory.generateQuestionPickManyDto(q2Id, factory.systematicStudyId, options = listOf("X", "Y"), questionContext = "EXTRACTION") + val a2 = factory.answerDetail(q2Id, "PICK_MANY", listOf("Y")) + + val q3Id = UUID.randomUUID() + val q3Dto = factory.generateQuestionPickManyDto(q3Id, factory.systematicStudyId, options = listOf("1", "2", "3"), questionContext = "EXTRACTION") + val a3 = factory.answerDetail(q3Id, "PICK_MANY", listOf("1", "2", "3")) + + val request = factory.batchAnswerRequest(listOf(a1, a2, a3)) + + preconditionCheckerMocking.makeEverythingWork() + every { studyReviewRepository.findById(any(), any()) } returns reviewDto + every { questionRepository.findById(any(), q1Id) } returns q1Dto + every { questionRepository.findById(any(), q2Id) } returns q2Dto + every { questionRepository.findById(any(), q3Id) } returns q3Dto + + sut.batchAnswerQuestion(presenter, request) + + verify(exactly = 1) { + presenter.prepareSuccessView(withArg { response -> + assertEquals(3, response.succeededAnswers.size) + assertTrue(response.failedAnswers.isEmpty()) + assertTrue(response.succeededAnswers.containsAll(listOf(q1Id, q2Id, q3Id))) + }) + } + } + + @Test + fun `should successfully answer all question types together in a single batch`() { + val reviewDto = factory.generateDto() + + val textualQId = UUID.randomUUID() + val textualQDto = factory.generateQuestionTextualDto(textualQId, factory.systematicStudyId, questionContext = "EXTRACTION") + val textualAnswer = factory.answerDetail(textualQId, "TEXTUAL", "Comprehensive answer") + + val pickListQId = UUID.randomUUID() + val pickListQDto = factory.generateQuestionPickListDto(pickListQId, factory.systematicStudyId, options = listOf("Yes", "No"), questionContext = "EXTRACTION") + val pickListAnswer = factory.answerDetail(pickListQId, "PICK_LIST", "Yes") + + val pickManyQId = UUID.randomUUID() + val pickManyQDto = factory.generateQuestionPickManyDto(pickManyQId, factory.systematicStudyId, options = listOf("High", "Medium", "Low"), questionContext = "EXTRACTION") + val pickManyAnswer = factory.answerDetail(pickManyQId, "PICK_MANY", listOf("High", "Low")) + + val numberedQId = UUID.randomUUID() + val numberedQDto = factory.generateQuestionNumberedScaleDto(numberedQId, factory.systematicStudyId, higher = 10, lower = 0, questionContext = "ROB") + val numberedAnswer = factory.answerDetail(numberedQId, "NUMBERED_SCALE", 7) + + val labeledScaleQId = UUID.randomUUID() + val labelDto = BatchAnswerQuestionService.LabelDto("Critical", 5) + val labeledScaleQDto = factory.generateQuestionLabeledScaleDto(labeledScaleQId, factory.systematicStudyId, labelDto = labelDto, questionContext = "ROB") + val labeledScaleAnswer = factory.answerDetail(labeledScaleQId, "LABELED_SCALE", labelDto) + + val request = factory.batchAnswerRequest(listOf(textualAnswer, pickListAnswer, pickManyAnswer, numberedAnswer, labeledScaleAnswer)) + + preconditionCheckerMocking.makeEverythingWork() + every { studyReviewRepository.findById(any(), any()) } returns reviewDto + every { questionRepository.findById(any(), textualQId) } returns textualQDto + every { questionRepository.findById(any(), pickListQId) } returns pickListQDto + every { questionRepository.findById(any(), pickManyQId) } returns pickManyQDto + every { questionRepository.findById(any(), numberedQId) } returns numberedQDto + every { questionRepository.findById(any(), labeledScaleQId) } returns labeledScaleQDto + + sut.batchAnswerQuestion(presenter, request) + + verify(exactly = 1) { studyReviewRepository.saveOrUpdate(any()) } + verify(exactly = 1) { + presenter.prepareSuccessView(withArg { response -> + assertEquals(5, response.totalAnswered) + assertTrue(response.failedAnswers.isEmpty()) + val expectedIds = listOf(textualQId, pickListQId, pickManyQId, numberedQId, labeledScaleQId) + assertTrue(response.succeededAnswers.containsAll(expectedIds)) + }) + } + } + + @Test + fun `should successfully answer pick-many and labeled-scale questions`() { + val reviewDto = factory.generateDto() + + val pickManyQId = UUID.randomUUID() + val pickManyOptions = listOf("Option 1", "Option 2", "Option 3") + val pickManyQDto = factory.generateQuestionPickManyDto(pickManyQId, factory.systematicStudyId, options = pickManyOptions, questionContext = "EXTRACTION") + val pickManyAnswer = factory.answerDetail(questionId = pickManyQId, type = "PICK_MANY", answer = listOf("Option 1", "Option 3")) + + val labeledScaleQId = UUID.randomUUID() + val labelDto = BatchAnswerQuestionService.LabelDto("Good", 3) + val labeledScaleQDto = factory.generateQuestionLabeledScaleDto(labeledScaleQId, factory.systematicStudyId, labelDto = labelDto, questionContext = "ROB") + val labeledScaleAnswer = factory.answerDetail(questionId = labeledScaleQId, type = "LABELED_SCALE", answer = labelDto) + + val request = factory.batchAnswerRequest(listOf(pickManyAnswer, labeledScaleAnswer)) + + preconditionCheckerMocking.makeEverythingWork() + every { studyReviewRepository.findById(any(), any()) } returns reviewDto + every { questionRepository.findById(any(), pickManyQId) } returns pickManyQDto + every { questionRepository.findById(any(), labeledScaleQId) } returns labeledScaleQDto + + sut.batchAnswerQuestion(presenter, request) + + verify(exactly = 1) { studyReviewRepository.saveOrUpdate(any()) } + verify(exactly = 1) { + presenter.prepareSuccessView(withArg { response -> + assertEquals(2, response.succeededAnswers.size) + assertTrue(response.failedAnswers.isEmpty()) + assertTrue(response.succeededAnswers.containsAll(listOf(pickManyQId, labeledScaleQId))) + }) + } + } + @Test fun `should handle a mix of successful and failed answers`() { val reviewDto = factory.generateDto() @@ -179,6 +322,30 @@ class BatchAnswerQuestionServiceImplTest { @Nested @DisplayName("When failing to answer questions") inner class WhenFailingToAnswerQuestions { + + @Test + fun `should create a failed answer entry for a pick-list answer not in options`() { + val reviewDto = factory.generateDto() + val questionId = UUID.randomUUID() + val questionDto = factory.generateQuestionPickListDto(questionId, factory.systematicStudyId, options = listOf("A", "B"), questionContext = "EXTRACTION") + val answerDetail = factory.answerDetail(questionId, "PICK_LIST", "C") + 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) + + verify(exactly = 1) { + presenter.prepareSuccessView(withArg { response -> + assertTrue(response.succeededAnswers.isEmpty()) + assertEquals(1, response.failedAnswers.size) + assertTrue("must be one of the valid options" in response.failedAnswers.first().reason) + }) + } + } + @Test fun `should create a failed answer entry for a question with conflicting types`() { val reviewDto = factory.generateDto() @@ -216,7 +383,7 @@ class BatchAnswerQuestionServiceImplTest { every { studyReviewRepository.findById(any(), any()) } returns reviewDto every { questionRepository.findById(any(), questionId) } returns questionDto - sut.batchAnswerQuestion(presenter, request) // Updated call + sut.batchAnswerQuestion(presenter, request) verify(exactly = 1) { presenter.prepareSuccessView(withArg { response -> @@ -242,7 +409,7 @@ class BatchAnswerQuestionServiceImplTest { every { studyReviewRepository.findById(any(), any()) } returns reviewDto every { questionRepository.findById(any(), questionId) } returns questionDto - sut.batchAnswerQuestion(presenter, request) // Updated call + sut.batchAnswerQuestion(presenter, request) verify(exactly = 1) { presenter.prepareSuccessView(withArg { response -> 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 7cc0bbc18..64962c363 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 @@ -217,6 +217,27 @@ class TestDataFactory { QuestionContextEnum.valueOf(questionContext), ) + fun generateQuestionPickManyDto( + questionId: UUID, + systematicStudyId: UUID = this.systematicStudyId, + code: String = faker.lorem.words(), + description: String = faker.lorem.words(), + options: List, + questionContext: String + ) = + QuestionDto( + questionId, + systematicStudyId, + code, + description, + "PICK_MANY", + null, + null, + null, + options, + QuestionContextEnum.valueOf(questionContext), + ) + fun batchAnswerRequest( answers: List, researcherId: UUID = this.researcherId, diff --git a/review/src/test/kotlin/br/all/application/util/PreconditionCheckerMockingNew.kt b/review/src/test/kotlin/br/all/application/util/PreconditionCheckerMockingNew.kt index 350e3865d..62fa50e8c 100644 --- a/review/src/test/kotlin/br/all/application/util/PreconditionCheckerMockingNew.kt +++ b/review/src/test/kotlin/br/all/application/util/PreconditionCheckerMockingNew.kt @@ -122,12 +122,14 @@ class PreconditionCheckerMockingNew( description: String = faker.lorem.words(), ownerId: UUID = userId, collaborators: Set = emptySet(), + objectives: String = faker.beer.brand() ) = SystematicStudyDto( id, title, description, ownerId, mutableSetOf(ownerId).also { it.addAll(collaborators) }, + objectives ) private fun generateUserDto( diff --git a/review/src/test/kotlin/br/all/domain/model/question/PickManyTest.kt b/review/src/test/kotlin/br/all/domain/model/question/PickManyTest.kt new file mode 100644 index 000000000..35eea3e9d --- /dev/null +++ b/review/src/test/kotlin/br/all/domain/model/question/PickManyTest.kt @@ -0,0 +1,108 @@ +package br.all.domain.model.question + +import br.all.domain.model.review.SystematicStudyId +import br.all.domain.model.study.Answer +import io.github.serpro69.kfaker.Faker +import org.junit.jupiter.api.* +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.util.* +import kotlin.test.assertEquals + +@Tag("UnitTest") +class PickManyTest { + private val faker = Faker() + private lateinit var validPickMany: PickMany + + @BeforeEach + fun setUp() { + val validOptions = listOf(faker.lorem.words(), faker.lorem.words(), faker.lorem.words()) + validPickMany = PickMany( + QuestionId(UUID.randomUUID()), + SystematicStudyId(UUID.randomUUID()), + faker.lorem.words(), + faker.lorem.words(), + validOptions + ) + } + + @Nested + @Tag("ValidClasses") + @DisplayName("When successfully answering a pick-many question") + inner class WhenSuccessfullyAnsweringPickManyQuestion { + @Test + fun `should answer with a single valid option from the list`() { + val chosenAnswer = listOf(validPickMany.options[1]) + val expectedAnswer = Answer(validPickMany.id.value(), chosenAnswer) + + val actualAnswer = validPickMany.answer(chosenAnswer) + + assertEquals(expectedAnswer, actualAnswer) + } + + @Test + fun `should answer with multiple valid options from the list`() { + val chosenAnswer = listOf(validPickMany.options[0], validPickMany.options[2]) + val expectedAnswer = Answer(validPickMany.id.value(), chosenAnswer) + + val actualAnswer = validPickMany.answer(chosenAnswer) + + assertEquals(expectedAnswer, actualAnswer) + } + } + + @Nested + @Tag("InvalidClasses") + @DisplayName("When being unable to create or answer PickMany questions") + inner class WhenBeingUnableToCreateOrAnswerPickMany { + + @Test + fun `should throw IllegalArgumentException for an empty options list during creation`() { + assertThrows { + PickMany( + QuestionId(UUID.randomUUID()), + SystematicStudyId(UUID.randomUUID()), + faker.lorem.words(), + faker.lorem.words(), + emptyList() + ) + } + } + + @ParameterizedTest(name = "[{index}]: option = \"{0}\"") + @ValueSource(strings = ["", " ", " "]) + fun `should throw IllegalArgumentException for a blank option during creation`(option: String) { + val options = listOf(faker.lorem.words(), option) + + assertThrows { + PickMany( + QuestionId(UUID.randomUUID()), + SystematicStudyId(UUID.randomUUID()), + faker.lorem.words(), + faker.lorem.words(), + options + ) + } + } + + @Test + fun `should throw IllegalArgumentException for an empty answer list`() { + assertThrows { validPickMany.answer(emptyList()) } + } + + @ParameterizedTest(name = "[{index}]: value = \"{0}\"") + @ValueSource(strings = ["", " ", " "]) + fun `should throw IllegalArgumentException for a blank value in the answer list`(value: String) { + val invalidAnswer = listOf(validPickMany.options.first(), value) + + assertThrows { validPickMany.answer(invalidAnswer) } + } + + @Test + fun `should throw IllegalArgumentException for a value in the answer list that is not in options`() { + val invalidAnswer = listOf(validPickMany.options.first(), "invalid option") + + assertThrows { validPickMany.answer(invalidAnswer) } + } + } +} \ No newline at end of file diff --git a/review/src/test/kotlin/br/all/domain/model/review/SystematicStudyTest.kt b/review/src/test/kotlin/br/all/domain/model/review/SystematicStudyTest.kt index 664865e91..dafc3229c 100644 --- a/review/src/test/kotlin/br/all/domain/model/review/SystematicStudyTest.kt +++ b/review/src/test/kotlin/br/all/domain/model/review/SystematicStudyTest.kt @@ -17,7 +17,7 @@ class SystematicStudyTest { fun setUp() { val systematicStudyId = SystematicStudyId(UUID.randomUUID()) val owner = ResearcherId(UUID.randomUUID()) - sut = SystematicStudy(systematicStudyId, "Some title", "Some description", owner) + sut = SystematicStudy(systematicStudyId, "Some title", "Some description", owner, objectives = "Some objective") } @Nested @@ -33,14 +33,14 @@ class SystematicStudyTest { val owner = ResearcherId(UUID.randomUUID()) val collaborators = mutableSetOf(ResearcherId(UUID.randomUUID())) - assertDoesNotThrow { SystematicStudy(id, "Title", "Description", owner, collaborators) } + assertDoesNotThrow { SystematicStudy(id, "Title", "Description", owner, collaborators, "Objectives") } } @Test fun `should owner be a collaborator`() { val id = SystematicStudyId(UUID.randomUUID()) val ownerId = ResearcherId(UUID.randomUUID()) - val sut = SystematicStudy(id, "Title", "Description", ownerId, mutableSetOf()) + val sut = SystematicStudy(id, "Title", "Description", ownerId, mutableSetOf(), "Objectives") assertTrue(ownerId in sut.collaborators) } @@ -51,12 +51,12 @@ class SystematicStudyTest { @DisplayName("And providing invalid states") inner class AndProvidingInvalidStates { @ParameterizedTest(name = "[{index}]: title=\"{0}\", description=\"{1}\"") - @CsvSource("'',Some description", "Some title,''", "'',''") - fun `should not create systematic study without title or description`(title: String, description: String){ + @CsvSource("'','Some description',''", "'Some title','',''", "'','',Some objective") + fun `should not create systematic study without title, description or objective`(title: String, description: String, objectives: String){ val id = SystematicStudyId(UUID.randomUUID()) val owner = ResearcherId(UUID.randomUUID()) - assertThrows { SystematicStudy(id, title, description, owner) } + assertThrows { SystematicStudy(id, title, description, owner, objectives = objectives) } } } } 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 7b226fa5d..fc802c4c7 100644 --- a/review/src/test/kotlin/br/all/domain/services/BibtexConverterServiceTest.kt +++ b/review/src/test/kotlin/br/all/domain/services/BibtexConverterServiceTest.kt @@ -3,6 +3,7 @@ package br.all.domain.services import br.all.domain.model.review.SystematicStudyId import br.all.domain.model.search.SearchSessionID import br.all.domain.model.study.* +import br.all.domain.shared.exception.bibtex.BibtexParseException import org.junit.jupiter.api.* import java.util.* import kotlin.test.assertEquals @@ -439,57 +440,9 @@ class BibtexConverterServiceTest { } @Test - fun `should throw IllegalArgumentException for unknown type entry`() { + fun `should throw BibtexParseException for unknown type entry`() { val bibtex = BibtexTestData.testInputs["unknown type of bibtex"]!! - assertThrows { - sut.convert(bibtex) - } - } - - @Test - fun `should throw IllegalArgumentException for invalid title entry`() { - val bibtex = BibtexTestData.testInputs["invalid title"]!! - assertThrows { - sut.convert(bibtex) - } - } - - @Test - fun `should throw IllegalArgumentException for invalid author entry`() { - val bibtex = BibtexTestData.testInputs["invalid authors"]!! - assertThrows { - sut.convert(bibtex) - } - } - - @Test - fun `should throw IllegalArgumentException for invalid year entry`() { - val bibtex = BibtexTestData.testInputs["invalid year"]!! - assertThrows { - sut.convert(bibtex) - } - } - - @Test - fun `should throw IllegalArgumentException for invalid venue entry`() { - val bibtex = BibtexTestData.testInputs["invalid venue"]!! - assertThrows { - sut.convert(bibtex) - } - } - - @Test - fun `should throw IllegalArgumentException for invalid abstract entry`() { - val bibtex = BibtexTestData.testInputs["invalid abstract"]!! - assertThrows { - sut.convert(bibtex) - } - } - - @Test - fun `should throw IllegalArgumentException for invalid doi`() { - val bibtex = BibtexTestData.testInputs["invalid doi"]!! - assertThrows { + assertThrows { sut.convert(bibtex) } } @@ -511,9 +464,7 @@ class BibtexConverterServiceTest { } assertAll( {assertEquals(4, studyReviewList.first.size)}, - ) - } } } \ No newline at end of file diff --git a/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt b/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt index cb78fa080..f5ef9a2b9 100644 --- a/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt +++ b/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt @@ -30,6 +30,7 @@ object BibtexTestData { "invalid year" to """ @article{nash51, title = {Non-cooperative Games}, + author = {Nash, John}, journal = {Annals of Mathematics}, abstract = {Lorem Ipsum}, keywords = {keyword1, keyword2}, @@ -42,6 +43,7 @@ object BibtexTestData { @article{nash51, title = {Non-cooperative Games}, year = {1951}, + author = {Nash, John}, journal = {Annals of Mathematics}, keywords = {keyword1, keyword2}, references = {ref1, ref2}, @@ -53,6 +55,7 @@ object BibtexTestData { @article{nash51, title = {Non-cooperative Games}, year = {1951}, + author = {Nash, John}, abstract = {Lorem Ipsum}, keywords = {keyword1, keyword2}, references = {ref1, ref2}, @@ -64,6 +67,7 @@ object BibtexTestData { @article{nash51, title = {Non-cooperative Games}, year = {1951}, + author = {Nash, John}, journal = {Annals of Mathematics}, abstract = {Lorem Ipsum}, keywords = {keyword1, keyword2}, @@ -164,7 +168,7 @@ object BibtexTestData { school = {Stanford University}, address = {Stanford, CA}, year = {1956}, - month = {jun} + month = {jun}, abstract = {Lorem Ipsum} } """, @@ -176,7 +180,7 @@ object BibtexTestData { school = {Massachusetts Institute of Technology}, year = {1996}, address = {Cambridge, MA}, - month = {sep} + month = {sep}, abstract = {Lorem Ipsum} } """, @@ -189,7 +193,7 @@ object BibtexTestData { year = {2016}, publisher = {Pearson}, address = {New York, NY}, - pages = {187--221} + pages = {187--221}, abstract = {Lorem Ipsum} } """, @@ -222,7 +226,7 @@ object BibtexTestData { author = {{NASA}}, howpublished = {\url{https://www.nasa.gov/nh/pluto-the-other-red-planet}}, year = {2015}, - note = {Accessed: 2018-12-06} + note = {Accessed: 2018-12-06}, abstract = {Lorem Ipsum} } """, @@ -315,7 +319,7 @@ object BibtexTestData { "multiple bibtex entries with some invalid" to """ //invalid - @article{nash51, + {nash51, title = {Non-cooperative Games}, abstract = {Lorem Ipsum}, keywords = {keyword1, keyword2}, 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 dc1eda98d..c21ed088f 100644 --- a/review/src/test/kotlin/br/all/domain/services/RisConverterServiceTest.kt +++ b/review/src/test/kotlin/br/all/domain/services/RisConverterServiceTest.kt @@ -66,8 +66,8 @@ class RisConverterServiceTest { source = mutableSetOf("Compendex") ) assertEquals( - studyReview.title, - "Sampling for Scalable Visual Analytics IEEE Computer Graphics and Applications" + "Sampling for Scalable Visual Analytics IEEE Computer Graphics and Applications", + studyReview.title ) } @@ -82,8 +82,8 @@ class RisConverterServiceTest { source = mutableSetOf("Compendex") ) assertEquals( - studyReview.title, - "Sampling for Scalable Visual Analytics IEEE Computer Graphics and Applications" + "Sampling for Scalable Visual Analytics IEEE Computer Graphics and Applications", + studyReview.title ) } @@ -97,7 +97,7 @@ class RisConverterServiceTest { study, source = mutableSetOf("Compendex") ) - assertEquals(studyReview.year, 2017) + assertEquals(2017, studyReview.year) } @Test @@ -110,7 +110,7 @@ class RisConverterServiceTest { study, source = mutableSetOf("Compendex") ) - assertEquals(studyReview.year, 2017) + assertEquals(2017, studyReview.year) } @Test @@ -202,13 +202,14 @@ class RisConverterServiceTest { @Test fun `Should create a StudyReview list from multiple RIS entries as input`() { val ris = testInput["multiple RIS entries"]!! - val studyReviewList = sut.convertManyToStudyReview( + val (validStudies, invalidEntries) = sut.convertManyToStudyReview( SystematicStudyId(UUID.randomUUID()), SearchSessionID(UUID.randomUUID()), ris, source = mutableSetOf("Compendex") ) - assertEquals(3, studyReviewList.first.size) + assertEquals(0, invalidEntries.size) + assertEquals(3, validStudies.size) } @Test @@ -499,7 +500,7 @@ class RisConverterServiceTest { } @Nested - inner class InvalidClasses { + inner class LenientParsingTests { @Test fun `convertManyToStudyReview should not accept a blank ris entry as input`() { assertThrows { @@ -513,132 +514,52 @@ class RisConverterServiceTest { } @Test - fun `convertToStudyReview should not accept a blank ris entry as input`() { + fun `convert should not accept a blank ris entry as input`() { assertThrows { - val study = sut.convert("") - sut.convertToStudyReview( - SystematicStudyId(UUID.randomUUID()), - SearchSessionID(UUID.randomUUID()), - study, - source = mutableSetOf("Compendex") - ) + sut.convert("") } } @Test - fun `should throw IllegalArgumentException for unknown type entry`() { + fun `should create study with UNKNOWN type for unknown type entry`() { val ris = testInput["unknown ris"]!! - assertThrows { - val study = sut.convert(ris) - sut.convertToStudyReview( - SystematicStudyId(UUID.randomUUID()), - SearchSessionID(UUID.randomUUID()), - study, - source = mutableSetOf("Compendex") - ) - } + val study = sut.convert(ris) + assertEquals(StudyType.UNKNOWN, study.type) } @Test - fun `should throw IllegalArgumentException for invalid title entry`() { - val ris = testInput["invalid title"].toString() - assertThrows { - val study = sut.convert(ris) - sut.convertToStudyReview( - SystematicStudyId(UUID.randomUUID()), - SearchSessionID(UUID.randomUUID()), - study, - source = mutableSetOf("Compendex") - ) - } + fun `should create study with default title for missing title entry`() { + val ris = testInput["invalid title"]!! + val study = sut.convert(ris) + assertEquals("", study.title) } @Test - fun `should throw IllegalArgumentException for invalid author entry`() { + fun `should create study with default authors for missing author entry`() { val ris = testInput["invalid authors"]!! - assertThrows { - val study = sut.convert(ris) - sut.convertToStudyReview( - SystematicStudyId(UUID.randomUUID()), - SearchSessionID(UUID.randomUUID()), - study, - source = mutableSetOf("Compendex") - ) - } + val study = sut.convert(ris) + assertEquals("", study.authors) } @Test - fun `should throw IllegalArgumentException for invalid year entry`() { + fun `should create study with default year for missing year entry`() { val ris = testInput["invalid year"]!! - assertThrows { - val study = sut.convert(ris) - sut.convertToStudyReview( - SystematicStudyId(UUID.randomUUID()), - SearchSessionID(UUID.randomUUID()), - study, - source = mutableSetOf("Compendex") - ) - } - } - - @Test - fun `should throw IllegalArgumentException for invalid venue entry`() { - val ris = testInput["invalid venue"]!! - assertThrows { - val study = sut.convert(ris) - sut.convertToStudyReview( - SystematicStudyId(UUID.randomUUID()), - SearchSessionID(UUID.randomUUID()), - study, - source = mutableSetOf("Compendex") - ) - } - } - - @Test - fun `should throw IllegalArgumentException for invalid abstract entry`() { - val ris = testInput["invalid abstract"]!! - assertThrows { - val study = sut.convert(ris) - sut.convertToStudyReview( - SystematicStudyId(UUID.randomUUID()), - SearchSessionID(UUID.randomUUID()), - study, - source = mutableSetOf("Compendex") - ) - } - } - - @Test - fun `should throw IllegalArgumentException for invalid doi`() { - val ris = testInput["invalid doi"]!! - assertThrows { - val study = sut.convert(ris) - sut.convertToStudyReview( - SystematicStudyId(UUID.randomUUID()), - SearchSessionID(UUID.randomUUID()), - study, - source = mutableSetOf("Compendex") - ) - } + val study = sut.convert(ris) + assertEquals(0, study.year) } @Test - fun `should return the correct number of invalid ris files`() { + fun `should correctly parse entries that are missing non-essential fields`() { val ris = testInput["three error ris"]!! - val studyReviewList = sut.convertManyToStudyReview( + val (validStudies, invalidEntries) = sut.convertManyToStudyReview( SystematicStudyId(UUID.randomUUID()), SearchSessionID(UUID.randomUUID()), ris, source = mutableSetOf("Compendex") ) - println("Valid Study Reviews: ${studyReviewList.first}") - println("Invalid RIS Entries: ${studyReviewList.second}") - - assertEquals(2, studyReviewList.first.size) - - assertEquals(3, studyReviewList.second.size) + assertEquals(5, validStudies.size) + assertEquals(0, invalidEntries.size) } } -} +} \ No newline at end of file diff --git a/review/src/test/kotlin/br/all/domain/services/RisTestData.kt b/review/src/test/kotlin/br/all/domain/services/RisTestData.kt index d12ac4a0c..2723f9eaf 100644 --- a/review/src/test/kotlin/br/all/domain/services/RisTestData.kt +++ b/review/src/test/kotlin/br/all/domain/services/RisTestData.kt @@ -343,7 +343,7 @@ object RisTestData { DO - 10.1109/MCG.2017.6 JO - \url{https://www.nasa.gov/nh/pluto-the-other-red-planet} IS - 1 - Y1 - Jan.-Feb. 2017 + Y1 - blablabla AB - Lorem Ipsum ER - """, @@ -370,10 +370,12 @@ object RisTestData { ER - """, - "invalid doi" to """TY - JOUR + "invalid doi" to """TY - VIDEO + TI - Pluto: The 'Other' Red Planet AU - NASA PY - 2015 KW - KW + DO - JO - \url{https://www.nasa.gov/nh/pluto-the-other-red-planet} IS - 1 Y1 - Jan.-Feb. 2017 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 deleted file mode 100644 index 29df084e3..000000000 --- a/review/src/test/kotlin/br/all/domain/shared/ddd/TextTest.kt +++ /dev/null @@ -1,175 +0,0 @@ -package br.all.domain.shared.ddd - -import br.all.domain.shared.valueobject.Text -import org.junit.jupiter.api.* -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals - -@Tag("UnitTest") -class TextTest { - - @Test - fun `valid TEXT should not throw an exception`() { - val textValue = "Mussum Ipsum, cacilds vidis litro abertis. Nulla id gravida magna, ut semper sapien. Todo mundo vê os porris que eu tomo, mas ninguém vê os tombis que eu levo! Leite de capivaris, leite de mula manquis sem cabeça. Admodum accumsan disputationi eu sit. Vide electram sadipscing et per. Si num tem leite então bota uma pinga aí cumpadi! A ordem dos tratores não altera o pão duris. Negão é teu passadis, eu sou faxa pretis. Nullam volutpat risus nec leo commodo, ut interdum diam laoreet. Sed non consequat odio." - val text = Text(textValue) - } - - @Test - fun `equal TEXTs should be equal`() { - val textValue1 = "Mussum Ipsum, cacilds vidis litro abertis. Delegadis gente finis, bibendum egestas augue arcu ut est. Negão é teu passadis, eu sou faxa pretis. Manduma pindureta quium dia nois paga. Interessantiss quisso pudia ce receita de bolis, mais bolis eu num gostis." - val textValue2 = "Mussum Ipsum, cacilds vidis litro abertis. Delegadis gente finis, bibendum egestas augue arcu ut est. Negão é teu passadis, eu sou faxa pretis. Manduma pindureta quium dia nois paga. Interessantiss quisso pudia ce receita de bolis, mais bolis eu num gostis." - - val text1 = Text(textValue1) - val text2 = Text(textValue2) - - assertEquals(text1, text2) - } - - @Test - fun `diferent TEXTs should not be equal`() { - val textValue1 = "Mussum Ipsum, cacilds vidis litro abertis. Delegadis gente finis, bibendum egestas augue arcu ut est. Negão é teu passadis, eu sou faxa pretis. Manduma pindureta quium dia nois paga. Interessantiss quisso pudia ce receita de bolis, mais bolis eu num gostis." - val textValue2 = "Mussum Ipsum, cacilds vidis litro abertis. Per aumento de cachacis, eu reclamis. Tá deprimidis, eu conheço uma cachacis que pode alegrar sua vidis. Nulla id gravida magna, ut semper sapien. Aenean aliquam molestie leo, vitae iaculis nisl." - - val text1 = Text(textValue1) - val text2 = Text(textValue2) - - assertNotEquals(text1, text2) - } - - @Test - fun `valid text with special characters should stil valid`() { - val textValue = "Mussum Ipsum, cacilds vidis litro abertis. Nulla id gravida magna, ut semper sapien. Todo mundo vê os porris que eu tomo, mas ninguém vê os tombis que eu levo! Leite de capivaris, leite de mula manquis sem cabeça. Admodum accumsan disputationi eu sit. Vide electram sadipscing et per. Si num tem leite então bota uma pinga aí cumpadi! A ordem dos tratores não altera o pão duris. Negão é teu passadis, eu sou faxa pretis. Nullam volutpat risus nec leo commodo, ut interdum diam laoreet. Sed non consequat odio." - val text = Text(textValue) - val newTextValue = textValue + "(test!@#$%)" - assertDoesNotThrow { - Text(textValue) - Text(newTextValue) - } - - } - - @Test - fun `empty TEXT should throw an exception`() { - val textValue = "" - assertThrows { - Text(textValue) - } - } - - @Test - fun `blank TEXT should throw an exception`() { - val textValue = " " - assertThrows { - Text(textValue) - } - } - - @Nested - @DisplayName("should NOT be able to accept as TEXT") - inner class InvalidTextTest { - - - @Test - fun `@@@`() { - val textValue = "@@@" - assertThrows { - Text(textValue) - } - } - @Test - @DisplayName(".") - fun ShouldNotAcceptDotsAsText() { - val textValue = "." - assertThrows { - Text(textValue) - } - } - @Test - @DisplayName(",") - fun ShouldNotAcceptCommasAsText() { - val textValue = "." - assertThrows { - Text(textValue) - } - } - @Test - fun `!!!`() { - val textValue = "!!!" - assertThrows { - Text(textValue) - } - } - @Test - fun `###`() { - val textValue = "###" - assertThrows { - Text(textValue) - } - } - @Test - fun `$$$`() { - val textValue = "$$$" - assertThrows { - Text(textValue) - } - } - @Test - fun `%%%`() { - val textValue = "%%%" - assertThrows { - Text(textValue) - } - } - @Test - @DisplayName(":") - fun ShouldNotAcceptColonsAsText() { - val textValue = ":" - assertThrows { - Text(textValue) - } - } - @Test - @DisplayName(";") - fun `ShouldNotAcceptSemicolonsAsText`() { - val textValue = ";" - assertThrows { - Text(textValue) - } - } - @Test - @DisplayName("[") - fun `ShouldNotAcceptBracesText`() { - val textValue = "[[" - assertThrows { - Text(textValue) - } - } - @Test - @DisplayName("]") - fun `ShouldNotAcceptCloseBracesText`() { - val textValue = "]]" - assertThrows { - Text(textValue) - } - } - - @Test - @DisplayName("#\$%!;") - fun `ShouldNotAcceptJustSymbolsText`() { - val textValue = "#$%!;" - assertThrows { - Text(textValue) - } - } - - @Test - @DisplayName("[]") - fun ShouldNotAcceptBraceText() { - val textValue = "[[[]]]" - assertThrows { - Text(textValue) - } - } - - } -} diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexInvalidFieldFormatException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexInvalidFieldFormatException.kt new file mode 100644 index 000000000..7254a342c --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexInvalidFieldFormatException.kt @@ -0,0 +1,4 @@ +package br.all.domain.shared.exception.bibtex + +class BibtexInvalidFieldFormatException(val fieldName: String, val value: String, val expectedFormat: String) : + BibtexParseException("Invalid format for field '$fieldName'. Expected $expectedFormat, but got '$value'.") \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexMalformedEntryException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexMalformedEntryException.kt new file mode 100644 index 000000000..ff96a774b --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexMalformedEntryException.kt @@ -0,0 +1,4 @@ +package br.all.domain.shared.exception.bibtex + +class BibtexMalformedEntryException(reason: String) : + BibtexParseException("Malformed BibTeX entry: $reason") \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexMissingRequiredFieldException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexMissingRequiredFieldException.kt new file mode 100644 index 000000000..01393bc49 --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexMissingRequiredFieldException.kt @@ -0,0 +1,4 @@ +package br.all.domain.shared.exception.bibtex + +class BibtexMissingRequiredFieldException(val fieldName: String) : + BibtexParseException("Missing required field: '$fieldName'") \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexParseException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexParseException.kt new file mode 100644 index 000000000..4f6108004 --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexParseException.kt @@ -0,0 +1,3 @@ +package br.all.domain.shared.exception.bibtex + +sealed class BibtexParseException(message: String) : RuntimeException(message) \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexUnknownEntryTypeException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexUnknownEntryTypeException.kt new file mode 100644 index 000000000..f42cef1d9 --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexUnknownEntryTypeException.kt @@ -0,0 +1,4 @@ +package br.all.domain.shared.exception.bibtex + +class BibtexUnknownEntryTypeException(val typeName: String) : + BibtexParseException("Unknown BibTeX entry type: '$typeName'") \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisInvalidFieldFormatException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisInvalidFieldFormatException.kt new file mode 100644 index 000000000..0296231f5 --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisInvalidFieldFormatException.kt @@ -0,0 +1,4 @@ +package br.all.domain.shared.exception.ris + +class RisInvalidFieldFormatException(val fieldName: String, val value: String, val expectedFormat: String) : + RisParseException("Invalid format for field '$fieldName'. Expected $expectedFormat, but got '$value'.") \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisMissingRequiredFieldException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisMissingRequiredFieldException.kt new file mode 100644 index 000000000..30d1f7334 --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisMissingRequiredFieldException.kt @@ -0,0 +1,4 @@ +package br.all.domain.shared.exception.ris + +class RisMissingRequiredFieldException(val fieldName: String) : + RisParseException("Missing required field: '$fieldName'") \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisParseException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisParseException.kt new file mode 100644 index 000000000..d7313a399 --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisParseException.kt @@ -0,0 +1,3 @@ +package br.all.domain.shared.exception.ris + +sealed class RisParseException(message: String) : RuntimeException(message) \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisUnknownEntryTypeException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisUnknownEntryTypeException.kt new file mode 100644 index 000000000..cfdbae441 --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisUnknownEntryTypeException.kt @@ -0,0 +1,4 @@ +package br.all.domain.shared.exception.ris + +class RisUnknownEntryTypeException(val typeName: String) : + RisParseException("Unknown or unsupported RIS entry type: '$typeName'") \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/service/PasswordEncoderPort.kt b/shared/src/main/kotlin/br/all/domain/shared/service/PasswordEncoderPort.kt new file mode 100644 index 000000000..6d72fc0d3 --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/service/PasswordEncoderPort.kt @@ -0,0 +1,6 @@ +package br.all.domain.shared.service + +interface PasswordEncoderPort { + fun encode(rawPassword: String): String + fun matches(rawPassword: String, encodedPassword: String): Boolean +} \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/user/Name.kt b/shared/src/main/kotlin/br/all/domain/shared/user/Name.kt new file mode 100644 index 000000000..7eb2f20af --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/user/Name.kt @@ -0,0 +1,30 @@ +package br.all.domain.shared.user + +import br.all.domain.shared.ddd.Notification +import br.all.domain.shared.ddd.ValueObject + +data class Name(val value: String) : ValueObject() { + + init { + val notification = validate() + require(notification.hasNoErrors()) { notification.message() } + } + + override fun validate(): Notification { + val notification = Notification() + + if (value.isBlank()) { + notification.addError("The name must not be blank!") + } else { + if (value.startsWith(" ") || value.endsWith(" ")) { + notification.addError("The name must not start or end with blank spaces!") + } + + if (!value.matches(Regex("[\\p{L}.' ]+"))) { + notification.addError("The name must contain only letters, dots, apostrophes, and blank spaces!") + } + } + + return notification + } +} diff --git a/account/src/main/kotlin/br/all/domain/user/Text.kt b/shared/src/main/kotlin/br/all/domain/shared/user/Text.kt similarity index 95% rename from account/src/main/kotlin/br/all/domain/user/Text.kt rename to shared/src/main/kotlin/br/all/domain/shared/user/Text.kt index eb81a04ba..89144d6b4 100644 --- a/account/src/main/kotlin/br/all/domain/user/Text.kt +++ b/shared/src/main/kotlin/br/all/domain/shared/user/Text.kt @@ -1,4 +1,4 @@ -package br.all.domain.user +package br.all.domain.shared.user import br.all.domain.shared.ddd.Notification import br.all.domain.shared.ddd.ValueObject diff --git a/account/src/main/kotlin/br/all/domain/user/Username.kt b/shared/src/main/kotlin/br/all/domain/shared/user/Username.kt similarity index 94% rename from account/src/main/kotlin/br/all/domain/user/Username.kt rename to shared/src/main/kotlin/br/all/domain/shared/user/Username.kt index 95c604a79..2979e335c 100644 --- a/account/src/main/kotlin/br/all/domain/user/Username.kt +++ b/shared/src/main/kotlin/br/all/domain/shared/user/Username.kt @@ -1,4 +1,4 @@ -package br.all.domain.user +package br.all.domain.shared.user import br.all.domain.shared.ddd.Notification import br.all.domain.shared.ddd.ValueObject @@ -18,4 +18,4 @@ data class Username(val value: String) : ValueObject(){ notification.addError("Username must contain only letters and numbers, dashes and underscores!") return notification } -} +} \ No newline at end of file diff --git a/shared/src/test/kotlin/br/all/domain/shared/user/NameTest.kt b/shared/src/test/kotlin/br/all/domain/shared/user/NameTest.kt new file mode 100644 index 000000000..b40ea1617 --- /dev/null +++ b/shared/src/test/kotlin/br/all/domain/shared/user/NameTest.kt @@ -0,0 +1,102 @@ +package br.all.domain.shared.user + +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +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.assertThrows +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +@Tag("UnitTest") +class NameTest { + + @Nested + @DisplayName("when name is valid") + inner class ValidNameTest { + @Test + fun `should create Name for simple name`() { + assertDoesNotThrow { Name("John Doe") } + } + + @Test + fun `should create Name with accented characters`() { + assertDoesNotThrow { Name("João Pacífico") } + } + + @Test + fun `should create Name with dots`() { + assertDoesNotThrow { Name("M. John Doe") } + } + + @Test + fun `should create Name with apostrophes`() { + assertDoesNotThrow { Name("O'Connell") } + } + + @Test + fun `should create Name with a mix of valid characters`() { + assertDoesNotThrow { Name("M. O'Connell Pacífico") } + } + } + + @Nested + @DisplayName("when name is invalid") + inner class InvalidNameTest { + @Test + fun `should throw exception for empty name`() { + val exception = assertThrows { Name("") } + assertTrue(exception.message!!.contains("The name must not be blank!")) + } + + @Test + fun `should throw exception for blank name`() { + val exception = assertThrows { Name(" ") } + assertTrue(exception.message!!.contains("The name must not be blank!")) + } + + @Test + fun `should throw exception for name starting with a space`() { + val exception = assertThrows { Name(" John Doe") } + assertEquals("The name must not start or end with blank spaces!", exception.message) + } + + @Test + fun `should throw exception for name ending with a space`() { + val exception = assertThrows { Name("John Doe ") } + assertEquals("The name must not start or end with blank spaces!", exception.message) + } + + @Test + fun `should throw exception for name with numbers`() { + val exception = assertThrows { Name("John Doe 123") } + assertEquals("The name must contain only letters, dots, apostrophes, and blank spaces!", exception.message) + } + + @Test + fun `should throw exception for name with invalid symbols`() { + val exception = assertThrows { Name("John-Doe@") } + assertEquals("The name must contain only letters, dots, apostrophes, and blank spaces!", exception.message) + } + } + + @Nested + @DisplayName("equality checks") + inner class EqualityTest { + @Test + fun `should be equal when two Name objects have the same value`() { + val name1 = Name("M. Pacífico") + val name2 = Name("M. Pacífico") + assertEquals(name1, name2) + } + + @Test + fun `should not be equal when two Name objects have different values`() { + val name1 = Name("John Doe") + val name2 = Name("Jane Doe") + assertNotEquals(name1, name2) + } + } +} diff --git a/shared/src/test/kotlin/br/all/domain/shared/user/TextTest.kt b/shared/src/test/kotlin/br/all/domain/shared/user/TextTest.kt new file mode 100644 index 000000000..365045397 --- /dev/null +++ b/shared/src/test/kotlin/br/all/domain/shared/user/TextTest.kt @@ -0,0 +1,165 @@ +package br.all.domain.shared.user + +import org.junit.jupiter.api.Assertions +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.assertThrows +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +@Tag("UnitTest") +class TextTest { + + @Test + fun `should create Text when value is valid`() { + val textValue = "Mussum Ipsum cacilds vidis litro abertis" + Assertions.assertDoesNotThrow { + Text(textValue) + } + } + + @Test + fun `should be equal when two Text objects have the same value`() { + val textValue1 = "Mussum Ipsum cacilds vidis litro abertis Delegadis gente finis" + val textValue2 = "Mussum Ipsum cacilds vidis litro abertis Delegadis gente finis" + + val text1 = Text(textValue1) + val text2 = Text(textValue2) + + assertEquals(text1, text2) + } + + @Test + fun `should not be equal when two Text objects have different values`() { + val textValue1 = "Mussum Ipsum cacilds vidis litro abertis" + val textValue2 = "Per aumento de cachacis eu reclamis" + + val text1 = Text(textValue1) + val text2 = Text(textValue2) + + assertNotEquals(text1, text2) + } + + @Test + fun `should throw exception for empty TEXT`() { + val textValue = "" + assertThrows { + Text(textValue) + } + } + + @Test + fun `should throw exception for blank TEXT`() { + val textValue = " " + assertThrows { + Text(textValue) + } + } + + @Test + fun `should throw exception for text starting with a space`() { + val textValue = " leading space" + val exception = assertThrows { + Text(textValue) + } + assertEquals("The text must not start or end with blank spaces!", exception.message) + } + + @Test + fun `should throw exception for text ending with a space`() { + val textValue = "trailing space " + val exception = assertThrows { + Text(textValue) + } + assertEquals("The text must not start or end with blank spaces!", exception.message) + } + + @Nested + @DisplayName("should throw exception for text with invalid characters") + inner class InvalidTextTest { + + @Test + fun `like numbers`() { + val textValue = "Text with 123" + val exception = assertThrows { + Text(textValue) + } + assertEquals("The text must contain only letters and blank spaces!", exception.message) + } + + @Test + fun `like @@@`() { + val textValue = "@@@" + assertThrows { Text(textValue) } + } + + @Test + @DisplayName("like . (dots)") + fun shouldNotAcceptDotsAsText() { + val textValue = "." + assertThrows { Text(textValue) } + } + + @Test + @DisplayName("like , (commas)") + fun shouldNotAcceptCommasAsText() { + val textValue = "," + assertThrows { Text(textValue) } + } + + @Test + fun `like !!!`() { + val textValue = "!!!" + assertThrows { Text(textValue) } + } + + @Test + fun `like ###`() { + val textValue = "###" + assertThrows { Text(textValue) } + } + + @Test + fun `like $$$`() { + val textValue = "$$$" + assertThrows { Text(textValue) } + } + +// // This test can cause problems on Windows, but it passes. +// @Test +// fun `like %%%`() { +// val textValue = "%%%" +// assertThrows { Text(textValue) } +// } + + @Test + @DisplayName("like : (colons)") + fun shouldNotAcceptColonsAsText() { + val textValue = ":" + assertThrows { Text(textValue) } + } + + @Test + @DisplayName("like ; (semicolons)") + fun shouldNotAcceptSemicolonsAsText() { + val textValue = ";" + assertThrows { Text(textValue) } + } + + @Test + @DisplayName("like [ ] (brackets)") + fun shouldNotAcceptBracketsText() { + val textValue = "[[]]" + assertThrows { Text(textValue) } + } + + @Test + @DisplayName("like a mix of symbols (#$%!;)") + fun shouldNotAcceptJustSymbolsText() { + val textValue = "#$%!;" + assertThrows { Text(textValue) } + } + } +} \ No newline at end of file diff --git a/shared/src/test/kotlin/br/all/domain/shared/user/UsernameTest.kt b/shared/src/test/kotlin/br/all/domain/shared/user/UsernameTest.kt new file mode 100644 index 000000000..683d6e6b1 --- /dev/null +++ b/shared/src/test/kotlin/br/all/domain/shared/user/UsernameTest.kt @@ -0,0 +1,92 @@ +package br.all.domain.shared.user + +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +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.assertThrows +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + + +@Tag("UnitTest") +class UsernameTest { + + @Nested + @DisplayName("when username is valid") + inner class ValidUsernameTest { + @Test + fun `should create Username with letters and numbers`() { + assertDoesNotThrow { Username("user123") } + } + + @Test + fun `should create Username with dashes`() { + assertDoesNotThrow { Username("user-name") } + } + + @Test + fun `should create Username with underscores`() { + assertDoesNotThrow { Username("user_name") } + } + + @Test + fun `should create Username with a mix of valid characters`() { + assertDoesNotThrow { Username("user-123_name") } + } + } + + @Nested + @DisplayName("when username is invalid") + inner class InvalidUsernameTest { + @Test + fun `should throw exception for empty username`() { + val exception = assertThrows { Username("") } + assertTrue(exception.message!!.contains("Username must not be blank!")) + } + + @Test + fun `should throw exception for blank username`() { + val exception = assertThrows { Username(" ") } + assertTrue(exception.message!!.contains("Username must not be blank!")) + } + + @Test + fun `should throw exception for username with spaces`() { + val exception = assertThrows { Username("user name") } + assertEquals("Username must contain only letters and numbers, dashes and underscores!", exception.message) + } + + @Test + fun `should throw exception for username with invalid symbols`() { + val exception = assertThrows { Username("user@name!") } + assertEquals("Username must contain only letters and numbers, dashes and underscores!", exception.message) + } + + @Test + fun `should throw exception for username with dots`() { + val exception = assertThrows { Username("user.name") } + assertEquals("Username must contain only letters and numbers, dashes and underscores!", exception.message) + } + } + + @Nested + @DisplayName("equality checks") + inner class EqualityTest { + @Test + fun `should be equal when two Username objects have the same value`() { + val user1 = Username("test-user_1") + val user2 = Username("test-user_1") + assertEquals(user1, user2) + } + + @Test + fun `should not be equal when two Username objects have different values`() { + val user1 = Username("user1") + val user2 = Username("user2") + assertNotEquals(user1, user2) + } + } +} \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/WebApplication.kt b/web/src/main/kotlin/br/all/WebApplication.kt index 3b7058762..18a550a83 100644 --- a/web/src/main/kotlin/br/all/WebApplication.kt +++ b/web/src/main/kotlin/br/all/WebApplication.kt @@ -1,6 +1,10 @@ package br.all +import br.all.application.study.repository.StudyReviewRepository +import br.all.application.study.repository.toDto import br.all.domain.model.review.toSystematicStudyId +import br.all.domain.model.study.StudyReview +import br.all.domain.services.ReviewSimilarityService import br.all.utils.example.CreateQuestionExampleService import br.all.utils.example.CreateSystematicReviewExampleService import br.all.utils.example.CreateSearchSessionExampleService @@ -23,14 +27,17 @@ class WebApplication { register: RegisterUserExampleService, create: CreateSystematicReviewExampleService, search: CreateSearchSessionExampleService, - question: CreateQuestionExampleService + question: CreateQuestionExampleService, + studyReviewRepository: StudyReviewRepository, + reviewSimilarityService: ReviewSimilarityService ) = CommandLineRunner { val password = encoder.encode("admin") val lucasUserAccount = register.registerUserAccount("buenolro", password) val systematicId = create.createReview(lucasUserAccount.id.value(), setOf(lucasUserAccount.id.value())) + val allScoredStudies = mutableListOf() - search.convert( + allScoredStudies.addAll(search.extractAndScoreStudiesFromFile( systematicStudyId = systematicId.toSystematicStudyId(), userId = lucasUserAccount.id.value(), bibFileName = "Springer.bib", @@ -40,9 +47,9 @@ class WebApplication { ab:(("Service Oriented" OR "Service-oriented") AND (Robot OR Robotic OR humanoid)) """.trimIndent(), additionalInformation = "Springer search performed on 2022-09-20 using complementary substrings (example shown above). Returned 11 studies after duplicate filtering. Only English studies were considered." - ) + )) - search.convert( + allScoredStudies.addAll(search.extractAndScoreStudiesFromFile( systematicStudyId = systematicId.toSystematicStudyId(), userId = lucasUserAccount.id.value(), bibFileName = "WebOfScience.bib", @@ -56,9 +63,9 @@ class WebApplication { AND (Robot OR Robotic OR humanoid)) """.trimIndent(), additionalInformation = "Web of Science search performed on 2022-09-20 using both Topic and Title fields. Returned 80 studies. Only English studies were considered." - ) + )) - search.convert( + allScoredStudies.addAll(search.extractAndScoreStudiesFromFile( systematicStudyId = systematicId.toSystematicStudyId(), userId = lucasUserAccount.id.value(), bibFileName = "ACM.bib", @@ -69,9 +76,9 @@ class WebApplication { AND (Robot OR Robotic OR humanoid)) """.trimIndent(), additionalInformation = "ACM Digital Library search was performed on 2022-09-20. Query split into two parts (abstract and title); returned 5 and 1 results respectively. Only English studies were considered.", - ) + )) - search.convert( + allScoredStudies.addAll(search.extractAndScoreStudiesFromFile( systematicStudyId = systematicId.toSystematicStudyId(), userId = lucasUserAccount.id.value(), bibFileName = "Compendex.bib", @@ -82,9 +89,9 @@ class WebApplication { AND (Robot OR Robotic OR humanoid)) WN KY), English only """.trimIndent(), additionalInformation = "Compendex search was performed on 2022-09-20 using the specified query applied to the keywords (WN KY) field. Only studies in English were considered.", - ) + )) - search.convert( + allScoredStudies.addAll(search.extractAndScoreStudiesFromFile( systematicStudyId = systematicId.toSystematicStudyId(), userId = lucasUserAccount.id.value(), bibFileName = "IEEE.bib", @@ -95,9 +102,9 @@ class WebApplication { AND ("Abstract":Robot OR "Abstract":Robotic OR "Abstract":humanoid) """.trimIndent(), additionalInformation = "IEEE Xplore search performed on 2022-09-20 using separate queries for abstract and title. The abstract query returned 72 studies and the title query returned 22 studies. Only studies in English were considered." - ) + )) - search.convert( + allScoredStudies.addAll(search.extractAndScoreStudiesFromFile( systematicStudyId = systematicId.toSystematicStudyId(), userId = lucasUserAccount.id.value(), bibFileName = "science.bib", @@ -108,9 +115,9 @@ class WebApplication { AND (Robot OR Robotic OR humanoid)) """.trimIndent(), additionalInformation = "ScienceDirect search performed on 2022-09-20 using the TITLE-ABSTR-KEY field. Returned 4 studies. Only English studies were considered." - ) + )) - search.convert( + allScoredStudies.addAll(search.extractAndScoreStudiesFromFile( systematicStudyId = systematicId.toSystematicStudyId(), userId = lucasUserAccount.id.value(), bibFileName = "scopus.bib", @@ -121,8 +128,39 @@ class WebApplication { AND (robot OR robotic OR humanoid)) """.trimIndent(), additionalInformation = "Scopus search performed on 2022-09-20 using the TITLE-ABS-KEY field. Returned 230 studies (duplicates filtered later). Only English studies were considered." - ) + )) + studyReviewRepository.saveOrUpdateBatch(allScoredStudies.map { it.toDto() }) + + val duplicatedAnalysedReviews = reviewSimilarityService.findDuplicates(allScoredStudies, emptyList()) + val duplicateStudies = duplicatedAnalysedReviews + .flatMap { (key, value) -> listOf(key) + value } + .toList() + + val duplicateStudiesSet = duplicateStudies.toSet() + val uniqueStudies = allScoredStudies.filter { it !in duplicateStudiesSet } + + applyRandomClassification(uniqueStudies) + applyRandomClassification(duplicateStudies) + + studyReviewRepository.saveOrUpdateBatch(allScoredStudies.map { it.toDto() }) + } +} + +private fun applyRandomClassification(studies: List,) { + if (studies.isEmpty()) { + return + } + + val shuffledStudies = studies.shuffled() + val includeCount = (shuffledStudies.size * 0.33).toInt() + val excludeStopIndex = includeCount + (shuffledStudies.size * 0.33).toInt() + + shuffledStudies.forEachIndexed { index, item -> + when { + index < includeCount -> item.includeInSelection() + index < excludeStopIndex -> item.excludeInSelection() + } } } 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 2a23e29f2..455629e5a 100644 --- a/web/src/main/kotlin/br/all/protocol/controller/ProtocolController.kt +++ b/web/src/main/kotlin/br/all/protocol/controller/ProtocolController.kt @@ -22,7 +22,7 @@ import br.all.application.protocol.find.FindProtocolService.RequestModel as Find import br.all.application.protocol.find.FindProtocolStageService.RequestModel as FindStageRequestModel @RestController -@RequestMapping("/systematic-study/{systematicStudyId}/protocol") +@RequestMapping("/api/v1/systematic-study/{systematicStudyId}/protocol") class ProtocolController( private val findProtocolService: FindProtocolService, private val updateProtocolService: UpdateProtocolService, diff --git a/web/src/main/kotlin/br/all/question/controller/ExtractionQuestionController.kt b/web/src/main/kotlin/br/all/question/controller/ExtractionQuestionController.kt index e423f1232..69ee16bbb 100644 --- a/web/src/main/kotlin/br/all/question/controller/ExtractionQuestionController.kt +++ b/web/src/main/kotlin/br/all/question/controller/ExtractionQuestionController.kt @@ -37,6 +37,7 @@ class ExtractionQuestionController( ) { data class TextualRequest(val code: String, val description: String) data class PickListRequest(val code: String, val description: String, val options: List) + data class PickManyRequest(val code: String, val description: String, val options: List) data class LabeledScaleRequest(val code: String, val description: String, val scales: Map) data class NumberScaleRequest(val code: String, val description: String, val lower: Int, val higher: Int) val questionContext = "EXTRACTION" @@ -119,6 +120,42 @@ class ExtractionQuestionController( ) ) + @PostMapping("/pick-many") + @Operation(summary = "Create a extraction pick-many question in the protocol") + @ApiResponses( + value = [ + ApiResponse(responseCode = "201", description = "Success creating a pick-many question in the protocol", + content = [Content(schema = Schema(hidden = true))]), + ApiResponse( + responseCode = "400", + description = "Fail creating a pick-many question in the protocol - invalid input", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "401", + description = "Fail creating a pick-many question in the protocol - unauthenticated user", + content = [Content(schema = Schema(hidden = true))] + ),ApiResponse( + responseCode = "403", + description = "Fail creating a pick-many question in the protocol - unauthorized user", + content = [Content(schema = Schema(hidden = true))] + ), + ] + ) + fun createPickManyQuestion( + @PathVariable systematicStudyId: UUID, @RequestBody request: PickManyRequest, + ): ResponseEntity<*> = createQuestion( + RequestModel( + authenticationInfoService.getAuthenticatedUserId(), + systematicStudyId, + questionContext, + PICK_MANY, + request.code, + request.description, + options = request.options + ) + ) + @PostMapping("/labeled-scale") @Operation(summary = "Create a extraction labeled-scale question in the protocol") @ApiResponses( diff --git a/web/src/main/kotlin/br/all/question/controller/RiskOfBiasQuestionController.kt b/web/src/main/kotlin/br/all/question/controller/RiskOfBiasQuestionController.kt index a58824bc6..41499029c 100644 --- a/web/src/main/kotlin/br/all/question/controller/RiskOfBiasQuestionController.kt +++ b/web/src/main/kotlin/br/all/question/controller/RiskOfBiasQuestionController.kt @@ -36,6 +36,7 @@ class RiskOfBiasQuestionController( ) { data class TextualRequest(val code: String, val description: String) data class PickListRequest(val code: String, val description: String, val options: List) + data class PickManyRequest(val code: String, val description: String, val options: List) data class LabeledScaleRequest(val code: String, val description: String, val scales: Map) data class NumberScaleRequest(val code: String, val description: String, val lower: Int, val higher: Int) val questionContext = "ROB" @@ -119,6 +120,43 @@ class RiskOfBiasQuestionController( ) ) + @PostMapping("/pick-many") + @Operation(summary = "Create a risk of bias pick-many question in the protocol") + @ApiResponses( + value = [ + ApiResponse(responseCode = "201", description = "Success creating a pick-many question in the protocol", + content = [Content(schema = Schema(hidden = true))]), + ApiResponse( + responseCode = "400", + description = "Fail creating a pick-many question in the protocol - invalid input", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "401", + description = "Fail creating a pick-many question in the protocol - unauthenticated user", + content = [Content(schema = Schema(hidden = true))] + ),ApiResponse( + responseCode = "403", + description = "Fail creating a pick-many question in the protocol - unauthorized user", + content = [Content(schema = Schema(hidden = true))] + ), + + ] + ) + fun createPickManyQuestion( + @PathVariable systematicStudyId: UUID, @RequestBody request: PickManyRequest, + ): ResponseEntity<*> = createQuestion( + RequestModel( + authenticationInfoService.getAuthenticatedUserId(), + systematicStudyId, + questionContext, + PICK_MANY, + request.code, + request.description, + options = request.options + ) + ) + @PostMapping("/labeled-scale") @Operation(summary = "Create a risk of bias labeled-scale question in the protocol") @ApiResponses( diff --git a/web/src/main/kotlin/br/all/question/presenter/extraction/RestfulCreateExtractionQuestionPresenter.kt b/web/src/main/kotlin/br/all/question/presenter/extraction/RestfulCreateExtractionQuestionPresenter.kt index 1293091b9..2f54827cf 100644 --- a/web/src/main/kotlin/br/all/question/presenter/extraction/RestfulCreateExtractionQuestionPresenter.kt +++ b/web/src/main/kotlin/br/all/question/presenter/extraction/RestfulCreateExtractionQuestionPresenter.kt @@ -24,11 +24,12 @@ class RestfulCreateExtractionQuestionPresenter( val selfRef = linksFactory.findExtractionQuestion(response.systematicStudyId, response.questionId) val pickList = linksFactory.createPickListExtractionQuestion(response.systematicStudyId) + val pickMany = linksFactory.createPickManyExtractionQuestion(response.systematicStudyId) val labeledScale = linksFactory.createLabeledScaleExtractionQuestion(response.systematicStudyId) val numberScale = linksFactory.createNumberScaleExtractionQuestion(response.systematicStudyId) val findAll = linksFactory.findAllReviewExtractionQuestions(response.systematicStudyId) - viewModel.add(selfRef, pickList, labeledScale, numberScale, findAll) + viewModel.add(selfRef, pickList, pickMany, labeledScale, numberScale, findAll) responseEntity = status(HttpStatus.CREATED).body(viewModel) } diff --git a/web/src/main/kotlin/br/all/question/presenter/extraction/RestfulFindAllExtractionQuestionPresenter.kt b/web/src/main/kotlin/br/all/question/presenter/extraction/RestfulFindAllExtractionQuestionPresenter.kt index b78e80658..603e45cec 100644 --- a/web/src/main/kotlin/br/all/question/presenter/extraction/RestfulFindAllExtractionQuestionPresenter.kt +++ b/web/src/main/kotlin/br/all/question/presenter/extraction/RestfulFindAllExtractionQuestionPresenter.kt @@ -22,10 +22,11 @@ class RestfulFindAllExtractionQuestionPresenter( val selfRef = linksFactory.findAllReviewExtractionQuestions(response.systematicStudyId) val createQuestion = linksFactory.createTextualExtractionQuestion(response.systematicStudyId) val createPickList = linksFactory.createPickListExtractionQuestion(response.systematicStudyId) + val createPickMany = linksFactory.createPickManyExtractionQuestion(response.systematicStudyId) val createLabeledScale = linksFactory.createLabeledScaleExtractionQuestion(response.systematicStudyId) val createNumberScale = linksFactory.createNumberScaleExtractionQuestion(response.systematicStudyId) - viewModel.add(selfRef, createQuestion, createPickList, createLabeledScale, createNumberScale) + viewModel.add(selfRef, createQuestion, createPickList, createPickMany, createLabeledScale, createNumberScale) responseEntity = ResponseEntity.status(HttpStatus.OK).body(viewModel) } diff --git a/web/src/main/kotlin/br/all/question/presenter/extraction/RestfulFindExtractionQuestionPresenter.kt b/web/src/main/kotlin/br/all/question/presenter/extraction/RestfulFindExtractionQuestionPresenter.kt index 75552dbdd..7105af7b5 100644 --- a/web/src/main/kotlin/br/all/question/presenter/extraction/RestfulFindExtractionQuestionPresenter.kt +++ b/web/src/main/kotlin/br/all/question/presenter/extraction/RestfulFindExtractionQuestionPresenter.kt @@ -34,7 +34,7 @@ class RestfulFindExtractionQuestionPresenter( val questionId = content.questionId val systematicStudyId = content.systematicStudyId val code = content.code - val description = content.code + val description = content.description val questionType = content.questionType val scales = content.scales val higher = content.higher diff --git a/web/src/main/kotlin/br/all/question/presenter/riskOfBias/RestfulCreateRoBQuestionPresenter.kt b/web/src/main/kotlin/br/all/question/presenter/riskOfBias/RestfulCreateRoBQuestionPresenter.kt index 9be521156..c003991d7 100644 --- a/web/src/main/kotlin/br/all/question/presenter/riskOfBias/RestfulCreateRoBQuestionPresenter.kt +++ b/web/src/main/kotlin/br/all/question/presenter/riskOfBias/RestfulCreateRoBQuestionPresenter.kt @@ -23,11 +23,12 @@ class RestfulCreateRoBQuestionPresenter( val selfRef = linksFactory.findRobQuestion(response.systematicStudyId, response.questionId) val pickList = linksFactory.createPickListRobQuestion(response.systematicStudyId) + val pickMany = linksFactory.createPickManyRobQuestion(response.systematicStudyId) val labeledScale = linksFactory.createLabeledScaleRobQuestion(response.systematicStudyId) val numberScale = linksFactory.createNumberScaleRobQuestion(response.systematicStudyId) val findAll = linksFactory.findAllReviewRobQuestions(response.systematicStudyId) - restfulResponse.add(selfRef, pickList, labeledScale, numberScale, findAll) + restfulResponse.add(selfRef, pickList, pickMany, labeledScale, numberScale, findAll) responseEntity = status(HttpStatus.CREATED).body(restfulResponse) } diff --git a/web/src/main/kotlin/br/all/question/presenter/riskOfBias/RestfulFindAllRoBQuestionPresenter.kt b/web/src/main/kotlin/br/all/question/presenter/riskOfBias/RestfulFindAllRoBQuestionPresenter.kt index dc356d90f..41bd77f66 100644 --- a/web/src/main/kotlin/br/all/question/presenter/riskOfBias/RestfulFindAllRoBQuestionPresenter.kt +++ b/web/src/main/kotlin/br/all/question/presenter/riskOfBias/RestfulFindAllRoBQuestionPresenter.kt @@ -22,10 +22,11 @@ class RestfulFindAllRoBQuestionPresenter( val selfRef = linksFactory.findAllReviewRobQuestions(response.systematicStudyId) val createQuestion = linksFactory.createTextualRobQuestion(response.systematicStudyId) val createPickList = linksFactory.createPickListRobQuestion(response.systematicStudyId) + val createPickMany = linksFactory.createPickManyRobQuestion(response.systematicStudyId) val createLabeledScale = linksFactory.createLabeledScaleRobQuestion(response.systematicStudyId) val createNumberScale = linksFactory.createNumberScaleRobQuestion(response.systematicStudyId) - restfulResponse.add(selfRef, createQuestion ,createPickList,createLabeledScale,createNumberScale) + restfulResponse.add(selfRef, createQuestion, createPickList, createPickMany, createLabeledScale, createNumberScale) responseEntity = ResponseEntity.status(HttpStatus.OK).body(restfulResponse) } diff --git a/web/src/main/kotlin/br/all/question/presenter/riskOfBias/RestfulFindRoBQuestionPresenter.kt b/web/src/main/kotlin/br/all/question/presenter/riskOfBias/RestfulFindRoBQuestionPresenter.kt index 6a817378a..4409fbb97 100644 --- a/web/src/main/kotlin/br/all/question/presenter/riskOfBias/RestfulFindRoBQuestionPresenter.kt +++ b/web/src/main/kotlin/br/all/question/presenter/riskOfBias/RestfulFindRoBQuestionPresenter.kt @@ -35,7 +35,7 @@ class RestfulFindRoBQuestionPresenter( val questionId = content.questionId val systematicStudyId = content.systematicStudyId val code = content.code - val description = content.code + val description = content.description val questionType = content.questionType val scales = content.scales val higher = content.higher diff --git a/web/src/main/kotlin/br/all/review/requests/PostRequest.kt b/web/src/main/kotlin/br/all/review/requests/PostRequest.kt index b617549f6..1c74dd663 100644 --- a/web/src/main/kotlin/br/all/review/requests/PostRequest.kt +++ b/web/src/main/kotlin/br/all/review/requests/PostRequest.kt @@ -7,7 +7,8 @@ data class PostRequest( val title: String, val description: String, val collaborators: Set, + val objectives: String, ) { fun toCreateRequestModel(userId: UUID) = - RequestModel(userId, title, description, collaborators) + RequestModel(userId, title, description, collaborators, objectives) } \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/review/requests/PutRequest.kt b/web/src/main/kotlin/br/all/review/requests/PutRequest.kt index 9460bb53c..ba5dd2ed8 100644 --- a/web/src/main/kotlin/br/all/review/requests/PutRequest.kt +++ b/web/src/main/kotlin/br/all/review/requests/PutRequest.kt @@ -5,7 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema import java.util.* @Schema(name = "SystematicStudyPutRequest") -data class PutRequest(val title: String?, val description: String?) { +data class PutRequest(val title: String?, val description: String?, val objectives: String?) { fun toUpdateRequestModel(researcherId: UUID, systematicStudyId: UUID) = - UpdateSystematicStudyService.RequestModel(researcherId, systematicStudyId, title, description) + UpdateSystematicStudyService.RequestModel(researcherId, systematicStudyId, title, description, objectives) } \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/security/config/SecurityConfiguration.kt b/web/src/main/kotlin/br/all/security/config/SecurityConfiguration.kt index 06d34bc97..7eb9f9497 100644 --- a/web/src/main/kotlin/br/all/security/config/SecurityConfiguration.kt +++ b/web/src/main/kotlin/br/all/security/config/SecurityConfiguration.kt @@ -60,7 +60,7 @@ class SecurityConfiguration( fun corsConfigurationSource(): CorsConfigurationSource { val config = CorsConfiguration() - config.allowedOrigins = listOf("http://localhost:5173") + config.allowedOrigins = listOf("http://localhost:5173", "http://localhost:5174") config.allowedMethods = listOf("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") config.allowedHeaders = listOf("*") config.allowCredentials = true diff --git a/web/src/main/kotlin/br/all/security/service/PasswordEncoderAdapter.kt b/web/src/main/kotlin/br/all/security/service/PasswordEncoderAdapter.kt new file mode 100644 index 000000000..01a5afbd9 --- /dev/null +++ b/web/src/main/kotlin/br/all/security/service/PasswordEncoderAdapter.kt @@ -0,0 +1,18 @@ +package br.all.security.service + +import br.all.domain.shared.service.PasswordEncoderPort +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Component + +@Component +class PasswordEncoderAdapter( + private val encoder: PasswordEncoder +) : PasswordEncoderPort { + override fun encode(rawPassword: String): String { + return encoder.encode(rawPassword) + } + + override fun matches(rawPassword: String, encodedPassword: String): Boolean { + return encoder.matches(rawPassword, encodedPassword) + } +} \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/user/controller/UserAccountConfiguration.kt b/web/src/main/kotlin/br/all/user/controller/UserAccountConfiguration.kt index 44d088719..cc320ac3c 100644 --- a/web/src/main/kotlin/br/all/user/controller/UserAccountConfiguration.kt +++ b/web/src/main/kotlin/br/all/user/controller/UserAccountConfiguration.kt @@ -3,6 +3,9 @@ package br.all.user.controller import br.all.application.user.create.RegisterUserAccountServiceImpl import br.all.application.user.find.RetrieveUserProfileServiceImpl import br.all.application.user.repository.UserAccountRepository +import br.all.application.user.update.ChangeAccountPasswordServiceImpl +import br.all.application.user.update.PatchUserProfileServiceImpl +import br.all.domain.shared.service.PasswordEncoderPort import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -10,8 +13,14 @@ import org.springframework.context.annotation.Configuration class UserAccountConfiguration { @Bean - fun registerUser(repository: UserAccountRepository) = RegisterUserAccountServiceImpl(repository) + fun registerUser(repository: UserAccountRepository, encoder: PasswordEncoderPort) = RegisterUserAccountServiceImpl(repository, encoder) @Bean fun retrieveUserProfile(repository: UserAccountRepository) = RetrieveUserProfileServiceImpl(repository) + + @Bean + fun patchUserProfile(repository: UserAccountRepository) = PatchUserProfileServiceImpl(repository) + + @Bean + fun changeAccountPassword(repository: UserAccountRepository, encoder: PasswordEncoderPort) = ChangeAccountPasswordServiceImpl(repository, encoder) } \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt b/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt index 3919eaef8..98d91ceeb 100644 --- a/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt +++ b/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt @@ -4,19 +4,30 @@ import br.all.application.user.CredentialsService import br.all.application.user.create.RegisterUserAccountService import br.all.application.user.create.RegisterUserAccountService.RequestModel import br.all.application.user.find.RetrieveUserProfileService +import br.all.application.user.update.ChangeAccountPasswordService +import br.all.application.user.update.PatchUserProfileService import br.all.security.service.AuthenticationInfoService +import br.all.security.service.AuthenticationService +import br.all.user.presenter.RestfulChangeAccountPasswordPresenter +import br.all.user.presenter.RestfulPatchUserProfilePresenter import br.all.user.presenter.RestfulRegisterUserAccountPresenter import br.all.user.presenter.RestfulRetrieveUserProfilePresenter +import br.all.user.requests.ChangeAccountPasswordRequest +import br.all.user.requests.PatchUserProfileRequest import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -27,16 +38,19 @@ class UserAccountController( private val registerUserAccountService: RegisterUserAccountService, private val encoder: PasswordEncoder, private val retrieveUserProfileService: RetrieveUserProfileService, - private val authenticationInfoService: AuthenticationInfoService + private val authenticationInfoService: AuthenticationInfoService, + private val patchUserProfileService: PatchUserProfileService, + private val changeAccountPasswordService: ChangeAccountPasswordService, + private val authenticationService: AuthenticationService ) { @PostMapping - @Operation(summary = "Create a new user") + @Operation(summary = "Create an new user") @ApiResponses( value = [ ApiResponse( responseCode = "201", - description = "Success creating a user", + description = "Success creating an user", content = [Content( mediaType = "application/json", schema = Schema(implementation = CredentialsService.ResponseModel::class) @@ -44,25 +58,24 @@ class UserAccountController( ), ApiResponse( responseCode = "400", - description = "Fail creating a user - invalid input", + description = "Fail creating an user - invalid input", content = [Content(schema = Schema(hidden = true))] ), ApiResponse( responseCode = "409", - description = "Fail creating a user - user already exists", + description = "Fail creating an user - user already exists", content = [Content(schema = Schema(hidden = true))] ), ] ) fun registerUser(@RequestBody request: RequestModel): ResponseEntity<*> { val presenter = RestfulRegisterUserAccountPresenter() - val encodedPasswordRequest = request.copy(password = encoder.encode(request.password)) - registerUserAccountService.register(presenter, encodedPasswordRequest) + registerUserAccountService.register(presenter, request) return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) } @GetMapping("/profile") - @Operation(summary = "Retrieve public information of a user") + @Operation(summary = "Retrieve public information of an user") @ApiResponses( value = [ ApiResponse( @@ -97,4 +110,101 @@ class UserAccountController( retrieveUserProfileService.retrieveData(presenter, request) return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) } + + + @PatchMapping("/profile") + @Operation(summary = "Update public information of an user") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Success updating user profile", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = PatchUserProfileService.ResponseModel::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Fail updating user profile - unauthenticated collaborator", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "403", + description = "Fail updating user profile - unauthorized collaborator", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "404", + description = "Fail updating user profile - nonexistent user", + content = [Content(schema = Schema(hidden = true))] + ), + ]) + fun patchUserPublicData(@RequestBody body: PatchUserProfileRequest): ResponseEntity<*> { + val presenter = RestfulPatchUserProfilePresenter() + val userId = authenticationInfoService.getAuthenticatedUserId() + val request = PatchUserProfileService.RequestModel( + userId = userId, + name = body.name, + email = body.email, + affiliation = body.affiliation, + country = body.country + ) + + patchUserProfileService.patchProfile(presenter, request) + return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) + } + + @PutMapping("/change-password") + @Operation(summary = "Update password of an user account and logout") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Success updating account password", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ChangeAccountPasswordService.ResponseModel::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Fail updating account password - unauthenticated collaborator", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "403", + description = "Fail updating account password - unauthorized collaborator", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "404", + description = "Fail updating account password - nonexistent user", + content = [Content(schema = Schema(hidden = true))] + ), + ]) + fun putAccountPassword( + @RequestBody body: ChangeAccountPasswordRequest, + request: HttpServletRequest, + response: HttpServletResponse + ): ResponseEntity<*> { + val presenter = RestfulChangeAccountPasswordPresenter() + val userId = authenticationInfoService.getAuthenticatedUserId() + val changePasswordRequest = ChangeAccountPasswordService.RequestModel( + userId = userId, + oldPassword = body.oldPassword, + newPassword = body.newPassword, + confirmPassword = body.confirmPassword + ) + + changeAccountPasswordService.changePassword(presenter, changePasswordRequest) + + if (presenter.responseEntity?.statusCode?.isError == true) { + return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) + } + + authenticationService.logout(request, response) + + return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) + } } \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/user/presenter/RestfulChangeAccountPasswordPresenter.kt b/web/src/main/kotlin/br/all/user/presenter/RestfulChangeAccountPasswordPresenter.kt new file mode 100644 index 000000000..7a639421f --- /dev/null +++ b/web/src/main/kotlin/br/all/user/presenter/RestfulChangeAccountPasswordPresenter.kt @@ -0,0 +1,29 @@ +package br.all.user.presenter + +import br.all.application.user.update.ChangeAccountPasswordPresenter +import br.all.application.user.update.ChangeAccountPasswordService.ResponseModel +import br.all.shared.error.createErrorResponseFrom +import org.springframework.hateoas.RepresentationModel +import org.springframework.http.ResponseEntity +import org.springframework.http.ResponseEntity.ok +import java.util.UUID + +class RestfulChangeAccountPasswordPresenter : ChangeAccountPasswordPresenter { + + var responseEntity: ResponseEntity<*>? = null + + override fun prepareSuccessView(response: ResponseModel) { + val restfulResponse = ViewModel( + userId = response.userId + ) + responseEntity = ok(restfulResponse) + } + + override fun prepareFailView(throwable: Throwable) = run { responseEntity = createErrorResponseFrom(throwable) } + + override fun isDone() = responseEntity != null + + private data class ViewModel( + val userId: UUID + ) : RepresentationModel() +} \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/user/presenter/RestfulPatchUserProfilePresenter.kt b/web/src/main/kotlin/br/all/user/presenter/RestfulPatchUserProfilePresenter.kt new file mode 100644 index 000000000..2205ac76c --- /dev/null +++ b/web/src/main/kotlin/br/all/user/presenter/RestfulPatchUserProfilePresenter.kt @@ -0,0 +1,42 @@ +package br.all.user.presenter + +import br.all.application.user.update.PatchUserProfilePresenter +import br.all.application.user.update.PatchUserProfileService.InvalidEntry +import br.all.application.user.update.PatchUserProfileService.ResponseModel +import br.all.shared.error.createErrorResponseFrom +import org.springframework.hateoas.RepresentationModel +import org.springframework.http.ResponseEntity +import org.springframework.http.ResponseEntity.ok +import java.util.UUID + +class RestfulPatchUserProfilePresenter : PatchUserProfilePresenter { + + var responseEntity: ResponseEntity<*>? = null + + override fun prepareSuccessView(response: ResponseModel) { + val restfulResponse = ViewModel( + userId = response.userId, + name = response.name, + username = response.username, + email = response.email, + affiliation = response.affiliation, + country = response.country, + invalidEntries = response.invalidEntries + ) + responseEntity = ok(restfulResponse) + } + + override fun prepareFailView(throwable: Throwable) = run { responseEntity = createErrorResponseFrom(throwable) } + + override fun isDone() = responseEntity != null + + private data class ViewModel( + val userId: UUID, + val name: String, + val username: String, + val email: String, + val affiliation: String, + val country: String, + val invalidEntries: List + ) : RepresentationModel() +} \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/user/presenter/RestfulRetrieveUserProfilePresenter.kt b/web/src/main/kotlin/br/all/user/presenter/RestfulRetrieveUserProfilePresenter.kt index 9e3fc3156..9884fe50f 100644 --- a/web/src/main/kotlin/br/all/user/presenter/RestfulRetrieveUserProfilePresenter.kt +++ b/web/src/main/kotlin/br/all/user/presenter/RestfulRetrieveUserProfilePresenter.kt @@ -15,6 +15,7 @@ class RestfulRetrieveUserProfilePresenter : RetrieveUserProfilePresenter { override fun prepareSuccessView(response: ResponseModel) { val restfulResponse = ViewModel( response.userId, + response.name, response.username, response.email, response.affiliation, @@ -30,6 +31,7 @@ class RestfulRetrieveUserProfilePresenter : RetrieveUserProfilePresenter { private data class ViewModel( val userId: UUID, + val name: String, val username: String, val email: String, val affiliation: String, diff --git a/web/src/main/kotlin/br/all/user/requests/ChangeAccountPasswordRequest.kt b/web/src/main/kotlin/br/all/user/requests/ChangeAccountPasswordRequest.kt new file mode 100644 index 000000000..7ce198d03 --- /dev/null +++ b/web/src/main/kotlin/br/all/user/requests/ChangeAccountPasswordRequest.kt @@ -0,0 +1,7 @@ +package br.all.user.requests + +data class ChangeAccountPasswordRequest( + val oldPassword: String, + val newPassword: String, + val confirmPassword: String +) \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/user/requests/PatchUserProfileRequest.kt b/web/src/main/kotlin/br/all/user/requests/PatchUserProfileRequest.kt new file mode 100644 index 000000000..c443ffe7a --- /dev/null +++ b/web/src/main/kotlin/br/all/user/requests/PatchUserProfileRequest.kt @@ -0,0 +1,8 @@ +package br.all.user.requests + +data class PatchUserProfileRequest( + val name: String, + val email: String, + val affiliation: String, + val country: String +) diff --git a/web/src/main/kotlin/br/all/utils/LinksFactory.kt b/web/src/main/kotlin/br/all/utils/LinksFactory.kt index e2ed2eb30..ea7d1b7f7 100644 --- a/web/src/main/kotlin/br/all/utils/LinksFactory.kt +++ b/web/src/main/kotlin/br/all/utils/LinksFactory.kt @@ -33,7 +33,7 @@ class LinksFactory { }.withRel("update-protocol").withType("PUT") fun createReview(): Link = linkTo { - postSystematicStudy(PostRequest("title", "description", setOf())) + postSystematicStudy(PostRequest("title", "description", setOf(), "objectives")) }.withRel("create-review").withType("POST") fun findReview(systematicStudyId: UUID): Link = linkTo { @@ -51,7 +51,7 @@ class LinksFactory { fun updateReview(systematicStudyId: UUID): Link = linkTo { updateSystematicStudy( systematicStudyId, - br.all.review.requests.PutRequest("title", "description"), + br.all.review.requests.PutRequest("title", "description", "objectives"), ) }.withRel("update-review").withType("PUT") @@ -81,6 +81,15 @@ class LinksFactory { ) }.withRel("create-pick-list-extraction-question").withType("POST") + fun createPickManyExtractionQuestion(systematicStudyId: UUID): Link = linkTo { + createPickManyQuestion( + systematicStudyId, + request = ExtractionQuestionController.PickManyRequest( + "code", "description", listOf("option1") + ) + ) + }.withRel("create-pick-many-extraction-question").withType("POST") + fun createLabeledScaleExtractionQuestion(systematicStudyId: UUID): Link = linkTo { createLabeledScaleQuestion( systematicStudyId, @@ -125,6 +134,15 @@ class LinksFactory { ) }.withRel("create-pick-list-rob-question").withType("POST") + fun createPickManyRobQuestion(systematicStudyId: UUID): Link = linkTo { + createPickManyQuestion( + systematicStudyId, + request = RiskOfBiasQuestionController.PickManyRequest( + "code", "description", listOf("option1") + ) + ) + }.withRel("create-pick-many-rob-question").withType("POST") + fun createLabeledScaleRobQuestion(systematicStudyId: UUID): Link = linkTo { createLabeledScaleQuestion( systematicStudyId, diff --git a/web/src/main/kotlin/br/all/utils/example/CreateSearchSessionExampleService.kt b/web/src/main/kotlin/br/all/utils/example/CreateSearchSessionExampleService.kt index 85b4b1377..cadbd7785 100644 --- a/web/src/main/kotlin/br/all/utils/example/CreateSearchSessionExampleService.kt +++ b/web/src/main/kotlin/br/all/utils/example/CreateSearchSessionExampleService.kt @@ -7,6 +7,7 @@ import br.all.application.study.repository.StudyReviewRepository import br.all.application.study.repository.toDto import br.all.domain.model.review.SystematicStudyId import br.all.domain.model.search.toSearchSessionID +import br.all.domain.model.study.StudyReview import br.all.domain.services.ConverterFactoryService import br.all.domain.services.ReviewSimilarityService import br.all.domain.services.ScoreCalculatorService @@ -27,7 +28,7 @@ class CreateSearchSessionExampleService ( private val scoreCalculatorService: ScoreCalculatorService, private val reviewSimilarityService: ReviewSimilarityService ) { - fun convert( + fun extractAndScoreStudiesFromFile( systematicStudyId: SystematicStudyId, userId: UUID, bibFileName: String, @@ -35,7 +36,7 @@ class CreateSearchSessionExampleService ( timestamp: LocalDateTime, searchString: String, additionalInformation: String - ) { + ): List { val search = uuidGeneratorService.next() val resource = ClassPathResource(bibFileName) @@ -60,15 +61,6 @@ class CreateSearchSessionExampleService ( val scoredStudyReviews = scoreCalculatorService.applyScoreToManyStudyReviews(studyReviews, protocolDto!!.keywords) - studyReviewRepository.saveOrUpdateBatch(scoredStudyReviews.map { it.toDto() }) - - val duplicatedAnalysedReviews = reviewSimilarityService.findDuplicates(scoredStudyReviews, emptyList()) - val toSaveDuplicatedAnalysedReviews = duplicatedAnalysedReviews - .flatMap { (key, value) -> listOf(key) + value } - .toList() - - studyReviewRepository.saveOrUpdateBatch(toSaveDuplicatedAnalysedReviews.map { it.toDto() }) - - searchSessionRepository.create(searchSession) + return scoredStudyReviews } } diff --git a/web/src/main/kotlin/br/all/utils/example/CreateSystematicReviewExampleService.kt b/web/src/main/kotlin/br/all/utils/example/CreateSystematicReviewExampleService.kt index addd68c30..8711ca382 100644 --- a/web/src/main/kotlin/br/all/utils/example/CreateSystematicReviewExampleService.kt +++ b/web/src/main/kotlin/br/all/utils/example/CreateSystematicReviewExampleService.kt @@ -26,13 +26,10 @@ class CreateSystematicReviewExampleService( SystematicStudyDto( id = systematicId, title = "A Systematic Review on Service-Oriented Robotic Systems Development", - description = """ - This systematic review, conducted in January 2012 at ICMC/USP, presents a detailed panorama on - the design, implementation, and usage of service-oriented robotic systems. It identifies key - technologies, methodologies, and software engineering guidelines in the field. - """.trimIndent(), + description = """This systematic review, conducted in January 2012 at ICMC/USP, presents a detailed panorama on the design, implementation, and usage of service-oriented robotic systems. It identifies key technologies, methodologies, and software engineering guidelines in the field.""".trimIndent(), owner = ownerId, - collaborators = collaboratorIds + collaborators = collaboratorIds, + objectives = "These are the main objectives of this systematic review." ) ) @@ -85,6 +82,22 @@ class CreateSystematicReviewExampleService( ) ) + val eq5 = question.createQuestion( + reviewId = systematicId, + code = "EQ5", + title = "Which of the following service-oriented architectures (SOAs) have been applied to robotic systems in the reviewed studies?", + type = "PICK_MANY", + context = QuestionContextEnum.EXTRACTION, + options = listOf( + "ROS (Robot Operating System)", + "DDS (Data Distribution Service)", + "REST (Representational State Transfer)", + "SOAP (Simple Object Access Protocol)", + "Corba (Common Object Request Broker Architecture)", + "MQTT (Message Queuing Telemetry Transport)" + ) + ) + val rbq1 = question.createQuestion( reviewId = systematicId, code = "RBQ1", @@ -133,6 +146,21 @@ class CreateSystematicReviewExampleService( lower = 1 ) + val rbq5 = question.createQuestion( + reviewId = systematicId, + code = "RBQ5", + title = "Select all potential sources of bias that are present in the primary study.", + type = "PICK_MANY", + context = QuestionContextEnum.ROB, + options = listOf( + "Selection bias (e.g. non-randomized sampling)", + "Performance bias (e.g. lack of blinding of participants)", + "Detection bias (e.g. lack of blinding of outcome assessors)", + "Attrition bias (e.g. incomplete outcome data)", + "Reporting bias (e.g. selective reporting of outcomes)" + ) + ) + protocolRepository.saveOrUpdate( ProtocolDto( id = systematicId, @@ -175,8 +203,8 @@ class CreateSystematicReviewExampleService( ), dataCollectionProcess = "Data extraction will be performed using pre-defined extraction tables corresponding to each research question.", analysisAndSynthesisProcess = "Data were synthesized using statistical methods and meta-analysis to draw conclusions about the research area.", - extractionQuestions = setOf(eq1.questionId, eq2.questionId, eq3.questionId, eq4.questionId), - robQuestions = setOf(rbq1.questionId, rbq2.questionId, rbq3.questionId, rbq4.questionId), + extractionQuestions = setOf(eq1.questionId, eq2.questionId, eq3.questionId, eq4.questionId, eq5.questionId), + robQuestions = setOf(rbq1.questionId, rbq2.questionId, rbq3.questionId, rbq4.questionId, rbq5.questionId), picoc = PicocDto( population = "Researchers and developers of robotic systems interested in employing SOA.", intervention = "The development and use of service-oriented robotic systems.", diff --git a/web/src/main/kotlin/br/all/utils/example/RegisterUserExampleService.kt b/web/src/main/kotlin/br/all/utils/example/RegisterUserExampleService.kt index 8c61d6b1c..794c52426 100644 --- a/web/src/main/kotlin/br/all/utils/example/RegisterUserExampleService.kt +++ b/web/src/main/kotlin/br/all/utils/example/RegisterUserExampleService.kt @@ -3,6 +3,9 @@ package br.all.utils.example import br.all.application.user.repository.UserAccountRepository import br.all.application.user.repository.toDto import br.all.domain.shared.user.Email +import br.all.domain.shared.user.Name +import br.all.domain.shared.user.Text +import br.all.domain.shared.user.Username import br.all.domain.user.* import org.springframework.stereotype.Service import java.util.* @@ -14,9 +17,10 @@ class RegisterUserExampleService ( fun registerUserAccount( username: String, password: String, - email: String = "email@email.com", - country: String = "Country", - affiliation: String = "affiliation", + email: String = "lucas@gmail.com", + country: String = "Brazil", + affiliation: String = "IFSP", + name: String = "Lucas" ): UserAccount { val newUserAccount = UserAccount( @@ -26,7 +30,8 @@ class RegisterUserExampleService ( email = Email(email), country = Text(country), affiliation = affiliation, - authorities = setOf(Authority.USER) + authorities = setOf(Authority.USER), + name = Name(name), ) repo.save(newUserAccount.toDto()) diff --git a/web/src/main/resources/Springer.bib b/web/src/main/resources/Springer.bib index e4fbccbba..ba6860636 100644 --- a/web/src/main/resources/Springer.bib +++ b/web/src/main/resources/Springer.bib @@ -27,7 +27,7 @@ @INCOLLECTION{Awaad2008 affiliation = {Bonn-Rhein-Sieg University of Applied Science Grantham-Allee 20 53757 Sankt Augustin Germany}, isbn = {978-3-540-89075-1}, - keyword = {Computer Science}, + keywords = {Computer Science,Robotic Learning}, source = {Springer}, url = {http://dx.doi.org/10.1007/978-3-540-89076-8_13} } @@ -52,7 +52,7 @@ @INCOLLECTION{Borangiu2009 affiliation = {Department of Automatic Control and Industrial Informatics, University Politehnica of Bucharest, Bucharest, Romania}, isbn = {978-90-481-3522-6}, - keyword = {Engineering}, + keywords = {Engineering;SOA}, source = {Springer}, url = {http://dx.doi.org/10.1007/978-90-481-3522-6_49} } @@ -77,7 +77,7 @@ @INCOLLECTION{Borangiu2009 affiliation = {Department of Automatic Control and Industrial Informatics, University Politehnica of Bucharest, Bucharest, Romania}, isbn = {978-90-481-3522-6}, - keyword = {Engineering}, + keywords = {Engineering}, source = {Springer}, url = {http://dx.doi.org/10.1007/978-90-481-3522-6_49} } @@ -102,7 +102,7 @@ @INCOLLECTION{Borangiu2009 affiliation = {Department of Automatic Control and Industrial Informatics, University Politehnica of Bucharest, Bucharest, Romania}, isbn = {978-90-481-3522-6}, - keyword = {Engineering}, + keywords = {Engineering}, source = {Springer}, url = {http://dx.doi.org/10.1007/978-90-481-3522-6_49} } @@ -127,7 +127,7 @@ @INCOLLECTION{Borangiu2009 affiliation = {Department of Automatic Control and Industrial Informatics, University Politehnica of Bucharest, Bucharest, Romania}, isbn = {978-90-481-3522-6}, - keyword = {Engineering}, + keywords = {Engineering}, source = {Springer}, url = {http://dx.doi.org/10.1007/978-90-481-3522-6_49} } @@ -158,7 +158,7 @@ @INCOLLECTION{DeCarolis2009 socially oriented situations.}, affiliation = {University of Bari Dipartimento di Informatica}, isbn = {978-3-642-05407-5}, - keyword = {Computer Science}, + keywords = {Computer Science}, source = {Springer}, url = {http://dx.doi.org/10.1007/978-3-642-05408-2_20} } @@ -195,7 +195,7 @@ @INCOLLECTION{Helal2008 affiliation = {University of Florida Department of Computer & Information Science & Engineering Gainesville FL 32611 USA}, isbn = {978-3-540-70584-0}, - keyword = {Computer Science}, + keywords = {Computer Science}, source = {Springer}, url = {http://dx.doi.org/10.1007/978-3-540-70585-7_1} } @@ -229,7 +229,7 @@ @INCOLLECTION{Ko2009 affiliation = {Korea Advanced Institute of Science and Technology (KAIST) Computer Science Department Daejeon Korea}, isbn = {978-3-642-04594-3}, - keyword = {Computer Science}, + keywords = {Computer Science}, source = {Springer}, url = {http://dx.doi.org/10.1007/978-3-642-04595-0_46} } @@ -259,7 +259,7 @@ @INCOLLECTION{Kononchuk2011a comparison to other robot control systems.}, affiliation = {Ural State University, Lenina str. 51, Yekaterinburg, Russia}, isbn = {978-3-642-21975-7}, - keyword = {Computer Science}, + keywords = {Computer Science}, source = {Springer}, url = {http://dx.doi.org/10.1007/978-3-642-21975-7_15} } @@ -290,7 +290,7 @@ @INCOLLECTION{Mikulski2011 affiliation = {Institute of Automatic Control, Silesian University of Technology, Akademicka 16, 44-100 Gliwice, Poland}, isbn = {978-3-642-23168-1}, - keyword = {Books}, + keywords = {Books}, source = {Springer}, url = {http://dx.doi.org/10.1007/978-3-642-23169-8_9} } @@ -335,7 +335,7 @@ @INCOLLECTION{Pop2009 affiliation = {Telecommunications and IT Technical University of Cluj-Napoca, Faculty of Electronics Cluj-Napoca Romania}, isbn = {978-3-642-04292-8}, - keyword = {Engineering}, + keywords = {Engineering}, source = {Springer}, url = {http://dx.doi.org/10.1007/978-3-642-04292-8_13} } @@ -367,7 +367,7 @@ @INCOLLECTION{Weigand2011 a robot cleaner.}, affiliation = {Tilburg University, P.O.Box 90153, 5000 LE Tilburg, The Netherlands}, isbn = {978-3-642-21639-8}, - keyword = {Computer Science}, + keywords = {Computer Science}, source = {Springer}, url = {http://dx.doi.org/10.1007/978-3-642-21640-4_43} } @@ -399,7 +399,7 @@ @INCOLLECTION{Weigand2011 a robot cleaner.}, affiliation = {Tilburg University, P.O.Box 90153, 5000 LE Tilburg, The Netherlands}, isbn = {978-3-642-21639-8}, - keyword = {Computer Science}, + keywords = {Computer Science}, source = {Springer}, url = {http://dx.doi.org/10.1007/978-3-642-21640-4_43} } @@ -431,7 +431,7 @@ @INCOLLECTION{Weigand2011 a robot cleaner.}, affiliation = {Tilburg University, P.O.Box 90153, 5000 LE Tilburg, The Netherlands}, isbn = {978-3-642-21639-8}, - keyword = {Computer Science}, + keywords = {Computer Science}, source = {Springer}, url = {http://dx.doi.org/10.1007/978-3-642-21640-4_43} } @@ -463,7 +463,7 @@ @INCOLLECTION{Weigand2011 a robot cleaner.}, affiliation = {Tilburg University, P.O.Box 90153, 5000 LE Tilburg, The Netherlands}, isbn = {978-3-642-21639-8}, - keyword = {Computer Science}, + keywords = {Computer Science}, source = {Springer}, url = {http://dx.doi.org/10.1007/978-3-642-21640-4_43} } @@ -493,7 +493,7 @@ @INCOLLECTION{Yeom2007a affiliation = {School of Computer Science, The University of Seoul, Jeonnong-dong, Dongdaemun-gu, Seoul, 130-743 Korea}, isbn = {978-3-540-73548-9}, - keyword = {Computer Science}, + keywords = {Computer Science}, source = {Springer}, url = {http://dx.doi.org/10.1007/978-3-540-73549-6_8} } @@ -525,7 +525,7 @@ @INCOLLECTION{Zhu2011 affiliation = {School of Computer Science and Engineering, South China University of Technology, Guangzhou, China}, isbn = {978-3-642-23147-6}, - keyword = {Computer Science}, + keywords = {Computer Science}, source = {Springer}, url = {http://dx.doi.org/10.1007/978-3-642-23147-6_2} } @@ -557,7 +557,7 @@ @INCOLLECTION{Zhu2011 affiliation = {School of Computer Science and Engineering, South China University of Technology, Guangzhou, China}, isbn = {978-3-642-23147-6}, - keyword = {Computer Science}, + keywords = {Computer Science}, source = {Springer}, url = {http://dx.doi.org/10.1007/978-3-642-23147-6_2} } diff --git a/web/src/main/resources/banner.txt b/web/src/main/resources/banner.txt new file mode 100644 index 000000000..caa41d7af --- /dev/null +++ b/web/src/main/resources/banner.txt @@ -0,0 +1,10 @@ + █████████ █████ █████████ █████ █████████ ███████████ █████ + ███░░░░░███ ░░███ ███░░░░░███ ░░███ ███░░░░░███ ░░███░░░░░███░░███ +░███ ░░░ ███████ ░███ ░███ ████████ ███████ ░███ ░███ ░███ ░███ ░███ +░░█████████ ░░░███░ ░███████████ ░░███░░███░░░███░ ░███████████ ░██████████ ░███ + ░░░░░░░░███ ░███ ░███░░░░░███ ░███ ░░░ ░███ ░███░░░░░███ ░███░░░░░░ ░███ + ███ ░███ ░███ ███ ░███ ░███ ░███ ░███ ███ ░███ ░███ ░███ ░███ +░░█████████ ░░█████ █████ █████ █████ ░░█████ █████ █████ █████ █████ + ░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ + + 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 315c98db0..5b1c1bdba 100644 --- a/web/src/test/kotlin/br/all/protocol/controller/ProtocolControllerTest.kt +++ b/web/src/test/kotlin/br/all/protocol/controller/ProtocolControllerTest.kt @@ -63,13 +63,13 @@ class ProtocolControllerTest( } private fun getUrl(systematicStudy: UUID = factory.protocol) = - "/systematic-study/$systematicStudy/protocol" + "/api/v1/systematic-study/$systematicStudy/protocol" private fun putUrl(systematicStudyId: UUID = factory.protocol) = - "/systematic-study/$systematicStudyId/protocol" + "/api/v1/systematic-study/$systematicStudyId/protocol" private fun findStage(systematicStudy: UUID = factory.protocol) = - "/systematic-study/$systematicStudy/protocol/stage" + "/api/v1/systematic-study/$systematicStudy/protocol/stage" @Nested @DisplayName("When getting protocols") 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 f9b441db8..c05d71810 100644 --- a/web/src/test/kotlin/br/all/question/controller/ExtractionQuestionControllerTest.kt +++ b/web/src/test/kotlin/br/all/question/controller/ExtractionQuestionControllerTest.kt @@ -93,6 +93,19 @@ class ExtractionQuestionControllerTest( .andExpect(jsonPath("$._links").exists()) } + @Test + fun `should create pickmany question and return 201`() { + val json = factory.validCreatePickManyRequest() + mockMvc.perform( + post(postUrl() + "/pick-many").contentType(MediaType.APPLICATION_JSON).content(json) + .with(SecurityMockMvcRequestPostProcessors.user(user)) + ) + .andDo(print()) + .andExpect(status().isCreated) + .andExpect(jsonPath("$.systematicStudyId").value(systematicStudyId.toString())) + .andExpect(jsonPath("$._links").exists()) + } + @Test fun `should create labeledscale question and return 201`() { val json = factory.validCreateLabeledScaleRequest() @@ -155,6 +168,22 @@ class ExtractionQuestionControllerTest( } + @Test + fun `should find pickmany question and return 200`() { + val question = factory.validCreatePickManyQuestionDocument(questionId, systematicStudyId) + + repository.insert(question) + + val questionIdUrl = "/${questionId}" + mockMvc.perform(get(getUrl(questionIdUrl)).contentType(MediaType.APPLICATION_JSON) + .with(SecurityMockMvcRequestPostProcessors.user(user)) + ) + + .andExpect(status().isOk) + .andExpect(jsonPath("$.systematicStudyId").value(question.systematicStudyId.toString())) + .andExpect(jsonPath("$._links").exists()) + } + @Test fun `should find labeled scale question and return 200`() { val question = factory.validCreateLabeledScaleQuestionDocument(questionId, systematicStudyId) @@ -245,6 +274,17 @@ class ExtractionQuestionControllerTest( ).andExpect(status().isBadRequest) } + @Test + fun `should not create pickmany question with invalid input and return 400`() { + val json = factory.invalidCreatePickManyRequest() + mockMvc.perform( + post(postUrl() + "/pick-many") + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .with(SecurityMockMvcRequestPostProcessors.user(user)) + ).andExpect(status().isBadRequest) + } + @Test fun `should not create labeled scale question with invalid input and return 400`() { val json = factory.invalidCreateLabeledScaleRequest() 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 2ea33c5e5..50b4591eb 100644 --- a/web/src/test/kotlin/br/all/question/controller/RiskOfBiasQuestionControllerTest.kt +++ b/web/src/test/kotlin/br/all/question/controller/RiskOfBiasQuestionControllerTest.kt @@ -102,6 +102,20 @@ class RiskOfBiasQuestionControllerTest( .andExpect(jsonPath("$._links").exists()) } + @Test + fun `should create pickmany question and return 201`() { + val json = factory.validCreatePickManyRequest() + mockMvc.perform( + post(postUrl() + "/pick-many").contentType(MediaType.APPLICATION_JSON).content(json) + .with(SecurityMockMvcRequestPostProcessors.user(user)) + ) + + .andDo(print()) + .andExpect(status().isCreated) + .andExpect(jsonPath("$.systematicStudyId").value(systematicStudyId.toString())) + .andExpect(jsonPath("$._links").exists()) + } + @Test fun `should create labeled scale question and return 201`() { val json = factory.validCreateLabeledScaleRequest() @@ -163,6 +177,21 @@ class RiskOfBiasQuestionControllerTest( } + @Test + fun `should find pickmany question and return 200`() { + val question = factory.validCreatePickManyQuestionDocument(questionId, systematicStudyId) + + repository.insert(question) + + val questionIdUrl = "/${questionId}" + mockMvc.perform(get(getUrl(questionIdUrl)).contentType(MediaType.APPLICATION_JSON) + .with(SecurityMockMvcRequestPostProcessors.user(user)) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.systematicStudyId").value(question.systematicStudyId.toString())) + .andExpect(jsonPath("$._links").exists()) + } + @Test fun `should find labeled scale question and return 200`() { val question = factory.validCreateLabeledScaleQuestionDocument(questionId, systematicStudyId) @@ -255,6 +284,17 @@ class RiskOfBiasQuestionControllerTest( ).andExpect(status().isBadRequest) } + @Test + fun `should not create pickmany question with invalid input and return 400`() { + val json = factory.invalidCreatePickManyRequest() + mockMvc.perform( + post(postUrl() + "/pick-many") + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .with(SecurityMockMvcRequestPostProcessors.user(user)) + ).andExpect(status().isBadRequest) + } + @Test fun `should not create labeled scale question with invalid input and return 400`() { val json = factory.invalidCreateLabeledScaleRequest() diff --git a/web/src/test/kotlin/br/all/question/utils/TestDataFactory.kt b/web/src/test/kotlin/br/all/question/utils/TestDataFactory.kt index 2caa8698f..09a783414 100644 --- a/web/src/test/kotlin/br/all/question/utils/TestDataFactory.kt +++ b/web/src/test/kotlin/br/all/question/utils/TestDataFactory.kt @@ -42,6 +42,22 @@ class TestDataFactory { } """ + fun validCreatePickManyRequest(researcher: UUID = researcherId, systematicStudyId: UUID = this.systematicStudyId) = + """ + { + "researcherId": "$researcher", + "systematicStudyId": "$systematicStudyId", + "type": "PICK_MANY", + "code": "${faker.lorem.words()}", + "description": "${faker.paragraph(8)}", + "options": [ + "${faker.lorem.words()}", + "${faker.lorem.words()}", + "${faker.lorem.words()}" + ] + } + """ + fun validCreateLabeledScaleRequest(researcher: UUID = researcherId, systematicStudyId: UUID = this.systematicStudyId) = """ { @@ -94,6 +110,21 @@ class TestDataFactory { } """ + fun invalidCreatePickManyRequest(researcher: UUID = researcherId, systematicStudyId: UUID = this.systematicStudyId) = + """ + { + "researcherId": "$researcher", + "systematicStudyId": "$systematicStudyId", + "type": "PICK_MANY", + "code": "${faker.lorem.words()}", + "description": "${faker.paragraph(8)}", + "options": [ + "${faker.lorem.words()}", + "" + ] + } + """ + fun invalidCreateLabeledScaleRequest(researcher: UUID = researcherId, systematicStudyId: UUID = this.systematicStudyId) = """ { @@ -158,6 +189,25 @@ class TestDataFactory { questionType ) + fun validCreatePickManyQuestionDocument( + questionId: UUID, + systematicStudyId: UUID, + code: String = faker.lorem.words(), + description: String = faker.lorem.words(), + questionType: QuestionContextEnum = QuestionContextEnum.ROB + ) = QuestionDocument( + questionId, + systematicStudyId, + code, + description, + "PICK_MANY", + null, + null, + null, + listOf(faker.lorem.words(), faker.lorem.words()), + questionType + ) + fun validCreateLabeledScaleQuestionDocument( questionId: UUID, systematicStudyId: UUID, 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 bf5ded6e2..efbaba82f 100644 --- a/web/src/test/kotlin/br/all/review/shared/TestDataFactory.kt +++ b/web/src/test/kotlin/br/all/review/shared/TestDataFactory.kt @@ -16,25 +16,36 @@ class TestDataFactory { description: String = faker.lorem.words(), owner: UUID = this.researcherId, collaborators: MutableSet = mutableSetOf(), - ) = SystematicStudyDocument(id, title, description, owner, collaborators.also { it.add(owner) }.toSet()) + objectives: String = faker.adjective.positive(), + ) = SystematicStudyDocument(id, title, description, owner, collaborators.also { it.add(owner) }.toSet(), objectives) fun createValidPostRequest( title: String = faker.book.title(), description: String = faker.lorem.words(), - collaborators: Set = emptySet() - ) = """ + collaborators: Set = emptySet(), + objectives: String = faker.animal.name(), + ): String { + val collaboratorsJson = collaborators.joinToString( + prefix = "[", + postfix = "]" + ) { "\"$it\"" } + + return """ { "title": "$title", "description": "$description", - "collaborators": $collaborators + "collaborators": $collaboratorsJson, + "objectives": "$objectives" } """.trimIndent() + } fun createInvalidPostRequest() = """ { "title": "", "description": "", "collaborators": [], + "objectives": "" } """.trimIndent() diff --git a/web/src/test/kotlin/br/all/shared/TestHelperService.kt b/web/src/test/kotlin/br/all/shared/TestHelperService.kt index bf6d40fac..ba9b85ec7 100644 --- a/web/src/test/kotlin/br/all/shared/TestHelperService.kt +++ b/web/src/test/kotlin/br/all/shared/TestHelperService.kt @@ -25,6 +25,7 @@ class TestHelperService( fun createApplicationUser(): ApplicationUser { val userDto = UserAccountDto( id = UUID.randomUUID(), + name = faker.name.neutralFirstName(), username = faker.name.firstName() + "_" + UUID.randomUUID().toString().take(8), password = faker.fallout.locations(), email = faker.internet.email(), @@ -46,6 +47,7 @@ class TestHelperService( fun createUnauthorizedApplicationUser(): ApplicationUser { val userDto = UserAccountDto( id = UUID.randomUUID(), + name = faker.clashOfClans.troops(), username = faker.name.firstName() + "_" + UUID.randomUUID().toString().take(8), password = faker.fallout.locations(), email = faker.internet.email(), diff --git a/web/src/test/kotlin/br/all/user/controller/UserAccountControllerTest.kt b/web/src/test/kotlin/br/all/user/controller/UserAccountControllerTest.kt new file mode 100644 index 000000000..b64bba359 --- /dev/null +++ b/web/src/test/kotlin/br/all/user/controller/UserAccountControllerTest.kt @@ -0,0 +1,156 @@ +package br.all.user.controller + +import br.all.application.user.repository.UserAccountDto +import br.all.application.user.repository.UserAccountRepository +import br.all.security.service.ApplicationUser +import br.all.security.service.AuthenticationService +import br.all.shared.TestHelperService +import br.all.user.shared.TestDataFactory +import jakarta.transaction.Transactional +import org.junit.jupiter.api.* +import org.junit.jupiter.api.Assertions.* +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.util.UUID + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@Tag("IntegrationTest") +@Tag("ControllerTest") +@DisplayName("User Account Controller Integration Tests") +class UserAccountControllerTest( + @Autowired private val mockMvc: MockMvc, + @Autowired private val userAccountRepository: UserAccountRepository, + @Autowired private val testHelperService: TestHelperService, + @Autowired private val passwordEncoder: PasswordEncoder +) { + private lateinit var factory: TestDataFactory + private lateinit var testUser: ApplicationUser + private lateinit var userDto: UserAccountDto + + @MockitoBean + private lateinit var authenticationService: AuthenticationService + + private val testRefreshToken = "test-refresh-token-${UUID.randomUUID()}" + + @BeforeEach + fun setUp() { + factory = TestDataFactory() + + testUser = testHelperService.createApplicationUser() + + userDto = factory.createUserDto( + id = testUser.id, + email = testUser.username, + password = passwordEncoder.encode(factory.rawPassword) + ) + userAccountRepository.save(userDto) + userAccountRepository.updateRefreshToken(testUser.id, testRefreshToken) + } + + private fun registerUrl() = "/api/v1/user" + private fun profileUrl() = "/api/v1/user/profile" + private fun changePasswordUrl() = "/api/v1/user/change-password" + + @Nested + @DisplayName("When registering a new user") + inner class WhenRegisteringUser { + @Test + fun `should create a new user and return 201 Created`() { + val newUserEmail = "new.user@test.com" + val requestJson = factory.createValidRegisterRequestJson(email = newUserEmail) + + mockMvc.perform( + post(registerUrl()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson) + ) + .andExpect(status().isCreated) + .andExpect(jsonPath("$.email").value(newUserEmail)) + + assertTrue(userAccountRepository.existsByEmail(newUserEmail)) + } + + @Test + fun `should return 409 Conflict when email already exists`() { + val requestJson = factory.createValidRegisterRequestJson(email = testUser.username) + + mockMvc.perform( + post(registerUrl()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson) + ) + .andExpect(status().isConflict) + } + } + + @Nested + @DisplayName("When retrieving user profile") + inner class WhenRetrievingUserProfile { + @Test + fun `should return user profile and 200 OK for authenticated user`() { + mockMvc.perform( + get(profileUrl()) + .with(user(testUser)) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.userId").value(testUser.id.toString())) + .andExpect(jsonPath("$.name").value(userDto.name)) + .andExpect(jsonPath("$.email").value(userDto.email)) + } + + @Test + fun `should return 401 Unauthorized for unauthenticated user`() { + testHelperService.testForUnauthenticatedUser(mockMvc, get(profileUrl())) + } + } + + @Nested + @DisplayName("When updating user profile") + inner class WhenUpdatingUserProfile { + @Test + fun `should update user profile and return 200 OK`() { + val newName = "Updated Name" + val updateRequestJson = factory.createPatchProfileRequestJson(name = newName) + + mockMvc.perform( + patch(profileUrl()) + .with(user(testUser)) + .contentType(MediaType.APPLICATION_JSON) + .content(updateRequestJson) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.name").value(newName)) + + val updatedProfile = userAccountRepository.loadUserProfileById(testUser.id) + assertEquals(newName, updatedProfile?.name) + } + } + + @Nested + @DisplayName("When changing user password") + inner class WhenChangingUserPassword { + @Test + fun `should return 400 Bad Request if old password is incorrect`() { + val requestJson = factory.createChangePasswordRequestJson(oldPassword = "wrong-password") + + mockMvc.perform( + put(changePasswordUrl()) + .with(user(testUser)) + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson) + ) + .andExpect(status().isBadRequest) + } + } +} \ No newline at end of file diff --git a/web/src/test/kotlin/br/all/user/persistence/UserAccountRepositoryTest.kt b/web/src/test/kotlin/br/all/user/persistence/UserAccountRepositoryTest.kt new file mode 100644 index 000000000..3b8d2db6d --- /dev/null +++ b/web/src/test/kotlin/br/all/user/persistence/UserAccountRepositoryTest.kt @@ -0,0 +1,152 @@ +package br.all.user.persistence + +import br.all.application.user.repository.UserAccountRepository +import br.all.user.shared.TestDataFactory +import jakarta.transaction.Transactional +import org.junit.jupiter.api.Assertions.* +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.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.security.crypto.password.PasswordEncoder +import java.util.* + +@SpringBootTest +@Transactional +@Tag("IntegrationTest") +@Tag("RepositoryTest") +class UserAccountRepositoryTest { + + @Autowired + private lateinit var sut: UserAccountRepository + + @Autowired + private lateinit var passwordEncoder: PasswordEncoder + + private lateinit var factory: TestDataFactory + + @BeforeEach + fun setUp() { + factory = TestDataFactory() + } + + @Nested + @DisplayName("When saving and loading users") + inner class SavingAndLoading { + @Test + fun `should save a complete UserAccountDto and load it back successfully`() { + val originalDto = factory.createUserDto(password = passwordEncoder.encode(factory.rawPassword)) + + sut.save(originalDto) + val loadedDto = sut.loadFullUserAccountById(originalDto.id) + + assertNotNull(loadedDto) + assertEquals(originalDto, loadedDto) + } + + @Test + fun `should load user credentials by username`() { + val dto = factory.createUserDto(password = passwordEncoder.encode(factory.rawPassword)) + sut.save(dto) + + val credentials = sut.loadCredentialsByUsername(dto.username) + + assertNotNull(credentials) + assertEquals(dto.id, credentials?.id) + assertEquals(dto.username, credentials?.username) + } + + @Test + fun `should load a user profile by ID`() { + val dto = factory.createUserDto(password = passwordEncoder.encode(factory.rawPassword)) + sut.save(dto) + + val profile = sut.loadUserProfileById(dto.id) + + assertNotNull(profile) + assertEquals(dto.id, profile?.id) + assertEquals(dto.name, profile?.name) + assertEquals(dto.email, profile?.email) + } + + @Test + fun `should return null when loading a nonexistent user`() { + assertNull(sut.loadFullUserAccountById(UUID.randomUUID())) + assertNull(sut.loadCredentialsByUsername("nonexistent")) + assertNull(sut.loadUserProfileById(UUID.randomUUID())) + } + } + + @Nested + @DisplayName("When updating user data") + inner class Updating { + @Test + fun `should update a user's password`() { + val dto = factory.createUserDto(password = passwordEncoder.encode(factory.rawPassword)) + sut.save(dto) + val newPassword = "new-secure-password-123" + val newHashedPassword = passwordEncoder.encode(newPassword) + + sut.updatePassword(dto.id, newHashedPassword) + val updatedCredentials = sut.loadCredentialsById(dto.id) + + assertNotNull(updatedCredentials) + assertTrue(passwordEncoder.matches(newPassword, updatedCredentials!!.password)) + assertFalse(passwordEncoder.matches(factory.rawPassword, updatedCredentials.password)) + } + + @Test + fun `should update and clear a user's refresh token`() { + val dto = factory.createUserDto(password = passwordEncoder.encode(factory.rawPassword)) + sut.save(dto) + val newToken = "refreshtoken-${UUID.randomUUID()}" + + sut.updateRefreshToken(dto.id, newToken) + var credentials = sut.loadCredentialsById(dto.id) + + assertNotNull(credentials) + assertEquals(newToken, credentials?.refreshToken) + + sut.updateRefreshToken(dto.id, null) + credentials = sut.loadCredentialsById(dto.id) + + assertNotNull(credentials) + assertNull(credentials?.refreshToken) + } + } + + @Nested + @DisplayName("When checking for existence") + inner class ExistenceChecks { + @Test + fun `should correctly check for existence by email and username`() { + val dto = factory.createUserDto(password = passwordEncoder.encode(factory.rawPassword)) + sut.save(dto) + + assertTrue(sut.existsByEmail(dto.email)) + assertTrue(sut.existsByUsername(dto.username)) + assertFalse(sut.existsByEmail("nonexistent@test.com")) + assertFalse(sut.existsByUsername("nonexistent.user")) + } + } + + @Nested + @DisplayName("When deleting users") + inner class Deleting { + @Test + fun `should delete the user from all related tables`() { + val dto = factory.createUserDto(password = passwordEncoder.encode(factory.rawPassword)) + sut.save(dto) + + assertNotNull(sut.loadFullUserAccountById(dto.id)) + + sut.deleteById(dto.id) + + assertNull(sut.loadFullUserAccountById(dto.id)) + assertFalse(sut.existsByUsername(dto.username)) + } + } +} \ No newline at end of file diff --git a/web/src/test/kotlin/br/all/user/shared/TestDataFactory.kt b/web/src/test/kotlin/br/all/user/shared/TestDataFactory.kt new file mode 100644 index 000000000..fe63b55b5 --- /dev/null +++ b/web/src/test/kotlin/br/all/user/shared/TestDataFactory.kt @@ -0,0 +1,92 @@ +package br.all.user.shared + +import br.all.application.user.repository.UserAccountDto +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.github.serpro69.kfaker.Faker +import java.time.LocalDateTime +import java.util.* + +class TestDataFactory { + private val faker = Faker() + + val userId: UUID = UUID.randomUUID() + val username: String = faker.name.firstName() + val name: String = faker.name.neutralFirstName() + val email: String = faker.internet.email() + val rawPassword: String = faker.pearlJam.songs() + val affiliation: String = faker.university.name() + val country: String = faker.address.country() + + fun createUserDto( + id: UUID = userId, + name: String = this.name, + username: String = this.username, + email: String = this.email, + password: String, + country: String = this.country, + affiliation: String = this.affiliation, + createdAt: LocalDateTime = LocalDateTime.now(), + authorities: Set = setOf("ROLE_USER") + ) = UserAccountDto( + id = id, + name = name, + username = username, + password = password, + email = email, + country = country, + affiliation = affiliation, + createdAt = createdAt, + authorities = authorities, + refreshToken = null, + isAccountNonExpired = true, + isAccountNonLocked = true, + isCredentialsNonExpired = true, + isEnabled = true + ) + + fun createValidRegisterRequestJson( + name: String = faker.name.name(), + username: String = faker.name.firstName(), + email: String = faker.internet.email(), + password: String = faker.pearlJam.songs(), + affiliation: String = faker.university.name(), + country: String = faker.address.country() + ): String { + val map = mapOf( + "name" to name, + "username" to username, + "email" to email, + "password" to password, + "affiliation" to affiliation, + "country" to country + ) + return jacksonObjectMapper().writeValueAsString(map) + } + + fun createPatchProfileRequestJson( + name: String? = faker.name.name(), + email: String? = faker.internet.email(), + affiliation: String? = faker.university.name(), + country: String? = faker.address.country() + ): String { + val map = mutableMapOf() + name?.let { map["name"] = it } + email?.let { map["email"] = it } + affiliation?.let { map["affiliation"] = it } + country?.let { map["country"] = it } + return jacksonObjectMapper().writeValueAsString(map) + } + + fun createChangePasswordRequestJson( + oldPassword: String = this.rawPassword, + newPassword: String = faker.pearlJam.songs(), + confirmPassword: String = newPassword + ): String { + val map = mapOf( + "oldPassword" to oldPassword, + "newPassword" to newPassword, + "confirmPassword" to confirmPassword + ) + return jacksonObjectMapper().writeValueAsString(map) + } +} \ No newline at end of file