From e043f9217268d156fd75e11a9b54dd1ec1037aa5 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Sat, 9 Aug 2025 13:41:39 -0300 Subject: [PATCH 01/49] feat: add new patch profile presenter --- .../application/user/update/PatchUserProfilePresenter.kt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 account/src/main/kotlin/br/all/application/user/update/PatchUserProfilePresenter.kt 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 From e97b0723887deb4a376765f47b80f80b9280642b Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Sat, 9 Aug 2025 13:41:46 -0300 Subject: [PATCH 02/49] feat: add new patch profile service --- .../user/update/PatchUserProfileService.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 account/src/main/kotlin/br/all/application/user/update/PatchUserProfileService.kt 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..17ff10a3f --- /dev/null +++ b/account/src/main/kotlin/br/all/application/user/update/PatchUserProfileService.kt @@ -0,0 +1,24 @@ +package br.all.application.user.update + +import java.util.UUID + +interface PatchUserProfileService { + fun patchProfile(presenter: PatchUserProfilePresenter, request: RequestModel) + + data class RequestModel( + val userId: UUID, + val username: String, + val email: String, + val affiliation: String, + val country: String + ) + + data class ResponseModel( + val userId: UUID, + val username: String, + val email: String, + val affiliation: String, + val country: String, + val invalidEntries: List + ) +} \ No newline at end of file From 7ace4b75299ce1023527e1e032d778b91f28cbf3 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Mon, 11 Aug 2025 19:36:32 -0300 Subject: [PATCH 03/49] wip: implement patch user profile service --- .../update/PatchUserProfileServiceImpl.kt | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 account/src/main/kotlin/br/all/application/user/update/PatchUserProfileServiceImpl.kt 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..bd4f5444c --- /dev/null +++ b/account/src/main/kotlin/br/all/application/user/update/PatchUserProfileServiceImpl.kt @@ -0,0 +1,48 @@ +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.domain.shared.user.Email +import br.all.domain.user.Text +import br.all.domain.user.Username + +class PatchUserProfileServiceImpl( + private val repository: UserAccountRepository +) : PatchUserProfileService { + override fun patchProfile( + presenter: PatchUserProfilePresenter, + request: RequestModel + ) { + if (repository.loadCredentialsById(request.userId) == null) { + presenter.prepareFailView(NoSuchElementException("User with id ${request.userId} not found!")) + } + + if (presenter.isDone()) return + + val newUsername = Username(request.username) + val newEmail = Email(request.email) + val affiliation = Text(request.affiliation) + val country = Text(request.country) + + // TODO(): add invalid entries to the response model array + // TODO(): update only the valid entries + + // data class RequestModel( + // val userId: UUID, + // val username: String, + // val email: String, + // val affiliation: String, + // val country: String + // ) + // + // data class ResponseModel( + // val userId: UUID, + // val username: String, + // val email: String, + // val affiliation: String, + // val country: String, + // val invalidEntries: List + // ) + } +} \ No newline at end of file From dd9c766431e60b6e3079c45f9e7fc2e987846ad5 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Wed, 13 Aug 2025 15:36:09 -0300 Subject: [PATCH 04/49] chore: move account/text value object to shared --- .../application/user/create/RegisterUserAccountServiceImpl.kt | 1 + .../all/application/user/update/PatchUserProfileServiceImpl.kt | 2 +- account/src/main/kotlin/br/all/domain/user/UserAccount.kt | 1 + account/src/test/kotlin/br/all/domain/user/UserAccountTest.kt | 1 + .../src/main/kotlin/br/all/domain/shared}/user/Text.kt | 2 +- .../kotlin/br/all/utils/example/RegisterUserExampleService.kt | 1 + 6 files changed, 6 insertions(+), 2 deletions(-) rename {account/src/main/kotlin/br/all/domain => shared/src/main/kotlin/br/all/domain/shared}/user/Text.kt (95%) 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..597303814 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 @@ -6,6 +6,7 @@ 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.user.Email +import br.all.domain.shared.user.Text import br.all.domain.user.* class RegisterUserAccountServiceImpl(private val repository: UserAccountRepository) : RegisterUserAccountService { 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 index bd4f5444c..22a166ed6 100644 --- a/account/src/main/kotlin/br/all/application/user/update/PatchUserProfileServiceImpl.kt +++ b/account/src/main/kotlin/br/all/application/user/update/PatchUserProfileServiceImpl.kt @@ -4,7 +4,7 @@ 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.domain.shared.user.Email -import br.all.domain.user.Text +import br.all.domain.shared.user.Text import br.all.domain.user.Username class PatchUserProfileServiceImpl( 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..ae430e929 100644 --- a/account/src/main/kotlin/br/all/domain/user/UserAccount.kt +++ b/account/src/main/kotlin/br/all/domain/user/UserAccount.kt @@ -2,6 +2,7 @@ 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.Text import java.time.LocalDateTime import java.util.UUID 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..aa34aeff0 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,7 @@ package br.all.domain.user import br.all.domain.shared.user.Email +import br.all.domain.shared.user.Text import io.github.serpro69.kfaker.Faker import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Tag 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/web/src/main/kotlin/br/all/utils/example/RegisterUserExampleService.kt b/web/src/main/kotlin/br/all/utils/example/RegisterUserExampleService.kt index 8c61d6b1c..28a97371b 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,7 @@ 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.Text import br.all.domain.user.* import org.springframework.stereotype.Service import java.util.* From 98ca9072c7f32dd3c225a91517bb3517885ac13e Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Wed, 13 Aug 2025 15:46:13 -0300 Subject: [PATCH 05/49] chore: delete review/text value object and move tests to shared module --- .../br/all/domain/shared/valueobject/Text.kt | 29 --- .../br/all/domain/shared/ddd/TextTest.kt | 175 ------------------ .../br/all/domain/shared/user/TextTest.kt | 169 +++++++++++++++++ 3 files changed, 169 insertions(+), 204 deletions(-) delete mode 100644 review/src/main/kotlin/br/all/domain/shared/valueobject/Text.kt delete mode 100644 review/src/test/kotlin/br/all/domain/shared/ddd/TextTest.kt create mode 100644 shared/src/test/kotlin/br/all/domain/shared/user/TextTest.kt 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/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/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..8ad909527 --- /dev/null +++ b/shared/src/test/kotlin/br/all/domain/shared/user/TextTest.kt @@ -0,0 +1,169 @@ +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`() { + // This text is now valid as it only contains letters and spaces. + 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`() { + // Punctuation removed to make the text valid. + 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`() { + // Punctuation removed to make the text valid. + 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() { + // Corrected test value from "." to "," + 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 From f7cbaae758fedd6425283349de0ca38874eb725d Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Wed, 13 Aug 2025 16:03:13 -0300 Subject: [PATCH 06/49] chore: remove unneeded comments --- shared/src/test/kotlin/br/all/domain/shared/user/TextTest.kt | 4 ---- 1 file changed, 4 deletions(-) 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 index 8ad909527..365045397 100644 --- a/shared/src/test/kotlin/br/all/domain/shared/user/TextTest.kt +++ b/shared/src/test/kotlin/br/all/domain/shared/user/TextTest.kt @@ -14,7 +14,6 @@ class TextTest { @Test fun `should create Text when value is valid`() { - // This text is now valid as it only contains letters and spaces. val textValue = "Mussum Ipsum cacilds vidis litro abertis" Assertions.assertDoesNotThrow { Text(textValue) @@ -23,7 +22,6 @@ class TextTest { @Test fun `should be equal when two Text objects have the same value`() { - // Punctuation removed to make the text valid. val textValue1 = "Mussum Ipsum cacilds vidis litro abertis Delegadis gente finis" val textValue2 = "Mussum Ipsum cacilds vidis litro abertis Delegadis gente finis" @@ -35,7 +33,6 @@ class TextTest { @Test fun `should not be equal when two Text objects have different values`() { - // Punctuation removed to make the text valid. val textValue1 = "Mussum Ipsum cacilds vidis litro abertis" val textValue2 = "Per aumento de cachacis eu reclamis" @@ -108,7 +105,6 @@ class TextTest { @Test @DisplayName("like , (commas)") fun shouldNotAcceptCommasAsText() { - // Corrected test value from "." to "," val textValue = "," assertThrows { Text(textValue) } } From a91eaea959ce1d7c7d9d4972a873363ff3a87fe4 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 14 Aug 2025 19:35:13 -0300 Subject: [PATCH 07/49] feat: add name value object --- .../kotlin/br/all/domain/shared/user/Name.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 shared/src/main/kotlin/br/all/domain/shared/user/Name.kt 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..fd0307d73 --- /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, and blank spaces!") + } + } + + return notification + } +} From 69ea2afd99f3a713aa3547996492e77285a89cf3 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 14 Aug 2025 19:43:12 -0300 Subject: [PATCH 08/49] refactor: allow apostrophe in name value object --- shared/src/main/kotlin/br/all/domain/shared/user/Name.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index fd0307d73..d7db5c646 100644 --- a/shared/src/main/kotlin/br/all/domain/shared/user/Name.kt +++ b/shared/src/main/kotlin/br/all/domain/shared/user/Name.kt @@ -20,7 +20,7 @@ data class Name(val value: String) : ValueObject() { notification.addError("The name must not start or end with blank spaces!") } - if (!value.matches(Regex("[\\p{L}. ]+"))) { + if (!value.matches(Regex("[\\p{L}.' ]+"))) { notification.addError("The name must contain only letters, dots, and blank spaces!") } } From cdc5d7005f961c194e9da206af16260238879f89 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 14 Aug 2025 19:44:12 -0300 Subject: [PATCH 09/49] refactor: move username value object to shared module --- .../src/main/kotlin/br/all/domain/shared}/user/Username.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename {account/src/main/kotlin/br/all/domain => shared/src/main/kotlin/br/all/domain/shared}/user/Username.kt (94%) 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 From 47a3fc217f521b6f02e8ae0ca7b0fc5d487f1b06 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 14 Aug 2025 19:48:17 -0300 Subject: [PATCH 10/49] refactor!: add name attribute to user table and modify every needed file --- .../user/create/RegisterUserAccountService.kt | 1 + .../user/create/RegisterUserAccountServiceImpl.kt | 4 ++++ .../user/find/RetrieveUserProfileService.kt | 1 + .../user/find/RetrieveUserProfileServiceImpl.kt | 1 + .../application/user/repository/UserAccountDto.kt | 1 + .../application/user/repository/UserAccountMapper.kt | 2 +- .../application/user/repository/UserProfileDto.kt | 1 + .../user/update/PatchUserProfileService.kt | 10 ++++++---- .../user/update/PatchUserProfileServiceImpl.kt | 10 +++++----- .../kotlin/br/all/domain/user/AccountCredentials.kt | 2 ++ .../main/kotlin/br/all/domain/user/UserAccount.kt | 3 +++ .../br/all/infrastructure/user/UserAccountEntity.kt | 2 ++ .../br/all/infrastructure/user/UserAccountMapper.kt | 3 ++- .../br/all/application/user/utils/TestDataFactory.kt | 6 ++++-- .../kotlin/br/all/domain/user/UserAccountTest.kt | 12 +++++++++--- .../all/utils/example/RegisterUserExampleService.kt | 12 ++++++++---- .../test/kotlin/br/all/shared/TestHelperService.kt | 2 ++ 17 files changed, 53 insertions(+), 20 deletions(-) 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..36ba6130b 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 @@ -7,6 +7,7 @@ interface RegisterUserAccountService { fun register(presenter: RegisterUserAccountPresenter, request: RequestModel) data class RequestModel( + val name: String, val username: String, val password: String, val email: 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 597303814..f18340148 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 @@ -6,7 +6,9 @@ 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.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 { @@ -21,12 +23,14 @@ 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, email = email, 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..79c161926 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 @@ -10,6 +10,7 @@ interface RetrieveUserProfileService { 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..5aeac3761 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 @@ -26,6 +26,7 @@ class RetrieveUserProfileServiceImpl( val profile = ResponseModel( userId = userProfile.id, + name = userProfile.name, username = userCredentials.username, email = userProfile.email, affiliation = userProfile.affiliation, 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/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/PatchUserProfileService.kt b/account/src/main/kotlin/br/all/application/user/update/PatchUserProfileService.kt index 17ff10a3f..9638c4a68 100644 --- a/account/src/main/kotlin/br/all/application/user/update/PatchUserProfileService.kt +++ b/account/src/main/kotlin/br/all/application/user/update/PatchUserProfileService.kt @@ -7,14 +7,16 @@ interface PatchUserProfileService { data class RequestModel( val userId: UUID, - val username: String, - val email: String, - val affiliation: String, - val country: String + val name: String?, + val username: String?, + val email: String?, + val affiliation: String?, + val country: String? ) 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/update/PatchUserProfileServiceImpl.kt b/account/src/main/kotlin/br/all/application/user/update/PatchUserProfileServiceImpl.kt index 22a166ed6..57905c917 100644 --- a/account/src/main/kotlin/br/all/application/user/update/PatchUserProfileServiceImpl.kt +++ b/account/src/main/kotlin/br/all/application/user/update/PatchUserProfileServiceImpl.kt @@ -5,7 +5,7 @@ import br.all.application.user.update.PatchUserProfileService.RequestModel import br.all.application.user.update.PatchUserProfileService.ResponseModel import br.all.domain.shared.user.Email import br.all.domain.shared.user.Text -import br.all.domain.user.Username +import br.all.domain.shared.user.Username class PatchUserProfileServiceImpl( private val repository: UserAccountRepository @@ -20,10 +20,10 @@ class PatchUserProfileServiceImpl( if (presenter.isDone()) return - val newUsername = Username(request.username) - val newEmail = Email(request.email) - val affiliation = Text(request.affiliation) - val country = Text(request.country) +// val newUsername = Username(request.username) +// val newEmail = Email(request.email) +// val affiliation = Text(request.affiliation) +// val country = Text(request.country) // TODO(): add invalid entries to the response model array // TODO(): update only the valid entries 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 ae430e929..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,12 +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/UserAccountEntity.kt b/account/src/main/kotlin/br/all/infrastructure/user/UserAccountEntity.kt index eaed0a0e7..9a35c8a5e 100644 --- a/account/src/main/kotlin/br/all/infrastructure/user/UserAccountEntity.kt +++ b/account/src/main/kotlin/br/all/infrastructure/user/UserAccountEntity.kt @@ -8,6 +8,8 @@ import java.util.* @Table(name = "USER_ACCOUNTS") class UserAccountEntity ( @Id var id: UUID, + @Column(nullable = false) + var name: String, @OneToOne( mappedBy = "userAccount", cascade = [CascadeType.ALL]) @PrimaryKeyJoinColumn var accountCredentialsEntity: AccountCredentialsEntity, diff --git a/account/src/main/kotlin/br/all/infrastructure/user/UserAccountMapper.kt b/account/src/main/kotlin/br/all/infrastructure/user/UserAccountMapper.kt index 29982ff06..bdba1744c 100644 --- a/account/src/main/kotlin/br/all/infrastructure/user/UserAccountMapper.kt +++ b/account/src/main/kotlin/br/all/infrastructure/user/UserAccountMapper.kt @@ -16,13 +16,14 @@ fun UserAccountDto.toUserAccountEntity(): UserAccountEntity { isCredentialsNonExpired, isEnabled ) - return UserAccountEntity(id, credentials, email, country, affiliation, createdAt) + return UserAccountEntity(id, name, credentials, email, country, affiliation, createdAt) } fun AccountCredentialsEntity.toAccountCredentialsDto() = AccountCredentialsDto(id, username, password, authorities, refreshToken) fun UserAccountEntity.toUserProfileDto() = UserProfileDto( id = this.id, + name = this.name, email = this.email, country = this.country, affiliation = this.affiliation 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..013005209 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 @@ -10,11 +10,12 @@ class TestDataFactory { val faker = Faker() fun registerRequest() = RegisterUserAccountService.RequestModel( - username = faker.name.firstName(), + username = faker.leagueOfLegends.champion(), 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 +32,6 @@ class TestDataFactory { email = faker.internet.email(), country = faker.address.countryCode(), affiliation = faker.leagueOfLegends.rank(), + name = faker.name.firstName() ) } \ 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 aa34aeff0..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,7 +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 @@ -54,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() @@ -72,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()), @@ -79,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/web/src/main/kotlin/br/all/utils/example/RegisterUserExampleService.kt b/web/src/main/kotlin/br/all/utils/example/RegisterUserExampleService.kt index 28a97371b..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,7 +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.* @@ -15,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( @@ -27,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/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(), From bddae34257690e1fec559667aa229f9fd592acba Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 14 Aug 2025 19:54:28 -0300 Subject: [PATCH 11/49] fix: add apostrophes word in name value object error message --- shared/src/main/kotlin/br/all/domain/shared/user/Name.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index d7db5c646..7eb2f20af 100644 --- a/shared/src/main/kotlin/br/all/domain/shared/user/Name.kt +++ b/shared/src/main/kotlin/br/all/domain/shared/user/Name.kt @@ -21,7 +21,7 @@ data class Name(val value: String) : ValueObject() { } if (!value.matches(Regex("[\\p{L}.' ]+"))) { - notification.addError("The name must contain only letters, dots, and blank spaces!") + notification.addError("The name must contain only letters, dots, apostrophes, and blank spaces!") } } From 402864fb4c30475b32e97e2e98120a0733f3aaa4 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 14 Aug 2025 19:55:52 -0300 Subject: [PATCH 12/49] test: add name and username value objects test --- .../br/all/domain/shared/user/NameTest.kt | 102 ++++++++++++++++++ .../br/all/domain/shared/user/UsernameTest.kt | 92 ++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 shared/src/test/kotlin/br/all/domain/shared/user/NameTest.kt create mode 100644 shared/src/test/kotlin/br/all/domain/shared/user/UsernameTest.kt 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/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 From 673e63aa8761338bd7b9fa7fd6d79f0f2e94a1d0 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Mon, 18 Aug 2025 11:12:57 -0300 Subject: [PATCH 13/49] fix: change "keyword" to "keywords" --- web/src/main/resources/Springer.bib | 36 ++++++++++++++--------------- 1 file changed, 18 insertions(+), 18 deletions(-) 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} } From aa50074e96b7f10a7ee0a5856c23323ce618952a Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Mon, 18 Aug 2025 11:13:24 -0300 Subject: [PATCH 14/49] refactor: allow semicolon delimiter in bibtex keywords --- .../kotlin/br/all/domain/services/BibtexConverterService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..42fe55987 100644 --- a/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt +++ b/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt @@ -123,7 +123,7 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera } private fun parseKeywords(keywords: String?): Set { - return keywords?.split(",")?.map { it.trim() }?.toSet() ?: emptySet() + return keywords?.split(",", ";")?.map { it.trim() }?.toSet() ?: emptySet() } private fun parseReferences(references: String?): List { From ec5e0c635ee19fc73c214b4ddfd5923f5da48cde Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Wed, 20 Aug 2025 13:11:39 -0300 Subject: [PATCH 15/49] refactor(doi): update regex to allow prefix --- review/src/main/kotlin/br/all/domain/model/study/Doi.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..983eb9138 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?://(dx\\.)?doi\\.org/10\\.\\d{4,}/[\\w.-]+$") if (!value.matches(regex)) notification.addError("Wrong DOI format: $value") return notification } From 7a3465ed403eac66daa2b6efdb941e3ac94f9aa7 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Wed, 20 Aug 2025 13:12:00 -0300 Subject: [PATCH 16/49] refactor(bib): allow singular keyword alongside plural keywords --- .../all/domain/services/BibtexConverterService.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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 42fe55987..29d92cfce 100644 --- a/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt +++ b/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt @@ -2,8 +2,15 @@ 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 java.util.Locale class BibtexConverterService(private val studyReviewIdGeneratorService: IdGeneratorService) { @@ -81,7 +88,7 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera val authors = getValueFromFieldMap(fieldMap, authorTypes) val venue = getValueFromFieldMap(fieldMap, venueTypes) val abstract = fieldMap["abstract"] ?: " " - val keywords = parseKeywords(fieldMap["keywords"]) + val keywords = parseKeywords(fieldMap["keywords"] ?: fieldMap["keyword"]) val references = parseReferences(fieldMap["references"]) val doi = fieldMap["doi"]?.let { val cleanDoi = it.replace(Regex("}"), "") From 35350e99eb758210f99abdc22098490477ef0e6c Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Wed, 20 Aug 2025 13:53:09 -0300 Subject: [PATCH 17/49] fix(bib): properly formating doi --- .../br/all/domain/services/BibtexConverterService.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 29d92cfce..02b3e7b78 100644 --- a/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt +++ b/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt @@ -91,10 +91,14 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera 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") + val cleanDoi = it.replace(Regex("[{}]"), "").trim() + val fullUrl = if (cleanDoi.startsWith("http")) { + cleanDoi + } else { + "https://doi.org/$cleanDoi" + } + Doi(fullUrl) } - val type = extractStudyType(bibtexEntry) return Study(type, title, year, authors, venue, abstract, keywords, references, doi) From 3f01525614e95e2f2e8782574ea003ecec4bc845 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Wed, 20 Aug 2025 14:26:47 -0300 Subject: [PATCH 18/49] refactor(doi): add more valid doi url in regex --- review/src/main/kotlin/br/all/domain/model/study/Doi.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 983eb9138..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?://(dx\\.)?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 } From ffa5aa5e37ec2fe3901bbfe2dd3733d888cbc397 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Wed, 20 Aug 2025 14:47:22 -0300 Subject: [PATCH 19/49] test: change data factory to use first name for username --- .../kotlin/br/all/application/user/utils/TestDataFactory.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 013005209..4315ef03b 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 @@ -10,7 +10,7 @@ class TestDataFactory { val faker = Faker() fun registerRequest() = RegisterUserAccountService.RequestModel( - username = faker.leagueOfLegends.champion(), + username = faker.name.firstName(), password = faker.pearlJam.songs(), email = faker.internet.email(), country = faker.address.countryCode(), From 269308bff416faf447c5501f331bf43c223fa9a8 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Sat, 23 Aug 2025 12:56:01 -0300 Subject: [PATCH 20/49] fix: add missing name attribute to restful user profile presenter --- .../all/user/presenter/RestfulRetrieveUserProfilePresenter.kt | 2 ++ 1 file changed, 2 insertions(+) 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, From 55fc08ba04d71af098bb81ad809197d9fafd0cdb Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Mon, 25 Aug 2025 11:48:36 -0300 Subject: [PATCH 21/49] test: add missing name parameter at retrieve user profile tests --- .../application/user/find/RetrieveUserProfileServiceImplTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/account/src/test/kotlin/br/all/application/user/find/RetrieveUserProfileServiceImplTest.kt b/account/src/test/kotlin/br/all/application/user/find/RetrieveUserProfileServiceImplTest.kt index 3b3ebc4ba..202e3b444 100644 --- a/account/src/test/kotlin/br/all/application/user/find/RetrieveUserProfileServiceImplTest.kt +++ b/account/src/test/kotlin/br/all/application/user/find/RetrieveUserProfileServiceImplTest.kt @@ -56,6 +56,7 @@ class RetrieveUserProfileServiceImplTest { val capturedResponse = responseSlot.captured assertEquals(userProfile.id, capturedResponse.userId) + assertEquals(userProfile.name, capturedResponse.name) assertEquals(userCredentials.username, capturedResponse.username) assertEquals(userProfile.email, capturedResponse.email) assertEquals(userProfile.affiliation, capturedResponse.affiliation) From cc5785be094efec117ef4e6b1fdd4f2b143e7227 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Mon, 25 Aug 2025 12:32:39 -0300 Subject: [PATCH 22/49] feat: add pick many question class --- .../br/all/domain/model/question/PickMany.kt | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 review/src/main/kotlin/br/all/domain/model/question/PickMany.kt 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 From c74bdc47fc2ab24b0021b42863c50408cb80f0f7 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Mon, 25 Aug 2025 12:32:58 -0300 Subject: [PATCH 23/49] feat: add pick many building option to question builder --- .../main/kotlin/br/all/domain/model/question/QuestionBuilder.kt | 2 ++ 1 file changed, 2 insertions(+) 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) } From 662017c21276f624417ed8f5964aa7d4309151b0 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Mon, 25 Aug 2025 12:33:16 -0300 Subject: [PATCH 24/49] feat: update question mapper to allow pick many questions --- .../br/all/application/question/repository/QuestionMapper.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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}") } } - From 122819dba46fc9b5f1f39fd3adf860991c0e0340 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Mon, 25 Aug 2025 12:33:36 -0300 Subject: [PATCH 25/49] feat: add pick many questions to question service --- .../all/application/question/create/CreateQuestionService.kt | 2 +- .../application/question/create/CreateQuestionServiceImpl.kt | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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..dae9bf1d8 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 } @@ -57,6 +57,7 @@ class CreateQuestionServiceImpl( 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!!) } From 268abf930e24d2629d5334d3115cdb296f584aff Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Mon, 25 Aug 2025 12:33:56 -0300 Subject: [PATCH 26/49] test: add pick many dto options to question test data factory --- .../question/util/TestDataFactory.kt | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) 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() From 0079fbd00837e5bd6c8874570547e03fb61e1d5d Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Mon, 25 Aug 2025 12:34:57 -0300 Subject: [PATCH 27/49] test: add pick many question option to create find and update tests --- .../update/services/UpdateQuestionServiceImpl.kt | 1 + .../create/CreateQuestionServiceImplTest.kt | 16 ++++++++++++++++ .../question/find/FindQuestionServiceImplTest.kt | 1 + .../services/UpdateQuestionServiceImplTest.kt | 2 ++ 4 files changed, 20 insertions(+) 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..a072ae6ea 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 @@ -50,6 +50,7 @@ class UpdateQuestionServiceImpl( 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!!) } 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..5707554ad 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() } @@ -119,6 +120,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..a358abb25 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 @@ -73,6 +73,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 +96,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") From d1bb4ea2e86c564ebb68e34b24e46a58988959a4 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Mon, 25 Aug 2025 13:06:25 -0300 Subject: [PATCH 28/49] feat: add pick many question option to link factory --- .../main/kotlin/br/all/utils/LinksFactory.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/web/src/main/kotlin/br/all/utils/LinksFactory.kt b/web/src/main/kotlin/br/all/utils/LinksFactory.kt index e2ed2eb30..dd2151c24 100644 --- a/web/src/main/kotlin/br/all/utils/LinksFactory.kt +++ b/web/src/main/kotlin/br/all/utils/LinksFactory.kt @@ -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, From 4765ca8f1a3a8feda7e9d9e9969b79df8365fdc3 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Mon, 25 Aug 2025 13:06:53 -0300 Subject: [PATCH 29/49] feat: update all question restful presenter to allow pick many --- .../extraction/RestfulCreateExtractionQuestionPresenter.kt | 3 ++- .../extraction/RestfulFindAllExtractionQuestionPresenter.kt | 3 ++- .../presenter/riskOfBias/RestfulCreateRoBQuestionPresenter.kt | 3 ++- .../presenter/riskOfBias/RestfulFindAllRoBQuestionPresenter.kt | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) 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/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) } From 0c56e30f31843ca8c4b009e6683a8e91522a64ed Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Mon, 25 Aug 2025 13:07:13 -0300 Subject: [PATCH 30/49] feat: add create pick many question endpoints for extraction and rob --- .../ExtractionQuestionController.kt | 37 ++++++++++++++++++ .../RiskOfBiasQuestionController.kt | 38 +++++++++++++++++++ 2 files changed, 75 insertions(+) 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..02dae404a 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-list question in the protocol") + @ApiResponses( + value = [ + ApiResponse(responseCode = "201", description = "Success creating a pick-list question in the protocol", + content = [Content(schema = Schema(hidden = true))]), + ApiResponse( + responseCode = "400", + description = "Fail creating a pick-list question in the protocol - invalid input", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "401", + description = "Fail creating a pick-list question in the protocol - unauthenticated user", + content = [Content(schema = Schema(hidden = true))] + ),ApiResponse( + responseCode = "403", + description = "Fail creating a pick-list 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_LIST, + 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..7e22211c3 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-list question in the protocol") + @ApiResponses( + value = [ + ApiResponse(responseCode = "201", description = "Success creating a pick-list question in the protocol", + content = [Content(schema = Schema(hidden = true))]), + ApiResponse( + responseCode = "400", + description = "Fail creating a pick-list question in the protocol - invalid input", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "401", + description = "Fail creating a pick-list question in the protocol - unauthenticated user", + content = [Content(schema = Schema(hidden = true))] + ),ApiResponse( + responseCode = "403", + description = "Fail creating a pick-list 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_LIST, + request.code, + request.description, + options = request.options + ) + ) + @PostMapping("/labeled-scale") @Operation(summary = "Create a risk of bias labeled-scale question in the protocol") @ApiResponses( From 6a51f83a84b76dc2e8f0dea526e6a28f08533ff5 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Mon, 25 Aug 2025 13:36:45 -0300 Subject: [PATCH 31/49] fix: fix pick many endpoint returning the wrong requestion type in json --- .../controller/ExtractionQuestionController.kt | 12 ++++++------ .../controller/RiskOfBiasQuestionController.kt | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) 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 02dae404a..69ee16bbb 100644 --- a/web/src/main/kotlin/br/all/question/controller/ExtractionQuestionController.kt +++ b/web/src/main/kotlin/br/all/question/controller/ExtractionQuestionController.kt @@ -121,23 +121,23 @@ class ExtractionQuestionController( ) @PostMapping("/pick-many") - @Operation(summary = "Create a extraction pick-list question in the protocol") + @Operation(summary = "Create a extraction pick-many question in the protocol") @ApiResponses( value = [ - ApiResponse(responseCode = "201", description = "Success creating a pick-list question in the protocol", + 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-list question in the protocol - invalid input", + 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-list question in the protocol - unauthenticated user", + 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-list question in the protocol - unauthorized user", + description = "Fail creating a pick-many question in the protocol - unauthorized user", content = [Content(schema = Schema(hidden = true))] ), ] @@ -149,7 +149,7 @@ class ExtractionQuestionController( authenticationInfoService.getAuthenticatedUserId(), systematicStudyId, questionContext, - PICK_LIST, + PICK_MANY, request.code, request.description, options = request.options 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 7e22211c3..41499029c 100644 --- a/web/src/main/kotlin/br/all/question/controller/RiskOfBiasQuestionController.kt +++ b/web/src/main/kotlin/br/all/question/controller/RiskOfBiasQuestionController.kt @@ -121,23 +121,23 @@ class RiskOfBiasQuestionController( ) @PostMapping("/pick-many") - @Operation(summary = "Create a risk of bias pick-list question in the protocol") + @Operation(summary = "Create a risk of bias pick-many question in the protocol") @ApiResponses( value = [ - ApiResponse(responseCode = "201", description = "Success creating a pick-list question in the protocol", + 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-list question in the protocol - invalid input", + 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-list question in the protocol - unauthenticated user", + 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-list question in the protocol - unauthorized user", + description = "Fail creating a pick-many question in the protocol - unauthorized user", content = [Content(schema = Schema(hidden = true))] ), @@ -150,7 +150,7 @@ class RiskOfBiasQuestionController( authenticationInfoService.getAuthenticatedUserId(), systematicStudyId, questionContext, - PICK_LIST, + PICK_MANY, request.code, request.description, options = request.options From 2514b98279a0be2b1bc3ac5545922bd20d9bc5e9 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Mon, 25 Aug 2025 13:54:15 -0300 Subject: [PATCH 32/49] fix: fix code and description get question response model field being returned as the same value --- .../question/QuestionDbMapper.kt | 40 +++++++++---------- .../RestfulFindExtractionQuestionPresenter.kt | 2 +- .../RestfulFindRoBQuestionPresenter.kt | 2 +- 3 files changed, 22 insertions(+), 22 deletions(-) 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/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/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 From 9fde1909cbdc4960654439d4e750a29a95477368 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Mon, 25 Aug 2025 23:06:40 -0300 Subject: [PATCH 33/49] chore: mock two pick many questions into web application --- .../CreateSystematicReviewExampleService.kt | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) 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..0fa98c348 100644 --- a/web/src/main/kotlin/br/all/utils/example/CreateSystematicReviewExampleService.kt +++ b/web/src/main/kotlin/br/all/utils/example/CreateSystematicReviewExampleService.kt @@ -85,6 +85,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 +149,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 +206,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.", From 78d391d60c86aabee81707c0ef37d062e8cd3ca7 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 26 Aug 2025 11:15:31 -0300 Subject: [PATCH 34/49] feat: implement answering pick many question --- .../implementation/BatchAnswerQuestionServiceImpl.kt | 7 +++++++ 1 file changed, 7 insertions(+) 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) { From 5e530bf56f864a37b830f33a6a4bba8598edaa40 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 26 Aug 2025 12:04:44 -0300 Subject: [PATCH 35/49] chore: add pick many to dictionary --- .idea/dictionaries/project.xml | 1 + 1 file changed, 1 insertion(+) 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 From 28f80383f2b643baf7b40b769eeb1a4576c7a371 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 26 Aug 2025 12:05:04 -0300 Subject: [PATCH 36/49] refactor: add pick many options guard clause --- .../question/update/services/UpdateQuestionServiceImpl.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 a072ae6ea..98ff39202 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 } From cd121107ef6f22f7fbb8404626a56566acf12782 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 26 Aug 2025 12:10:34 -0300 Subject: [PATCH 37/49] refactor: add error catching when building a question --- .../create/CreateQuestionServiceImpl.kt | 20 +++++++++++-------- .../services/UpdateQuestionServiceImpl.kt | 19 +++++++++++------- 2 files changed, 24 insertions(+), 15 deletions(-) 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 dae9bf1d8..8dd760dfc 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 @@ -54,15 +54,19 @@ 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!!) - 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)) + 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) + } presenter.prepareSuccessView(ResponseModel(request.userId, systematicStudyId, generatedId)) } 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 98ff39202..8108ca442 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 @@ -49,15 +49,20 @@ 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.PICK_MANY -> builder.buildPickMany(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) } - questionRepository.createOrUpdate(question.toDto(type, request.questionContext)) presenter.prepareSuccessView(ResponseModel(userId, systematicStudyId, questionId.value)) } } From 348c2920e64ec34e8cc978e25fc69fa37ab1399a Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 26 Aug 2025 12:11:18 -0300 Subject: [PATCH 38/49] test: add new invalid entries tests for update question service --- .../services/UpdateQuestionServiceImplTest.kt | 111 +++++++++++++++++- 1 file changed, 107 insertions(+), 4 deletions(-) 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 a358abb25..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") @@ -127,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() @@ -173,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 From c62ea3d0fb6ffebb4a035e067caf575d86e2e3a4 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 26 Aug 2025 12:54:43 -0300 Subject: [PATCH 39/49] test: add new invalid entries tests for create question service --- .../create/CreateQuestionServiceImplTest.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) 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 5707554ad..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 @@ -105,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()) From bf780b9215bb7b9d19ee395d45b307c29b974ecb Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 26 Aug 2025 12:56:56 -0300 Subject: [PATCH 40/49] test: add pick many value object tests --- .../all/domain/model/question/PickManyTest.kt | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 review/src/test/kotlin/br/all/domain/model/question/PickManyTest.kt 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 From 5ef4f02a64b5a0494e853217c3fa059ec72bf57a Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 26 Aug 2025 13:28:15 -0300 Subject: [PATCH 41/49] fix: add missing return statement --- .../all/application/question/create/CreateQuestionServiceImpl.kt | 1 + .../question/update/services/UpdateQuestionServiceImpl.kt | 1 + 2 files changed, 2 insertions(+) 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 8dd760dfc..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 @@ -66,6 +66,7 @@ class CreateQuestionServiceImpl( 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/update/services/UpdateQuestionServiceImpl.kt b/review/src/main/kotlin/br/all/application/question/update/services/UpdateQuestionServiceImpl.kt index 8108ca442..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 @@ -61,6 +61,7 @@ class UpdateQuestionServiceImpl( questionRepository.createOrUpdate(question.toDto(type, request.questionContext)) } catch (e: IllegalArgumentException) { presenter.prepareFailView(e) + return } presenter.prepareSuccessView(ResponseModel(userId, systematicStudyId, questionId.value)) From 66448c1b600ef7d358c324a16b8f79de567153f2 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 26 Aug 2025 13:28:29 -0300 Subject: [PATCH 42/49] test: add test data for pick many controller tests --- .../br/all/question/utils/TestDataFactory.kt | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) 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, From 3eef601f9d43fead87b7e40793c59668a46837bf Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 26 Aug 2025 13:28:39 -0300 Subject: [PATCH 43/49] test: add new pick many controller tests --- .../ExtractionQuestionControllerTest.kt | 40 +++++++++++++++++++ .../RiskOfBiasQuestionControllerTest.kt | 40 +++++++++++++++++++ 2 files changed, 80 insertions(+) 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() From 3079503cdc60366dcf364e1f6f24f0f73fb368dc Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 26 Aug 2025 13:38:14 -0300 Subject: [PATCH 44/49] test: add test data for answering pick many questions --- .../application/study/util/TestDataFactory.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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, From 03aa108ccbbaef075ae2a2f9bf2a5450ce141512 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 26 Aug 2025 13:38:26 -0300 Subject: [PATCH 45/49] test: add more tests for answer service --- .../BatchAnswerQuestionServiceImplTest.kt | 171 +++++++++++++++++- 1 file changed, 169 insertions(+), 2 deletions(-) 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 -> From 13d846fd18cf316a44ccda9e68b407c515947034 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 26 Aug 2025 16:19:06 -0300 Subject: [PATCH 46/49] chore: add custom spring startup banner --- web/src/main/resources/banner.txt | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 web/src/main/resources/banner.txt 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 @@ + █████████ █████ █████████ █████ █████████ ███████████ █████ + ███░░░░░███ ░░███ ███░░░░░███ ░░███ ███░░░░░███ ░░███░░░░░███░░███ +░███ ░░░ ███████ ░███ ░███ ████████ ███████ ░███ ░███ ░███ ░███ ░███ +░░█████████ ░░░███░ ░███████████ ░░███░░███░░░███░ ░███████████ ░██████████ ░███ + ░░░░░░░░███ ░███ ░███░░░░░███ ░███ ░░░ ░███ ░███░░░░░███ ░███░░░░░░ ░███ + ███ ░███ ░███ ███ ░███ ░███ ░███ ░███ ███ ░███ ░███ ░███ ░███ +░░█████████ ░░█████ █████ █████ █████ ░░█████ █████ █████ █████ █████ + ░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ + + From 8c423c25af72da64a387edba0296625a56050b2e Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Fri, 29 Aug 2025 15:47:17 -0300 Subject: [PATCH 47/49] refactor: adapt protocol stage service to new modularization --- .../protocol/find/FindProtocolStageService.kt | 14 +- .../find/FindProtocolStageServiceImpl.kt | 123 ++++++++++-------- 2 files changed, 79 insertions(+), 58 deletions(-) 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..f70a3476f 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 @@ -33,34 +34,32 @@ class FindProtocolStageServiceImpl( 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 +67,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, protocolDto) -> 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 +85,62 @@ 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, protocolDto: ProtocolDto): Boolean { + val isStudyInfoComplete = studyDto.title.isNotBlank() && studyDto.description.isNotBlank() + val isProtocolGoalComplete = !protocolDto.goal.isNullOrBlank() + return isStudyInfoComplete && isProtocolGoalComplete } - 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 From 3a3d2aae05aabc7508e0aa999c98d80efedfb60c Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Fri, 29 Aug 2025 15:47:39 -0300 Subject: [PATCH 48/49] test: adapt protocol stage tests to new modularization --- .../find/FindProtocolStageServiceImplTest.kt | 290 +++++++++--------- 1 file changed, 139 insertions(+), 151 deletions(-) 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..7b2fb2808 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,118 @@ 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 RESEARCH_QUESTIONS stage (T2) when T1 is complete but no research questions exist`() { + val protocolDto = createFullProtocolDto(researchQuestions = emptySet()) - sut.getStage(presenter, request) + every { protocolRepository.findById(systematicStudyId) } returns protocolDto + + sut.getStage(presenter, RequestModel(researcherId, systematicStudyId)) - val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.PROTOCOL_PART_I) + val expectedResponse = ResponseModel(researcherId, systematicStudyId, ProtocolStage.RESEARCH_QUESTIONS) 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 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 - every { studyReviewRepository.findAllFromReview(systematicStudyId) } returns emptyList() - every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.ROB) } returns emptyList() - every { questionRepository.findAllBySystematicStudyId(systematicStudyId, QuestionContextEnum.EXTRACTION) } returns emptyList() - - val request = RequestModel(researcherId, systematicStudyId) - sut.getStage(presenter, request) + 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, request) + sut.getStage(presenter, RequestModel(researcherId, systematicStudyId)) - 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 +203,108 @@ 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" + ) = SystematicStudyDto( + id = id, + title = title, + description = description, + owner = UUID.randomUUID(), + collaborators = emptySet() + ) - 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 From 03340a531d650855254c38f98428101e38338c00 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Fri, 29 Aug 2025 15:50:34 -0300 Subject: [PATCH 49/49] fix: fix service crash when parsing dto with a blank title --- .../protocol/find/FindProtocolStageServiceImpl.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 f70a3476f..7eaaf15cd 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 @@ -28,7 +28,10 @@ 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