From e043f9217268d156fd75e11a9b54dd1ec1037aa5 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Sat, 9 Aug 2025 13:41:39 -0300 Subject: [PATCH 001/120] 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 002/120] 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 003/120] 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 004/120] 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 005/120] 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 006/120] 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 007/120] 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 008/120] 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 009/120] 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 010/120] 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 011/120] 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 012/120] 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 013/120] 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 014/120] 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 015/120] 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 016/120] 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 017/120] 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 018/120] 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 019/120] 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 020/120] 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 021/120] 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 022/120] 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 023/120] 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 024/120] 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 025/120] 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 026/120] 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 027/120] 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 028/120] 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 029/120] 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 030/120] 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 031/120] 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 032/120] 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 033/120] 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 034/120] 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 035/120] 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 036/120] 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 037/120] 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 038/120] 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 039/120] 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 040/120] 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 041/120] 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 042/120] 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 043/120] 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 044/120] 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 045/120] 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 046/120] 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 047/120] 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 048/120] 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 049/120] 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 From 7f0aed409d0170842e77f0860244dad9f3b43141 Mon Sep 17 00:00:00 2001 From: ericksgmes Date: Tue, 2 Sep 2025 13:13:31 -0300 Subject: [PATCH 050/120] feat: add objectives field to SystematicStudy with validation --- .../br/all/domain/model/review/SystematicStudy.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/review/src/main/kotlin/br/all/domain/model/review/SystematicStudy.kt b/review/src/main/kotlin/br/all/domain/model/review/SystematicStudy.kt index 69ce0e5c7..654c4df30 100644 --- a/review/src/main/kotlin/br/all/domain/model/review/SystematicStudy.kt +++ b/review/src/main/kotlin/br/all/domain/model/review/SystematicStudy.kt @@ -12,17 +12,26 @@ class SystematicStudy( description: String, owner: ResearcherId, collaborators: Set = emptySet(), + objectives: String, ) : Entity(id) { private val _collaborators = collaborators.toMutableSet() val collaborators get() = _collaborators.toSet() var owner = owner private set + + var objectives: String = objectives + set(value) { + require(value.isNotBlank()) { "Systematic study objective must not be blank." } + field = value + } + var title: String = title set(value) { require(value.isNotBlank()) { "Systematic study title must not be blank." } field = value } + var description = description set(value) { require(value.isNotBlank()) { "Systematic study description must not be blank." } @@ -40,6 +49,8 @@ class SystematicStudy( it.addError("Systematic Study title must not be blank!") if (description.isBlank()) it.addError("Systematic Study description must not be blank!") + if (objectives.isBlank()) + it.addError("Systematic Study objective must not be blank!") } fun addCollaborator(researcherId: ResearcherId) = _collaborators.add(researcherId) @@ -58,7 +69,7 @@ class SystematicStudy( } override fun toString() = "SystematicStudy(reviewId=$id, title='$title', description='$description', owner=$owner," + - " researchers=$_collaborators)" + " researchers=$_collaborators,"+" objectives=$objectives)" companion object } From 268271ed19ad8b927f92060aedea3e8c12b78563 Mon Sep 17 00:00:00 2001 From: ericksgmes Date: Tue, 2 Sep 2025 13:16:48 -0300 Subject: [PATCH 051/120] feat: add objectives field to CreateSystematicStudyService request model --- .../application/review/create/CreateSystematicStudyService.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/review/src/main/kotlin/br/all/application/review/create/CreateSystematicStudyService.kt b/review/src/main/kotlin/br/all/application/review/create/CreateSystematicStudyService.kt index 1b3ec80e4..6cdea53a5 100644 --- a/review/src/main/kotlin/br/all/application/review/create/CreateSystematicStudyService.kt +++ b/review/src/main/kotlin/br/all/application/review/create/CreateSystematicStudyService.kt @@ -10,6 +10,7 @@ interface CreateSystematicStudyService { val title : String, val description : String, val collaborators : Set, + val objectives: String, ) data class ResponseModel( From 1de42e77cce3eae28d4027b34abb5f02264b97da Mon Sep 17 00:00:00 2001 From: ericksgmes Date: Tue, 2 Sep 2025 13:16:53 -0300 Subject: [PATCH 052/120] feat: add objectives field to SystematicStudyDto --- .../br/all/application/review/repository/SystematicStudyDto.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/review/src/main/kotlin/br/all/application/review/repository/SystematicStudyDto.kt b/review/src/main/kotlin/br/all/application/review/repository/SystematicStudyDto.kt index 07a9a121a..6a1cb4033 100644 --- a/review/src/main/kotlin/br/all/application/review/repository/SystematicStudyDto.kt +++ b/review/src/main/kotlin/br/all/application/review/repository/SystematicStudyDto.kt @@ -8,4 +8,5 @@ data class SystematicStudyDto( val description : String, val owner : UUID, val collaborators : Set, + val objectives: String, ) From 56e67ed909e7624daf75a515e06378c0834cb6e0 Mon Sep 17 00:00:00 2001 From: ericksgmes Date: Tue, 2 Sep 2025 13:16:57 -0300 Subject: [PATCH 053/120] feat: map objectives field in SystematicStudyMapper --- .../all/application/review/repository/SystematicStudyMapper.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/review/src/main/kotlin/br/all/application/review/repository/SystematicStudyMapper.kt b/review/src/main/kotlin/br/all/application/review/repository/SystematicStudyMapper.kt index 025cda28a..d739ede39 100644 --- a/review/src/main/kotlin/br/all/application/review/repository/SystematicStudyMapper.kt +++ b/review/src/main/kotlin/br/all/application/review/repository/SystematicStudyMapper.kt @@ -12,6 +12,7 @@ fun SystematicStudy.toDto() = SystematicStudyDto( description, owner.value, collaborators.map { it.value }.toSet(), + objectives, ) fun SystematicStudy.Companion.fromRequestModel(id: UUID, requestModel: RequestModel) = SystematicStudy( @@ -19,6 +20,7 @@ fun SystematicStudy.Companion.fromRequestModel(id: UUID, requestModel: RequestMo requestModel.title, requestModel.description, ResearcherId(requestModel.userId), + objectives = requestModel.objectives, ) fun SystematicStudy.Companion.fromDto(dto: SystematicStudyDto) = SystematicStudy( @@ -29,4 +31,5 @@ fun SystematicStudy.Companion.fromDto(dto: SystematicStudyDto) = SystematicStudy dto.collaborators .map { ResearcherId(it) } .toMutableSet(), + dto.objectives, ) From a31da0bdd24a54b8cbf45158888b123a599e1583 Mon Sep 17 00:00:00 2001 From: ericksgmes Date: Tue, 2 Sep 2025 13:47:01 -0300 Subject: [PATCH 054/120] feat: include objectives field in CreateSystematicReviewExampleService --- .../all/utils/example/CreateSystematicReviewExampleService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 0fa98c348..31b37c783 100644 --- a/web/src/main/kotlin/br/all/utils/example/CreateSystematicReviewExampleService.kt +++ b/web/src/main/kotlin/br/all/utils/example/CreateSystematicReviewExampleService.kt @@ -32,7 +32,8 @@ class CreateSystematicReviewExampleService( technologies, methodologies, and software engineering guidelines in the field. """.trimIndent(), owner = ownerId, - collaborators = collaboratorIds + collaborators = collaboratorIds, + objectives = "These are the main objectives of this systematic review." ) ) From 07b2e49d4ca0c953e47aa2ec9d6a2125bdd800d3 Mon Sep 17 00:00:00 2001 From: ericksgmes Date: Tue, 2 Sep 2025 13:47:07 -0300 Subject: [PATCH 055/120] feat: add objectives field to createReview link in LinksFactory --- web/src/main/kotlin/br/all/utils/LinksFactory.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/main/kotlin/br/all/utils/LinksFactory.kt b/web/src/main/kotlin/br/all/utils/LinksFactory.kt index dd2151c24..a7f7da6ff 100644 --- a/web/src/main/kotlin/br/all/utils/LinksFactory.kt +++ b/web/src/main/kotlin/br/all/utils/LinksFactory.kt @@ -33,7 +33,7 @@ class LinksFactory { }.withRel("update-protocol").withType("PUT") fun createReview(): Link = linkTo { - postSystematicStudy(PostRequest("title", "description", setOf())) + postSystematicStudy(PostRequest("title", "description", setOf(), "objectives")) }.withRel("create-review").withType("POST") fun findReview(systematicStudyId: UUID): Link = linkTo { From 59ee46479c96f99d24cd0b8be8a2915e471483ee Mon Sep 17 00:00:00 2001 From: ericksgmes Date: Tue, 2 Sep 2025 13:47:11 -0300 Subject: [PATCH 056/120] feat: add objectives field to PostRequest and map to RequestModel --- web/src/main/kotlin/br/all/review/requests/PostRequest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/main/kotlin/br/all/review/requests/PostRequest.kt b/web/src/main/kotlin/br/all/review/requests/PostRequest.kt index b617549f6..1c74dd663 100644 --- a/web/src/main/kotlin/br/all/review/requests/PostRequest.kt +++ b/web/src/main/kotlin/br/all/review/requests/PostRequest.kt @@ -7,7 +7,8 @@ data class PostRequest( val title: String, val description: String, val collaborators: Set, + val objectives: String, ) { fun toCreateRequestModel(userId: UUID) = - RequestModel(userId, title, description, collaborators) + RequestModel(userId, title, description, collaborators, objectives) } \ No newline at end of file From 7be29aff7540da6ee62750407975581374bc8fcb Mon Sep 17 00:00:00 2001 From: ericksgmes Date: Tue, 2 Sep 2025 13:47:16 -0300 Subject: [PATCH 057/120] feat: map objectives field in SystematicStudyDbMapper --- .../br/all/infrastructure/review/SystematicStudyDbMapper.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/review/src/main/kotlin/br/all/infrastructure/review/SystematicStudyDbMapper.kt b/review/src/main/kotlin/br/all/infrastructure/review/SystematicStudyDbMapper.kt index 36368ba4d..6ea613b6c 100644 --- a/review/src/main/kotlin/br/all/infrastructure/review/SystematicStudyDbMapper.kt +++ b/review/src/main/kotlin/br/all/infrastructure/review/SystematicStudyDbMapper.kt @@ -8,6 +8,7 @@ fun SystematicStudyDto.toDocument() = SystematicStudyDocument( description, owner, collaborators, + objectives ) fun SystematicStudyDocument.toDto() = SystematicStudyDto( @@ -16,4 +17,5 @@ fun SystematicStudyDocument.toDto() = SystematicStudyDto( description, owner, collaborators, + objectives ) From 50e9b045eab68811b9468d6a8a08d470b3d10ef7 Mon Sep 17 00:00:00 2001 From: ericksgmes Date: Tue, 2 Sep 2025 13:47:20 -0300 Subject: [PATCH 058/120] feat: add objectives field to SystematicStudyDocument --- .../br/all/infrastructure/review/SystematicStudyDocument.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/review/src/main/kotlin/br/all/infrastructure/review/SystematicStudyDocument.kt b/review/src/main/kotlin/br/all/infrastructure/review/SystematicStudyDocument.kt index 6e3e584c3..62414582c 100644 --- a/review/src/main/kotlin/br/all/infrastructure/review/SystematicStudyDocument.kt +++ b/review/src/main/kotlin/br/all/infrastructure/review/SystematicStudyDocument.kt @@ -11,4 +11,5 @@ data class SystematicStudyDocument( val description: String, val owner: UUID, val collaborators: Set = emptySet(), + val objectives: String, ) From 14772a5f8af0761d9c00aaad9299eb1bbe9620f9 Mon Sep 17 00:00:00 2001 From: ericksgmes Date: Wed, 3 Sep 2025 12:03:47 -0300 Subject: [PATCH 059/120] test: include objectives field in test data for FindProtocolStageServiceImplTest --- .../protocol/find/FindProtocolStageServiceImplTest.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 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 7b2fb2808..1f2ee41c0 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 @@ -249,13 +249,15 @@ class FindProtocolStageServiceImplTest { private fun createFullSystematicStudyDto( id: UUID = systematicStudyId, title: String = "A complete systematic study", - description: String = "A complete description" + description: String = "A complete description", + objectives: String = "A complete objective", ) = SystematicStudyDto( id = id, title = title, description = description, owner = UUID.randomUUID(), - collaborators = emptySet() + collaborators = emptySet(), + objectives = objectives, ) private fun createFullProtocolDto( From dc9592e5bc0da3799343d2e2dd4cbb64a3eb62cb Mon Sep 17 00:00:00 2001 From: ericksgmes Date: Wed, 3 Sep 2025 12:03:52 -0300 Subject: [PATCH 060/120] test: include objectives field in test data for FindProtocolStageServiceImplTest --- .../br/all/application/util/PreconditionCheckerMockingNew.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/review/src/test/kotlin/br/all/application/util/PreconditionCheckerMockingNew.kt b/review/src/test/kotlin/br/all/application/util/PreconditionCheckerMockingNew.kt index 350e3865d..62fa50e8c 100644 --- a/review/src/test/kotlin/br/all/application/util/PreconditionCheckerMockingNew.kt +++ b/review/src/test/kotlin/br/all/application/util/PreconditionCheckerMockingNew.kt @@ -122,12 +122,14 @@ class PreconditionCheckerMockingNew( description: String = faker.lorem.words(), ownerId: UUID = userId, collaborators: Set = emptySet(), + objectives: String = faker.beer.brand() ) = SystematicStudyDto( id, title, description, ownerId, mutableSetOf(ownerId).also { it.addAll(collaborators) }, + objectives ) private fun generateUserDto( From 70d67ef9be7874b50282914d469884f268429a71 Mon Sep 17 00:00:00 2001 From: ericksgmes Date: Wed, 3 Sep 2025 12:04:04 -0300 Subject: [PATCH 061/120] test: add objectives field to test cases in SystematicStudyTest --- .../all/domain/model/review/SystematicStudyTest.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/review/src/test/kotlin/br/all/domain/model/review/SystematicStudyTest.kt b/review/src/test/kotlin/br/all/domain/model/review/SystematicStudyTest.kt index 664865e91..98171b514 100644 --- a/review/src/test/kotlin/br/all/domain/model/review/SystematicStudyTest.kt +++ b/review/src/test/kotlin/br/all/domain/model/review/SystematicStudyTest.kt @@ -17,7 +17,7 @@ class SystematicStudyTest { fun setUp() { val systematicStudyId = SystematicStudyId(UUID.randomUUID()) val owner = ResearcherId(UUID.randomUUID()) - sut = SystematicStudy(systematicStudyId, "Some title", "Some description", owner) + sut = SystematicStudy(systematicStudyId, "Some title", "Some description", owner, objectives = "Some objective") } @Nested @@ -33,14 +33,14 @@ class SystematicStudyTest { val owner = ResearcherId(UUID.randomUUID()) val collaborators = mutableSetOf(ResearcherId(UUID.randomUUID())) - assertDoesNotThrow { SystematicStudy(id, "Title", "Description", owner, collaborators) } + assertDoesNotThrow { SystematicStudy(id, "Title", "Description", owner, collaborators, "Objectives") } } @Test fun `should owner be a collaborator`() { val id = SystematicStudyId(UUID.randomUUID()) val ownerId = ResearcherId(UUID.randomUUID()) - val sut = SystematicStudy(id, "Title", "Description", ownerId, mutableSetOf()) + val sut = SystematicStudy(id, "Title", "Description", ownerId, mutableSetOf(), "Objectives") assertTrue(ownerId in sut.collaborators) } @@ -51,12 +51,12 @@ class SystematicStudyTest { @DisplayName("And providing invalid states") inner class AndProvidingInvalidStates { @ParameterizedTest(name = "[{index}]: title=\"{0}\", description=\"{1}\"") - @CsvSource("'',Some description", "Some title,''", "'',''") - fun `should not create systematic study without title or description`(title: String, description: String){ + @CsvSource("'',Some description", "Some title,''", "'','', Some objective") + fun `should not create systematic study without title or description`(title: String, description: String, objectives: String){ val id = SystematicStudyId(UUID.randomUUID()) val owner = ResearcherId(UUID.randomUUID()) - assertThrows { SystematicStudy(id, title, description, owner) } + assertThrows { SystematicStudy(id, title, description, owner, objectives = objectives) } } } } From 87cc5c3c5c076afe6e365d4aad2967229556d3bc Mon Sep 17 00:00:00 2001 From: ericksgmes Date: Wed, 3 Sep 2025 12:04:15 -0300 Subject: [PATCH 062/120] test: include objectives field in TestDataFactory --- .../br/all/application/review/util/TestDataFactory.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/review/src/test/kotlin/br/all/application/review/util/TestDataFactory.kt b/review/src/test/kotlin/br/all/application/review/util/TestDataFactory.kt index fd03b3921..845e9b97f 100644 --- a/review/src/test/kotlin/br/all/application/review/util/TestDataFactory.kt +++ b/review/src/test/kotlin/br/all/application/review/util/TestDataFactory.kt @@ -30,12 +30,14 @@ class TestDataFactory { description: String = faker.lorem.words(), ownerId: UUID = researcher, collaborators: Set = emptySet(), + objectives: String = faker.appliance.equipment() ) = SystematicStudyDto( id, title, description, ownerId, mutableSetOf(ownerId).also { it.addAll(collaborators) }, + objectives, ) fun protocolDto(systematicStudyId: UUID = systematicStudy) = Protocol @@ -47,8 +49,9 @@ class TestDataFactory { researcherId: UUID = researcher, title: String = faker.book.title(), description: String = faker.lorem.words(), - collaborators: Set = emptySet() - ) = CreateRequestModel(researcherId, title, description, collaborators) + collaborators: Set = emptySet(), + objective: String = faker.appliance.equipment(), + ) = CreateRequestModel(researcherId, title, description, collaborators, objective) fun createResponseModel( researcherId: UUID = researcher, From 30c3707b3eceb7c166d38e0a70f2baa9ad301f4d Mon Sep 17 00:00:00 2001 From: ericksgmes Date: Wed, 3 Sep 2025 12:33:40 -0300 Subject: [PATCH 063/120] test: update SystematicStudyTest to validate objectives field --- .../kotlin/br/all/domain/model/review/SystematicStudyTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/review/src/test/kotlin/br/all/domain/model/review/SystematicStudyTest.kt b/review/src/test/kotlin/br/all/domain/model/review/SystematicStudyTest.kt index 98171b514..dafc3229c 100644 --- a/review/src/test/kotlin/br/all/domain/model/review/SystematicStudyTest.kt +++ b/review/src/test/kotlin/br/all/domain/model/review/SystematicStudyTest.kt @@ -51,8 +51,8 @@ class SystematicStudyTest { @DisplayName("And providing invalid states") inner class AndProvidingInvalidStates { @ParameterizedTest(name = "[{index}]: title=\"{0}\", description=\"{1}\"") - @CsvSource("'',Some description", "Some title,''", "'','', Some objective") - fun `should not create systematic study without title or description`(title: String, description: String, objectives: String){ + @CsvSource("'','Some description',''", "'Some title','',''", "'','',Some objective") + fun `should not create systematic study without title, description or objective`(title: String, description: String, objectives: String){ val id = SystematicStudyId(UUID.randomUUID()) val owner = ResearcherId(UUID.randomUUID()) From 1ff93ae277834bd71b529f58434714bcf9ed922d Mon Sep 17 00:00:00 2001 From: ericksgmes Date: Wed, 3 Sep 2025 12:33:48 -0300 Subject: [PATCH 064/120] test: enhance TestDataFactory to handle objectives in request creation --- .../br/all/review/shared/TestDataFactory.kt | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/web/src/test/kotlin/br/all/review/shared/TestDataFactory.kt b/web/src/test/kotlin/br/all/review/shared/TestDataFactory.kt index bf5ded6e2..efbaba82f 100644 --- a/web/src/test/kotlin/br/all/review/shared/TestDataFactory.kt +++ b/web/src/test/kotlin/br/all/review/shared/TestDataFactory.kt @@ -16,25 +16,36 @@ class TestDataFactory { description: String = faker.lorem.words(), owner: UUID = this.researcherId, collaborators: MutableSet = mutableSetOf(), - ) = SystematicStudyDocument(id, title, description, owner, collaborators.also { it.add(owner) }.toSet()) + objectives: String = faker.adjective.positive(), + ) = SystematicStudyDocument(id, title, description, owner, collaborators.also { it.add(owner) }.toSet(), objectives) fun createValidPostRequest( title: String = faker.book.title(), description: String = faker.lorem.words(), - collaborators: Set = emptySet() - ) = """ + collaborators: Set = emptySet(), + objectives: String = faker.animal.name(), + ): String { + val collaboratorsJson = collaborators.joinToString( + prefix = "[", + postfix = "]" + ) { "\"$it\"" } + + return """ { "title": "$title", "description": "$description", - "collaborators": $collaborators + "collaborators": $collaboratorsJson, + "objectives": "$objectives" } """.trimIndent() + } fun createInvalidPostRequest() = """ { "title": "", "description": "", "collaborators": [], + "objectives": "" } """.trimIndent() From 23a834ec304235216ffe023b72ff6216c67314a6 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Wed, 3 Sep 2025 16:13:57 -0300 Subject: [PATCH 065/120] fix: remove comma from mocked pick many question --- .../example/CreateSystematicReviewExampleService.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 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 31b37c783..8f613374d 100644 --- a/web/src/main/kotlin/br/all/utils/example/CreateSystematicReviewExampleService.kt +++ b/web/src/main/kotlin/br/all/utils/example/CreateSystematicReviewExampleService.kt @@ -157,11 +157,11 @@ class CreateSystematicReviewExampleService( 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)" + "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)" ) ) From 68dc4b248c3464c5a35b7b8424882c262a02bd49 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 4 Sep 2025 11:31:25 -0300 Subject: [PATCH 066/120] feat: add single user repository method that returns profile and credentials --- .../user/repository/UserAccountRepository.kt | 1 + .../user/UserAccountRepositoryImpl.kt | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/account/src/main/kotlin/br/all/application/user/repository/UserAccountRepository.kt b/account/src/main/kotlin/br/all/application/user/repository/UserAccountRepository.kt index d14aa597a..763670422 100644 --- a/account/src/main/kotlin/br/all/application/user/repository/UserAccountRepository.kt +++ b/account/src/main/kotlin/br/all/application/user/repository/UserAccountRepository.kt @@ -12,4 +12,5 @@ interface UserAccountRepository { fun existsByUsername(username: String): Boolean fun deleteById(id: UUID) fun loadUserProfileById(id: UUID): UserProfileDto? + fun loadFullUserAccountById(id: UUID): UserAccountDto? } \ No newline at end of file diff --git a/account/src/main/kotlin/br/all/infrastructure/user/UserAccountRepositoryImpl.kt b/account/src/main/kotlin/br/all/infrastructure/user/UserAccountRepositoryImpl.kt index 8c3d02b06..57f1c1d53 100644 --- a/account/src/main/kotlin/br/all/infrastructure/user/UserAccountRepositoryImpl.kt +++ b/account/src/main/kotlin/br/all/infrastructure/user/UserAccountRepositoryImpl.kt @@ -26,6 +26,28 @@ class UserAccountRepositoryImpl( override fun loadUserProfileById(id: UUID): UserProfileDto? = userAccountRepository.findById(id).orElse(null)?.toUserProfileDto() + override fun loadFullUserAccountById(id: UUID): UserAccountDto? { + val userAccount = userAccountRepository.findById(id).orElse(null) ?: return null + val credentials = credentialsRepository.findById(id).orElse(null) ?: return null + + return UserAccountDto( + id = userAccount.id, + name = userAccount.name, + username = credentials.username, + password = credentials.password, + email = userAccount.email, + country = userAccount.country, + affiliation = userAccount.affiliation, + createdAt = userAccount.createdAt, + authorities = credentials.authorities, + refreshToken = credentials.refreshToken, + isAccountNonExpired = credentials.isAccountNonExpired, + isAccountNonLocked = credentials.isAccountNonLocked, + isCredentialsNonExpired = credentials.isCredentialsNonExpired, + isEnabled = credentials.isEnabled + ) + } + override fun loadCredentialsByUsername(username: String) = credentialsRepository.findByUsername(username)?.toAccountCredentialsDto() From b05dbd65b51eb637c423127a07f104f72a92eedd Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 4 Sep 2025 11:41:02 -0300 Subject: [PATCH 067/120] test: add full user dto to test data factory --- .../application/user/utils/TestDataFactory.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 4315ef03b..a3aa561cb 100644 --- a/account/src/test/kotlin/br/all/application/user/utils/TestDataFactory.kt +++ b/account/src/test/kotlin/br/all/application/user/utils/TestDataFactory.kt @@ -2,8 +2,10 @@ package br.all.application.user.utils import br.all.application.user.create.RegisterUserAccountService import br.all.application.user.repository.AccountCredentialsDto +import br.all.application.user.repository.UserAccountDto import br.all.application.user.repository.UserProfileDto import io.github.serpro69.kfaker.Faker +import java.time.LocalDateTime import java.util.* class TestDataFactory { @@ -34,4 +36,21 @@ class TestDataFactory { affiliation = faker.leagueOfLegends.rank(), name = faker.name.firstName() ) + + fun userAccountDto() = UserAccountDto( + id = UUID.randomUUID(), + username = faker.name.firstName(), + name = faker.name.firstName(), + password = faker.pearlJam.songs(), + email = faker.internet.email(), + country = faker.address.countryCode(), + affiliation = faker.lorem.words(), + createdAt = LocalDateTime.now(), + authorities = setOf("USER"), + refreshToken = faker.leagueOfLegends.rank(), + isAccountNonExpired = false, + isAccountNonLocked = false, + isCredentialsNonExpired = false, + isEnabled = true, + ) } \ No newline at end of file From d1ba8c4bdcf91056d214505fb74dcd8e76ac6fe6 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 4 Sep 2025 11:51:35 -0300 Subject: [PATCH 068/120] refactor: change retrieve user profile service to use new repository method --- .../find/RetrieveUserProfileServiceImpl.kt | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) 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 5aeac3761..146fe29c3 100644 --- a/account/src/main/kotlin/br/all/application/user/find/RetrieveUserProfileServiceImpl.kt +++ b/account/src/main/kotlin/br/all/application/user/find/RetrieveUserProfileServiceImpl.kt @@ -5,33 +5,26 @@ import br.all.application.user.find.RetrieveUserProfileService.ResponseModel import br.all.application.user.repository.UserAccountRepository class RetrieveUserProfileServiceImpl( - private val userAccountRepository: UserAccountRepository + private val repository: UserAccountRepository ) : RetrieveUserProfileService { override fun retrieveData( presenter: RetrieveUserProfilePresenter, request: RequestModel ) { - val userProfile = userAccountRepository.loadUserProfileById(request.userId) - if (userProfile == null) { + val userAccount = repository.loadFullUserAccountById(request.userId) + if (userAccount == null) { presenter.prepareFailView(NoSuchElementException("User with id ${request.userId} doesn't exist!")) return } - val userCredentials = userAccountRepository.loadCredentialsById(request.userId) - if (userCredentials == null) { - presenter.prepareFailView(NoSuchElementException("Account credentials with id ${request.userId} doesn't exist!")) - return - } - - val profile = ResponseModel( - userId = userProfile.id, - name = userProfile.name, - username = userCredentials.username, - email = userProfile.email, - affiliation = userProfile.affiliation, - country = userProfile.country, - authorities = userCredentials.authorities + userId = userAccount.id, + name = userAccount.name, + username = userAccount.username, + email = userAccount.email, + affiliation = userAccount.affiliation, + country = userAccount.country, + authorities = userAccount.authorities ) presenter.prepareSuccessView(profile) From b67b417b6a9780631f946f01c6acb97ba7aa80b5 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 4 Sep 2025 11:51:46 -0300 Subject: [PATCH 069/120] test: change retrieve user profile test to use new repository method --- .../RetrieveUserProfileServiceImplTest.kt | 48 +++++-------------- 1 file changed, 13 insertions(+), 35 deletions(-) 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 202e3b444..37b2cb894 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 @@ -19,7 +19,7 @@ import kotlin.NoSuchElementException class RetrieveUserProfileServiceImplTest { @MockK(relaxUnitFun = true) - private lateinit var userAccountRepository: UserAccountRepository + private lateinit var repository: UserAccountRepository @MockK(relaxUnitFun = true) private lateinit var presenter: RetrieveUserProfilePresenter @@ -29,7 +29,7 @@ class RetrieveUserProfileServiceImplTest { @BeforeEach fun setUp() { - sut = RetrieveUserProfileServiceImpl(userAccountRepository) + sut = RetrieveUserProfileServiceImpl(repository) factory = TestDataFactory() } @@ -39,12 +39,10 @@ class RetrieveUserProfileServiceImplTest { @Test fun `should retrieve user profile and prepare success view`() { - val userProfile = factory.userProfile() - val userCredentials = factory.accountCredentials().copy(id = userProfile.id) - val request = RetrieveUserProfileService.RequestModel(userId = userProfile.id) + val userAccountDto = factory.userAccountDto() + val request = RetrieveUserProfileService.RequestModel(userId = userAccountDto.id) - every { userAccountRepository.loadUserProfileById(request.userId) } returns userProfile - every { userAccountRepository.loadCredentialsById(request.userId) } returns userCredentials + every { repository.loadFullUserAccountById(userAccountDto.id) } returns userAccountDto val responseSlot = slot() every { presenter.prepareSuccessView(capture(responseSlot)) } returns Unit @@ -55,13 +53,13 @@ class RetrieveUserProfileServiceImplTest { verify(exactly = 0) { presenter.prepareFailView(any()) } 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) - assertEquals(userProfile.country, capturedResponse.country) - assertEquals(userCredentials.authorities, capturedResponse.authorities) + assertEquals(userAccountDto.id, capturedResponse.userId) + assertEquals(userAccountDto.name, capturedResponse.name) + assertEquals(userAccountDto.username, capturedResponse.username) + assertEquals(userAccountDto.email, capturedResponse.email) + assertEquals(userAccountDto.affiliation, capturedResponse.affiliation) + assertEquals(userAccountDto.country, capturedResponse.country) + assertEquals(userAccountDto.authorities, capturedResponse.authorities) } @Test @@ -69,7 +67,7 @@ class RetrieveUserProfileServiceImplTest { val userId = UUID.randomUUID() val request = RetrieveUserProfileService.RequestModel(userId = userId) - every { userAccountRepository.loadUserProfileById(userId) } returns null + every { repository.loadFullUserAccountById(userId) } returns null val exceptionSlot = slot() every { presenter.prepareFailView(capture(exceptionSlot)) } returns Unit @@ -82,25 +80,5 @@ class RetrieveUserProfileServiceImplTest { val capturedException = exceptionSlot.captured assertEquals("User with id $userId doesn't exist!", capturedException.message) } - - @Test - fun `should prepare fail view when user credentials are not found`() { - val userProfile = factory.userProfile() - val request = RetrieveUserProfileService.RequestModel(userId = userProfile.id) - - every { userAccountRepository.loadUserProfileById(request.userId) } returns userProfile - every { userAccountRepository.loadCredentialsById(request.userId) } returns null - - val exceptionSlot = slot() - every { presenter.prepareFailView(capture(exceptionSlot)) } returns Unit - - sut.retrieveData(presenter, request) - - verify(exactly = 0) { presenter.prepareSuccessView(any()) } - verify(exactly = 1) { presenter.prepareFailView(any()) } - - val capturedException = exceptionSlot.captured - assertEquals("Account credentials with id ${request.userId} doesn't exist!", capturedException.message) - } } } From c8ffeccffddd0db021cdc706ae858f6136782ca0 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 4 Sep 2025 11:54:44 -0300 Subject: [PATCH 070/120] refactor: add invalid entries data class to patch user response model --- .../user/update/PatchUserProfileService.kt | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) 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 9638c4a68..6701ee917 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,11 +7,10 @@ interface PatchUserProfileService { data class RequestModel( val userId: UUID, - val name: String?, - val username: String?, - val email: String?, - val affiliation: String?, - val country: String? + val name: String, + val email: String, + val affiliation: String, + val country: String ) data class ResponseModel( @@ -21,6 +20,12 @@ interface PatchUserProfileService { val email: String, val affiliation: String, val country: String, - val invalidEntries: List + val invalidEntries: List + ) + + data class InvalidEntry( + val field: String, + val entry: String, + val message: String ) } \ No newline at end of file From 1da15daa8eea5e95d095395df2c6d4b82795f475 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 4 Sep 2025 11:55:06 -0300 Subject: [PATCH 071/120] wip: implement patch user profile service --- .../update/PatchUserProfileServiceImpl.kt | 104 +++++++++++++----- 1 file changed, 75 insertions(+), 29 deletions(-) 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 57905c917..e6bf7e9f1 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 @@ -3,9 +3,10 @@ package br.all.application.user.update import br.all.application.user.repository.UserAccountRepository import br.all.application.user.update.PatchUserProfileService.RequestModel import br.all.application.user.update.PatchUserProfileService.ResponseModel +import br.all.application.user.update.PatchUserProfileService.InvalidEntry import br.all.domain.shared.user.Email +import br.all.domain.shared.user.Name import br.all.domain.shared.user.Text -import br.all.domain.shared.user.Username class PatchUserProfileServiceImpl( private val repository: UserAccountRepository @@ -14,35 +15,80 @@ class PatchUserProfileServiceImpl( presenter: PatchUserProfilePresenter, request: RequestModel ) { - if (repository.loadCredentialsById(request.userId) == null) { - presenter.prepareFailView(NoSuchElementException("User with id ${request.userId} not found!")) + val userAccount = repository.loadFullUserAccountById(request.userId) + if (userAccount == null) { + presenter.prepareFailView(NoSuchElementException("User with id ${request.userId} doesn't exist!")) + return } - 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 - // ) + val invalidEntries: MutableList = mutableListOf() + var updatedName = userAccount.name + var updatedEmail = userAccount.email + var updatedCountry = userAccount.country + var updatedAffiliation = userAccount.affiliation + + try { + val newName = Name(request.name) + updatedName = newName.value + } catch (e: Exception) { + invalidEntries.add(InvalidEntry( + field = "name", + entry = request.name, + message = e.message ?: "Invalid name format" + )) + } + + try { + val newEmail = Email(request.email) + updatedEmail = newEmail.email + } catch (e: Exception) { + invalidEntries.add(InvalidEntry( + field = "email", + entry = request.email, + message = e.message ?: "Invalid email format" + )) + } + + try { + val newCountry = Text(request.country) + updatedCountry = newCountry.value + } catch (e: Exception) { + invalidEntries.add(InvalidEntry( + field = "country", + entry = request.country, + message = e.message ?: "Invalid country format" + )) + } + + try { + val newAffiliation = Text(request.affiliation) + updatedAffiliation = newAffiliation.value + } catch (e: Exception) { + invalidEntries.add(InvalidEntry( + field = "affiliation", + entry = request.affiliation, + message = e.message ?: "Invalid affiliation format" + )) + } + + val updatedUserAccount = userAccount.copy( + name = updatedName, + email = updatedEmail, + country = updatedCountry, + affiliation = updatedAffiliation + ) + + val responseModel = ResponseModel( + userId = request.userId, + name = updatedName, + username = userAccount.username, + email = updatedEmail, + affiliation = updatedAffiliation, + country = updatedCountry, + invalidEntries = invalidEntries + ) + + repository.save(updatedUserAccount) + presenter.prepareSuccessView(responseModel) } } \ No newline at end of file From d4f6fbe6262037ab0e1fb4927cbe3d5c6ce6d90d Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Sat, 6 Sep 2025 01:52:01 -0300 Subject: [PATCH 072/120] build(security): enable an extra cors localhost port --- .../main/kotlin/br/all/security/config/SecurityConfiguration.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/main/kotlin/br/all/security/config/SecurityConfiguration.kt b/web/src/main/kotlin/br/all/security/config/SecurityConfiguration.kt index 06d34bc97..7eb9f9497 100644 --- a/web/src/main/kotlin/br/all/security/config/SecurityConfiguration.kt +++ b/web/src/main/kotlin/br/all/security/config/SecurityConfiguration.kt @@ -60,7 +60,7 @@ class SecurityConfiguration( fun corsConfigurationSource(): CorsConfigurationSource { val config = CorsConfiguration() - config.allowedOrigins = listOf("http://localhost:5173") + config.allowedOrigins = listOf("http://localhost:5173", "http://localhost:5174") config.allowedMethods = listOf("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") config.allowedHeaders = listOf("*") config.allowCredentials = true From 6fae019853e85d70da2b03dbb5ecf8982788e625 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 9 Sep 2025 11:55:36 -0300 Subject: [PATCH 073/120] refactor(patch-profile): create helper function to increase readability --- .../update/PatchUserProfileServiceImpl.kt | 73 +++++++------------ 1 file changed, 25 insertions(+), 48 deletions(-) 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 e6bf7e9f1..8dc91faff 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 @@ -21,55 +21,12 @@ class PatchUserProfileServiceImpl( return } - val invalidEntries: MutableList = mutableListOf() - var updatedName = userAccount.name - var updatedEmail = userAccount.email - var updatedCountry = userAccount.country - var updatedAffiliation = userAccount.affiliation + val invalidEntries = mutableListOf() - try { - val newName = Name(request.name) - updatedName = newName.value - } catch (e: Exception) { - invalidEntries.add(InvalidEntry( - field = "name", - entry = request.name, - message = e.message ?: "Invalid name format" - )) - } - - try { - val newEmail = Email(request.email) - updatedEmail = newEmail.email - } catch (e: Exception) { - invalidEntries.add(InvalidEntry( - field = "email", - entry = request.email, - message = e.message ?: "Invalid email format" - )) - } - - try { - val newCountry = Text(request.country) - updatedCountry = newCountry.value - } catch (e: Exception) { - invalidEntries.add(InvalidEntry( - field = "country", - entry = request.country, - message = e.message ?: "Invalid country format" - )) - } - - try { - val newAffiliation = Text(request.affiliation) - updatedAffiliation = newAffiliation.value - } catch (e: Exception) { - invalidEntries.add(InvalidEntry( - field = "affiliation", - entry = request.affiliation, - message = e.message ?: "Invalid affiliation format" - )) - } + val updatedName = validateField("name", request.name, userAccount.name, invalidEntries) { Name(it).value } + val updatedEmail = validateField("email", request.email, userAccount.email, invalidEntries) { Email(it).email } + val updatedCountry = validateField("country", request.country, userAccount.country, invalidEntries) { Text(it).value } + val updatedAffiliation = validateField("affiliation", request.affiliation, userAccount.affiliation, invalidEntries) { Text(it).value } val updatedUserAccount = userAccount.copy( name = updatedName, @@ -91,4 +48,24 @@ class PatchUserProfileServiceImpl( repository.save(updatedUserAccount) presenter.prepareSuccessView(responseModel) } + + private fun validateField( + fieldName: String, + newValue: String, + currentValue: String, + errors: MutableList, + validationLogic: (String) -> String + ): String { + return try { + validationLogic(newValue) + } catch (e: Exception) { + errors.add(InvalidEntry( + field = fieldName, + entry = newValue, + message = e.message ?: "Invalid $fieldName format" + )) + + currentValue + } + } } \ No newline at end of file From c5bb75f38afefcff5461f5155e9edcdbc2c1f9d8 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 9 Sep 2025 12:30:18 -0300 Subject: [PATCH 074/120] feat(patch-profile): add restful presenter --- .../RestfulPatchUserProfilePresenter.kt | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 web/src/main/kotlin/br/all/user/presenter/RestfulPatchUserProfilePresenter.kt diff --git a/web/src/main/kotlin/br/all/user/presenter/RestfulPatchUserProfilePresenter.kt b/web/src/main/kotlin/br/all/user/presenter/RestfulPatchUserProfilePresenter.kt new file mode 100644 index 000000000..2205ac76c --- /dev/null +++ b/web/src/main/kotlin/br/all/user/presenter/RestfulPatchUserProfilePresenter.kt @@ -0,0 +1,42 @@ +package br.all.user.presenter + +import br.all.application.user.update.PatchUserProfilePresenter +import br.all.application.user.update.PatchUserProfileService.InvalidEntry +import br.all.application.user.update.PatchUserProfileService.ResponseModel +import br.all.shared.error.createErrorResponseFrom +import org.springframework.hateoas.RepresentationModel +import org.springframework.http.ResponseEntity +import org.springframework.http.ResponseEntity.ok +import java.util.UUID + +class RestfulPatchUserProfilePresenter : PatchUserProfilePresenter { + + var responseEntity: ResponseEntity<*>? = null + + override fun prepareSuccessView(response: ResponseModel) { + val restfulResponse = ViewModel( + userId = response.userId, + name = response.name, + username = response.username, + email = response.email, + affiliation = response.affiliation, + country = response.country, + invalidEntries = response.invalidEntries + ) + responseEntity = ok(restfulResponse) + } + + override fun prepareFailView(throwable: Throwable) = run { responseEntity = createErrorResponseFrom(throwable) } + + override fun isDone() = responseEntity != null + + private data class ViewModel( + val userId: UUID, + val name: String, + val username: String, + val email: String, + val affiliation: String, + val country: String, + val invalidEntries: List + ) : RepresentationModel() +} \ No newline at end of file From 213fbfe25974035dcabd476130a429f6fd2ccaac Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 9 Sep 2025 12:51:06 -0300 Subject: [PATCH 075/120] feat(patch-profile): add a new user endpoint method --- .../controller/UserAccountConfiguration.kt | 4 ++ .../user/controller/UserAccountController.kt | 50 ++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/web/src/main/kotlin/br/all/user/controller/UserAccountConfiguration.kt b/web/src/main/kotlin/br/all/user/controller/UserAccountConfiguration.kt index 44d088719..04b92878d 100644 --- a/web/src/main/kotlin/br/all/user/controller/UserAccountConfiguration.kt +++ b/web/src/main/kotlin/br/all/user/controller/UserAccountConfiguration.kt @@ -3,6 +3,7 @@ package br.all.user.controller import br.all.application.user.create.RegisterUserAccountServiceImpl import br.all.application.user.find.RetrieveUserProfileServiceImpl import br.all.application.user.repository.UserAccountRepository +import br.all.application.user.update.PatchUserProfileServiceImpl import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -14,4 +15,7 @@ class UserAccountConfiguration { @Bean fun retrieveUserProfile(repository: UserAccountRepository) = RetrieveUserProfileServiceImpl(repository) + + @Bean + fun patchUserProfile(repository: UserAccountRepository) = PatchUserProfileServiceImpl(repository) } \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt b/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt index 3919eaef8..c82c6ac4c 100644 --- a/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt +++ b/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt @@ -4,7 +4,9 @@ import br.all.application.user.CredentialsService import br.all.application.user.create.RegisterUserAccountService import br.all.application.user.create.RegisterUserAccountService.RequestModel import br.all.application.user.find.RetrieveUserProfileService +import br.all.application.user.update.PatchUserProfileService import br.all.security.service.AuthenticationInfoService +import br.all.user.presenter.RestfulPatchUserProfilePresenter import br.all.user.presenter.RestfulRegisterUserAccountPresenter import br.all.user.presenter.RestfulRetrieveUserProfilePresenter import io.swagger.v3.oas.annotations.Operation @@ -16,6 +18,7 @@ import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -27,7 +30,8 @@ class UserAccountController( private val registerUserAccountService: RegisterUserAccountService, private val encoder: PasswordEncoder, private val retrieveUserProfileService: RetrieveUserProfileService, - private val authenticationInfoService: AuthenticationInfoService + private val authenticationInfoService: AuthenticationInfoService, + private val patchUserProfileService: PatchUserProfileService ) { @PostMapping @@ -97,4 +101,48 @@ class UserAccountController( retrieveUserProfileService.retrieveData(presenter, request) return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) } + + + @PatchMapping("/profile") + @Operation(summary = "Update public information of a user") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Success updating user profile", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = PatchUserProfileService.ResponseModel::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Fail updating user profile - unauthenticated collaborator", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "403", + description = "Fail updating user profile - unauthorized collaborator", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "404", + description = "Fail updating user profile - nonexistent user", + content = [Content(schema = Schema(hidden = true))] + ), + ]) + fun patchUserPublicData(@RequestBody body: PatchUserProfileService.RequestModel): ResponseEntity<*> { + val presenter = RestfulPatchUserProfilePresenter() + val userId = authenticationInfoService.getAuthenticatedUserId() + val request = PatchUserProfileService.RequestModel( + userId = userId, + name = body.name, + email = body.email, + affiliation = body.affiliation, + country = body.country + ) + + patchUserProfileService.patchProfile(presenter, request) + return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) + } } \ No newline at end of file From 95d3e63b1b027f2c508fd8511a35d06714bfb27e Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 9 Sep 2025 12:51:36 -0300 Subject: [PATCH 076/120] fix(user-controller): typos --- .../br/all/user/controller/UserAccountController.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt b/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt index c82c6ac4c..69fb2ecf2 100644 --- a/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt +++ b/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt @@ -35,12 +35,12 @@ class UserAccountController( ) { @PostMapping - @Operation(summary = "Create a new user") + @Operation(summary = "Create an new user") @ApiResponses( value = [ ApiResponse( responseCode = "201", - description = "Success creating a user", + description = "Success creating an user", content = [Content( mediaType = "application/json", schema = Schema(implementation = CredentialsService.ResponseModel::class) @@ -48,12 +48,12 @@ class UserAccountController( ), ApiResponse( responseCode = "400", - description = "Fail creating a user - invalid input", + description = "Fail creating an user - invalid input", content = [Content(schema = Schema(hidden = true))] ), ApiResponse( responseCode = "409", - description = "Fail creating a user - user already exists", + description = "Fail creating an user - user already exists", content = [Content(schema = Schema(hidden = true))] ), ] @@ -66,7 +66,7 @@ class UserAccountController( } @GetMapping("/profile") - @Operation(summary = "Retrieve public information of a user") + @Operation(summary = "Retrieve public information of an user") @ApiResponses( value = [ ApiResponse( @@ -104,7 +104,7 @@ class UserAccountController( @PatchMapping("/profile") - @Operation(summary = "Update public information of a user") + @Operation(summary = "Update public information of an user") @ApiResponses( value = [ ApiResponse( From 57133abfad555d076c678cf79d7dc0adf87f3922 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 9 Sep 2025 13:08:58 -0300 Subject: [PATCH 077/120] refactor(patch-profile): add request model for patching user profile --- .../br/all/user/controller/UserAccountController.kt | 3 ++- .../br/all/user/requests/PatchUserProfileRequest.kt | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 web/src/main/kotlin/br/all/user/requests/PatchUserProfileRequest.kt diff --git a/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt b/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt index 69fb2ecf2..b3dbaaea7 100644 --- a/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt +++ b/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt @@ -9,6 +9,7 @@ import br.all.security.service.AuthenticationInfoService import br.all.user.presenter.RestfulPatchUserProfilePresenter import br.all.user.presenter.RestfulRegisterUserAccountPresenter import br.all.user.presenter.RestfulRetrieveUserProfilePresenter +import br.all.user.requests.PatchUserProfileRequest import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema @@ -131,7 +132,7 @@ class UserAccountController( content = [Content(schema = Schema(hidden = true))] ), ]) - fun patchUserPublicData(@RequestBody body: PatchUserProfileService.RequestModel): ResponseEntity<*> { + fun patchUserPublicData(@RequestBody body: PatchUserProfileRequest): ResponseEntity<*> { val presenter = RestfulPatchUserProfilePresenter() val userId = authenticationInfoService.getAuthenticatedUserId() val request = PatchUserProfileService.RequestModel( diff --git a/web/src/main/kotlin/br/all/user/requests/PatchUserProfileRequest.kt b/web/src/main/kotlin/br/all/user/requests/PatchUserProfileRequest.kt new file mode 100644 index 000000000..c443ffe7a --- /dev/null +++ b/web/src/main/kotlin/br/all/user/requests/PatchUserProfileRequest.kt @@ -0,0 +1,8 @@ +package br.all.user.requests + +data class PatchUserProfileRequest( + val name: String, + val email: String, + val affiliation: String, + val country: String +) From bfbbf2ff9d220b356cf1855afc2302a47491c738 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 9 Sep 2025 13:32:25 -0300 Subject: [PATCH 078/120] test(patch-profile): add unit tests for PatchUserProfileServiceImpl --- .../update/PatchUserProfileServiceImplTest.kt | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 account/src/test/kotlin/br/all/application/user/update/PatchUserProfileServiceImplTest.kt diff --git a/account/src/test/kotlin/br/all/application/user/update/PatchUserProfileServiceImplTest.kt b/account/src/test/kotlin/br/all/application/user/update/PatchUserProfileServiceImplTest.kt new file mode 100644 index 000000000..7b28de501 --- /dev/null +++ b/account/src/test/kotlin/br/all/application/user/update/PatchUserProfileServiceImplTest.kt @@ -0,0 +1,155 @@ +package br.all.application.user.update + +import br.all.application.user.repository.UserAccountDto +import br.all.application.user.repository.UserAccountRepository +import br.all.application.user.utils.TestDataFactory +import com.mongodb.assertions.Assertions.assertTrue +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.extension.ExtendWith +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals + +@Tag("UnitTest") +@Tag("ServiceTest") +@ExtendWith(MockKExtension::class) +class PatchUserProfileServiceImplTest { + + @MockK(relaxUnitFun = true) + private lateinit var repository: UserAccountRepository + + @MockK(relaxUnitFun = true) + private lateinit var presenter: PatchUserProfilePresenter + + private lateinit var sut: PatchUserProfileServiceImpl + private lateinit var factory: TestDataFactory + + @BeforeEach + fun setup() { + sut = PatchUserProfileServiceImpl(repository) + factory = TestDataFactory() + } + + @Nested + @DisplayName("When patching a user profile") + inner class WhenPatchingUserProfile { + + @Test + fun `should update all fields and prepare success view when all entries are valid`() { + val existingUser = factory.userAccountDto() + val request = PatchUserProfileService.RequestModel( + userId = existingUser.id, + name = "New Valid Name", + email = "new.valid@email.com", + affiliation = "New Affiliation", + country = "New Country" + ) + + every { repository.loadFullUserAccountById(existingUser.id) } returns existingUser + + val responseSlot = slot() + every { presenter.prepareSuccessView(capture(responseSlot)) } returns Unit + + val savedUserSlot = slot() + every { repository.save(capture(savedUserSlot)) } returns Unit + + sut.patchProfile(presenter, request) + + verify(exactly = 1) { presenter.prepareSuccessView(any()) } + verify(exactly = 0) { presenter.prepareFailView(any()) } + verify(exactly = 1) { repository.save(any()) } + + val capturedResponse = responseSlot.captured + assertEquals(request.name, capturedResponse.name) + assertEquals(request.email, capturedResponse.email) + assertEquals(request.affiliation, capturedResponse.affiliation) + assertEquals(request.country, capturedResponse.country) + assertTrue(capturedResponse.invalidEntries.isEmpty()) + + val capturedSavedUser = savedUserSlot.captured + assertEquals(request.name, capturedSavedUser.name) + assertEquals(request.email, capturedSavedUser.email) + + assertEquals(existingUser.id, capturedSavedUser.id) + assertEquals(existingUser.username, capturedSavedUser.username) + } + + @Test + fun `should update valid fields, collect errors, and prepare success view when some entries are invalid`() { + val existingUser = factory.userAccountDto() + val request = PatchUserProfileService.RequestModel( + userId = existingUser.id, + name = "Another Valid Name", + email = "invalid-email", + affiliation = "Another Valid Affiliation", + country = "" + ) + + every { repository.loadFullUserAccountById(existingUser.id) } returns existingUser + + val responseSlot = slot() + every { presenter.prepareSuccessView(capture(responseSlot)) } returns Unit + + val savedUserSlot = slot() + every { repository.save(capture(savedUserSlot)) } returns Unit + + sut.patchProfile(presenter, request) + + verify(exactly = 1) { presenter.prepareSuccessView(any()) } + verify(exactly = 0) { presenter.prepareFailView(any()) } + verify(exactly = 1) { repository.save(any()) } + + val capturedResponse = responseSlot.captured + assertEquals(2, capturedResponse.invalidEntries.size) + assertEquals("email", capturedResponse.invalidEntries[0].field) + assertEquals("invalid-email", capturedResponse.invalidEntries[0].entry) + assertEquals("country", capturedResponse.invalidEntries[1].field) + assertEquals("", capturedResponse.invalidEntries[1].entry) + + assertEquals(request.name, capturedResponse.name) + assertEquals(request.affiliation, capturedResponse.affiliation) + assertEquals(existingUser.email, capturedResponse.email) + assertEquals(existingUser.country, capturedResponse.country) + + val capturedSavedUser = savedUserSlot.captured + assertEquals(request.name, capturedSavedUser.name) + assertEquals(request.affiliation, capturedSavedUser.affiliation) + assertEquals(existingUser.email, capturedSavedUser.email) + assertEquals(existingUser.country, capturedSavedUser.country) + } + + @Test + fun `should prepare fail view when user does not exist`() { + val nonExistentUserId = UUID.randomUUID() + val request = PatchUserProfileService.RequestModel( + userId = nonExistentUserId, + name = "Any Name", + email = "any@email.com", + affiliation = "Any", + country = "Any" + ) + + every { repository.loadFullUserAccountById(nonExistentUserId) } returns null + + val exceptionSlot = slot() + every { presenter.prepareFailView(capture(exceptionSlot)) } returns Unit + + sut.patchProfile(presenter, request) + + verify(exactly = 0) { presenter.prepareSuccessView(any()) } + verify(exactly = 1) { presenter.prepareFailView(any()) } + verify(exactly = 0) { repository.save(any()) } + + val capturedException = exceptionSlot.captured + assertEquals("User with id $nonExistentUserId doesn't exist!", capturedException.message) + } + } +} \ No newline at end of file From 357c8caa360bba885e6a462ea61470a342b7ac4b Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Wed, 10 Sep 2025 18:50:37 -0300 Subject: [PATCH 079/120] build: add swagger dependency for account module --- account/pom.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/account/pom.xml b/account/pom.xml index f89bc2c3f..a28147f92 100644 --- a/account/pom.xml +++ b/account/pom.xml @@ -65,5 +65,10 @@ 1.0-SNAPSHOT compile + + io.swagger.core.v3 + swagger-annotations-jakarta + 2.2.31 + \ No newline at end of file From aaf0be1a758b03e6be9b9f861adfdee5a00eaac0 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Wed, 10 Sep 2025 18:50:49 -0300 Subject: [PATCH 080/120] feat: add missing swagger schema tag to account module services --- .../all/application/user/create/RegisterUserAccountService.kt | 2 ++ .../br/all/application/user/find/RetrieveUserProfileService.kt | 2 ++ .../br/all/application/user/update/PatchUserProfileService.kt | 2 ++ 3 files changed, 6 insertions(+) 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 36ba6130b..335618bbf 100644 --- a/account/src/main/kotlin/br/all/application/user/create/RegisterUserAccountService.kt +++ b/account/src/main/kotlin/br/all/application/user/create/RegisterUserAccountService.kt @@ -1,5 +1,6 @@ package br.all.application.user.create +import io.swagger.v3.oas.annotations.media.Schema import java.util.UUID interface RegisterUserAccountService { @@ -15,6 +16,7 @@ interface RegisterUserAccountService { val affiliation: String, ) + @Schema(name = "RegisterUserAccountResponseModel", description = "Response model for Register User Account Service") data class ResponseModel( val id: UUID, val username: String, diff --git a/account/src/main/kotlin/br/all/application/user/find/RetrieveUserProfileService.kt b/account/src/main/kotlin/br/all/application/user/find/RetrieveUserProfileService.kt index 79c161926..f859455c5 100644 --- a/account/src/main/kotlin/br/all/application/user/find/RetrieveUserProfileService.kt +++ b/account/src/main/kotlin/br/all/application/user/find/RetrieveUserProfileService.kt @@ -1,4 +1,5 @@ package br.all.application.user.find +import io.swagger.v3.oas.annotations.media.Schema import java.util.UUID interface RetrieveUserProfileService { @@ -8,6 +9,7 @@ interface RetrieveUserProfileService { val userId: UUID ) + @Schema(name = "RetrieveUserProfileService", description = "Response model for Retrieve User Profile Service") data class ResponseModel( val userId: UUID, val name: String, 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 6701ee917..6c51da928 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 @@ -1,5 +1,6 @@ package br.all.application.user.update +import io.swagger.v3.oas.annotations.media.Schema import java.util.UUID interface PatchUserProfileService { @@ -13,6 +14,7 @@ interface PatchUserProfileService { val country: String ) + @Schema(name = "PatchUserProfileServiceResponseModel", description = "Response model for Patch User Profile Service") data class ResponseModel( val userId: UUID, val name: String, From d2b1e575e93a38101e62ce03238e8c416ad34014 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 11 Sep 2025 11:13:26 -0300 Subject: [PATCH 081/120] refactor(protocol-controller): update base path to include /api/v1 --- .../kotlin/br/all/protocol/controller/ProtocolController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/main/kotlin/br/all/protocol/controller/ProtocolController.kt b/web/src/main/kotlin/br/all/protocol/controller/ProtocolController.kt index 2a23e29f2..455629e5a 100644 --- a/web/src/main/kotlin/br/all/protocol/controller/ProtocolController.kt +++ b/web/src/main/kotlin/br/all/protocol/controller/ProtocolController.kt @@ -22,7 +22,7 @@ import br.all.application.protocol.find.FindProtocolService.RequestModel as Find import br.all.application.protocol.find.FindProtocolStageService.RequestModel as FindStageRequestModel @RestController -@RequestMapping("/systematic-study/{systematicStudyId}/protocol") +@RequestMapping("/api/v1/systematic-study/{systematicStudyId}/protocol") class ProtocolController( private val findProtocolService: FindProtocolService, private val updateProtocolService: UpdateProtocolService, From d1c9c7eb2ba9a55d76d8acfcf7a727c4196d9cc0 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Sat, 13 Sep 2025 19:07:25 -0300 Subject: [PATCH 082/120] test(protocol-controller): update URLs to include /api/v1 in tests --- .../br/all/protocol/controller/ProtocolControllerTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/test/kotlin/br/all/protocol/controller/ProtocolControllerTest.kt b/web/src/test/kotlin/br/all/protocol/controller/ProtocolControllerTest.kt index 315c98db0..5b1c1bdba 100644 --- a/web/src/test/kotlin/br/all/protocol/controller/ProtocolControllerTest.kt +++ b/web/src/test/kotlin/br/all/protocol/controller/ProtocolControllerTest.kt @@ -63,13 +63,13 @@ class ProtocolControllerTest( } private fun getUrl(systematicStudy: UUID = factory.protocol) = - "/systematic-study/$systematicStudy/protocol" + "/api/v1/systematic-study/$systematicStudy/protocol" private fun putUrl(systematicStudyId: UUID = factory.protocol) = - "/systematic-study/$systematicStudyId/protocol" + "/api/v1/systematic-study/$systematicStudyId/protocol" private fun findStage(systematicStudy: UUID = factory.protocol) = - "/systematic-study/$systematicStudy/protocol/stage" + "/api/v1/systematic-study/$systematicStudy/protocol/stage" @Nested @DisplayName("When getting protocols") From 8950a98ae92d37998fed8538a756bcd2aada76ca Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Sat, 13 Sep 2025 19:18:09 -0300 Subject: [PATCH 083/120] refactor(protocol-service): change protocol goal to systematic study objectives --- .../protocol/find/FindProtocolStageServiceImpl.kt | 9 ++++----- .../find/FindProtocolStageServiceImplTest.kt | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) 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 7eaaf15cd..8a077f465 100644 --- a/review/src/main/kotlin/br/all/application/protocol/find/FindProtocolStageServiceImpl.kt +++ b/review/src/main/kotlin/br/all/application/protocol/find/FindProtocolStageServiceImpl.kt @@ -70,7 +70,7 @@ class FindProtocolStageServiceImpl( extractionQuestionsCount: Int ): ProtocolStage { return when { - !isT1Complete(studyDto, protocolDto) -> ProtocolStage.GENERAL_DEFINITION + !isT1Complete(studyDto) -> ProtocolStage.GENERAL_DEFINITION !isT2Complete(protocolDto) -> ProtocolStage.RESEARCH_QUESTIONS isPicocStarted(protocolDto) && !isT3Complete(protocolDto) -> ProtocolStage.PICOC @@ -89,10 +89,9 @@ class FindProtocolStageServiceImpl( } - 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 isT1Complete(studyDto: SystematicStudyDto): Boolean { + val isStudyInfoComplete = studyDto.title.isNotBlank() && studyDto.description.isNotBlank() && studyDto.objectives.isNotBlank() + return isStudyInfoComplete } private fun isT2Complete(dto: ProtocolDto): Boolean { 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 1f2ee41c0..07759b9c6 100644 --- a/review/src/test/kotlin/br/all/application/protocol/find/FindProtocolStageServiceImplTest.kt +++ b/review/src/test/kotlin/br/all/application/protocol/find/FindProtocolStageServiceImplTest.kt @@ -108,6 +108,20 @@ class FindProtocolStageServiceImplTest { verify(exactly = 1) { presenter.prepareSuccessView(expectedResponse) } } + @Test + fun `should return GENERAL_DEFINITION stage (T1) when systematic study objectives is blank`() { + val incompleteStudyDto = createFullSystematicStudyDto(objectives = "") + val protocolDto = protocolFactory.protocolDto(systematicStudy = systematicStudyId) + + every { systematicStudyRepository.findById(systematicStudyId) } returns incompleteStudyDto + every { protocolRepository.findById(systematicStudyId) } returns protocolDto + + sut.getStage(presenter, 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()) From 0342bbef03b774ab8becbb87852821cddb5df9b5 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Sat, 13 Sep 2025 19:39:38 -0300 Subject: [PATCH 084/120] refactor(converters): improve validation and error handling for BibTeX and RIS parsing --- .../domain/services/BibtexConverterService.kt | 45 +++++++++++++------ .../domain/services/RisConverterService.kt | 43 +++++++++++++----- 2 files changed, 65 insertions(+), 23 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 02b3e7b78..131e0d7dd 100644 --- a/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt +++ b/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt @@ -71,8 +71,9 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera val study = convert(entry) validStudies.add(study) } catch (e: Exception) { - val entryName = extractEntryName(entry) - invalidEntries.add(entryName) + val entryKey = extractEntryKey(entry) + val reason = e.message ?: "Invalid entry" + invalidEntries.add("Entry '$entryKey': $reason") } } return Pair(validStudies, invalidEntries) @@ -83,18 +84,31 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera val fieldMap = parseBibtexFields(bibtexEntry) - val title = fieldMap["title"] ?: "" - val year = fieldMap["year"]?.toIntOrNull() ?: 0 - val authors = getValueFromFieldMap(fieldMap, authorTypes) - val venue = getValueFromFieldMap(fieldMap, venueTypes) - val abstract = fieldMap["abstract"] ?: " " + val title = fieldMap["title"]?.trim() ?: "" + if (title.isBlank()) throw IllegalArgumentException("Missing or invalid field 'title'") + + val yearStr = fieldMap["year"]?.trim() + val year = yearStr?.toIntOrNull() + ?: throw IllegalArgumentException("Missing or invalid field 'year': '${yearStr ?: "null"}'") + + val authors = getValueFromFieldMap(fieldMap, authorTypes).trim() + if (authors.isBlank()) throw IllegalArgumentException("Missing or invalid field 'author/authors/editor'") + + val venue = getValueFromFieldMap(fieldMap, venueTypes).trim() + if (venue.isBlank()) throw IllegalArgumentException("Missing or invalid field 'venue' (journal/booktitle/institution/organization/publisher/series/school/howpublished)") + + val abstract = (fieldMap["abstract"] ?: "").trim() + if (abstract.isBlank()) throw IllegalArgumentException("Missing or invalid field 'abstract'") + val keywords = parseKeywords(fieldMap["keywords"] ?: fieldMap["keyword"]) val references = parseReferences(fieldMap["references"]) val doi = fieldMap["doi"]?.let { val cleanDoi = it.replace(Regex("[{}]"), "").trim() + if (cleanDoi.isBlank()) throw IllegalArgumentException("Invalid DOI: empty value") val fullUrl = if (cleanDoi.startsWith("http")) { cleanDoi } else { + if (!cleanDoi.startsWith("10.")) throw IllegalArgumentException("Invalid DOI '$cleanDoi'") "https://doi.org/$cleanDoi" } Doi(fullUrl) @@ -142,15 +156,20 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera } private fun extractStudyType(bibtexEntry: String): StudyType { - val entryTypeRegex = Regex("""\b(\w+)\{""") + val entryTypeRegex = Regex("""^\s*(\w+)\s*\{""") val matchResult = entryTypeRegex.find(bibtexEntry) - val studyTypeName = matchResult?.groupValues?.get(1)?.uppercase(Locale.getDefault()) ?: "UNKNOWN" - return StudyType.valueOf(studyTypeName) + val rawType = matchResult?.groupValues?.get(1) ?: "" + val studyTypeName = rawType.uppercase(Locale.getDefault()) + return try { + StudyType.valueOf(studyTypeName) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Invalid or unsupported type '$rawType'") + } } - private fun extractEntryName(bibtexEntry: String): String { - val nameRegex = Regex("""\{(.*)}""", RegexOption.DOT_MATCHES_ALL) - val matchResult = nameRegex.find(bibtexEntry) + private fun extractEntryKey(bibtexEntry: String): String { + val keyRegex = Regex("""^\s*\w+\s*\{\s*([^,}]+)\s*,""", RegexOption.DOT_MATCHES_ALL) + val matchResult = keyRegex.find(bibtexEntry) return matchResult?.groupValues?.get(1)?.trim() ?: "UNKNOWN" } } \ No newline at end of file diff --git a/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt b/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt index 25b74c881..a424c50be 100644 --- a/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt +++ b/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt @@ -65,7 +65,8 @@ class RisConverterService(private val studyReviewIdGeneratorService: IdGenerator validStudies.add(study) } catch (e: Exception) { val entryName = extractInvalidRis(entry) - invalidEntries.add(entryName) + val reason = e.message ?: "Invalid entry" + invalidEntries.add("Entry '${summarizeRisEntry(entry)}': $reason") } } return Pair(validStudies, invalidEntries) @@ -75,20 +76,33 @@ class RisConverterService(private val studyReviewIdGeneratorService: IdGenerator require(ris.isNotBlank()) { "convert: RIS must not be blank." } val fieldMap = parseRisFields(ris) - val venue = fieldMap["JO"] ?: "" - val primaryTitle = getValueFromFieldMap(fieldMap, titleTypes) - val secondaryTitle = fieldMap["T2"] ?: "" - val year = fieldMap["PY"]?.toIntOrNull() + val venue = fieldMap["JO"]?.trim() ?: "" + if (venue.isBlank()) throw IllegalArgumentException("Missing or invalid field 'venue' (JO)") + + val primaryTitle = getValueFromFieldMap(fieldMap, titleTypes).trim() + val secondaryTitle = fieldMap["T2"]?.trim() ?: "" + val fullTitle = ("$primaryTitle $secondaryTitle").trim() + if (fullTitle.isBlank()) throw IllegalArgumentException("Missing or invalid field 'title' (TI/T1 or T2)") + + val year = fieldMap["PY"]?.trim()?.toIntOrNull() ?: fieldMap["Y1"]?.let { extractYear(it) } - ?: 0 - val authors = parseAuthors(fieldMap) + ?: throw IllegalArgumentException("Missing or invalid field 'year' (PY/Y1)") + + val authors = parseAuthors(fieldMap).trim() + if (authors.isBlank()) throw IllegalArgumentException("Missing or invalid field 'authors' (AU/A1)") val type = extractStudyType(ris) - val abs = fieldMap["AB"] ?: "" + val abs = fieldMap["AB"]?.trim() ?: "" + if (abs.isBlank()) throw IllegalArgumentException("Missing or invalid field 'abstract' (AB)") val keywords = parseKeywords(fieldMap["KW"]) val references = parseReferences(fieldMap["CR"]) - val doi = fieldMap["DO"]?.let { Doi("https://doi.org/$it") } + val doi = fieldMap["DO"]?.let { + val clean = it.trim() + if (clean.isBlank()) throw IllegalArgumentException("Invalid DOI: empty value") + if (!clean.startsWith("10.")) throw IllegalArgumentException("Invalid DOI '$clean'") + Doi("https://doi.org/$clean") + } - return Study(type, ("$primaryTitle $secondaryTitle").trim(), year, authors, venue, treatAbstract(abs), keywords, references, doi) + return Study(type, fullTitle, year, authors, venue, treatAbstract(abs), keywords, references, doi) } fun parseRisFields(ris: String): Map { @@ -233,4 +247,13 @@ class RisConverterService(private val studyReviewIdGeneratorService: IdGenerator val matchResult = risRegex.find(risEntry) return matchResult?.value?.trim() ?: "UNKNOWN" } + + private fun summarizeRisEntry(risEntry: String): String { + val map = parseRisFields(risEntry) + val ti = map["TI"] ?: map["T1"] ?: map["T2"] + if (!ti.isNullOrBlank()) return ti.trim().take(80) + val tyRegex = Regex("""(?m)^TY\s*-\s*(.+)$""") + val ty = tyRegex.find(risEntry)?.groupValues?.get(1)?.trim() + return ty ?: "UNKNOWN" + } } \ No newline at end of file From 0e2565c89863420177f5621cd5789c7bedc5eead Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Sat, 13 Sep 2025 20:08:07 -0300 Subject: [PATCH 085/120] refactor(bibtex-converter): enhance regex patterns for entry type and key extraction to allow "at" character --- .../kotlin/br/all/domain/services/BibtexConverterService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 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 131e0d7dd..2c6047f84 100644 --- a/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt +++ b/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt @@ -156,7 +156,7 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera } private fun extractStudyType(bibtexEntry: String): StudyType { - val entryTypeRegex = Regex("""^\s*(\w+)\s*\{""") + val entryTypeRegex = Regex("""^\s*@?\s*([A-Za-z]+)\s*\{""") val matchResult = entryTypeRegex.find(bibtexEntry) val rawType = matchResult?.groupValues?.get(1) ?: "" val studyTypeName = rawType.uppercase(Locale.getDefault()) @@ -168,7 +168,7 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera } private fun extractEntryKey(bibtexEntry: String): String { - val keyRegex = Regex("""^\s*\w+\s*\{\s*([^,}]+)\s*,""", RegexOption.DOT_MATCHES_ALL) + val keyRegex = Regex("""^\s*@?\s*\w+\s*\{\s*([^,}]+)\s*,""", RegexOption.DOT_MATCHES_ALL) val matchResult = keyRegex.find(bibtexEntry) return matchResult?.groupValues?.get(1)?.trim() ?: "UNKNOWN" } From 6a962ea1ace4b8d2faebb8efe7e47f8ebc6a6ef9 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Sat, 13 Sep 2025 20:14:42 -0300 Subject: [PATCH 086/120] refactor(bib-converter): revert exception handling for nullable allowed fields --- .../domain/services/BibtexConverterService.kt | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 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 2c6047f84..936c39e11 100644 --- a/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt +++ b/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt @@ -84,22 +84,11 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera val fieldMap = parseBibtexFields(bibtexEntry) - val title = fieldMap["title"]?.trim() ?: "" - if (title.isBlank()) throw IllegalArgumentException("Missing or invalid field 'title'") - - val yearStr = fieldMap["year"]?.trim() - val year = yearStr?.toIntOrNull() - ?: throw IllegalArgumentException("Missing or invalid field 'year': '${yearStr ?: "null"}'") - - val authors = getValueFromFieldMap(fieldMap, authorTypes).trim() - if (authors.isBlank()) throw IllegalArgumentException("Missing or invalid field 'author/authors/editor'") - - val venue = getValueFromFieldMap(fieldMap, venueTypes).trim() - if (venue.isBlank()) throw IllegalArgumentException("Missing or invalid field 'venue' (journal/booktitle/institution/organization/publisher/series/school/howpublished)") - - val abstract = (fieldMap["abstract"] ?: "").trim() - if (abstract.isBlank()) throw IllegalArgumentException("Missing or invalid field 'abstract'") - + val title = fieldMap["title"] ?: "" + val year = fieldMap["year"]?.toIntOrNull() ?: 0 + val authors = getValueFromFieldMap(fieldMap, authorTypes) + val venue = getValueFromFieldMap(fieldMap, venueTypes) + val abstract = fieldMap["abstract"] ?: " " val keywords = parseKeywords(fieldMap["keywords"] ?: fieldMap["keyword"]) val references = parseReferences(fieldMap["references"]) val doi = fieldMap["doi"]?.let { From d14c25b08a79a26d30bab9c55f31f73f914b2aa3 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 16 Sep 2025 12:31:19 -0300 Subject: [PATCH 087/120] feat: add specific exceptions for bib service --- .../domain/shared/exception/bibtex/BibtexParseException.kt | 3 +++ .../shared/exception/bibtex/InvalidFieldFormatException.kt | 4 ++++ .../domain/shared/exception/bibtex/MalformedEntryException.kt | 4 ++++ .../shared/exception/bibtex/MissingRequiredFieldException.kt | 4 ++++ .../shared/exception/bibtex/UnknownEntryTypeException.kt | 4 ++++ 5 files changed, 19 insertions(+) create mode 100644 shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexParseException.kt create mode 100644 shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/InvalidFieldFormatException.kt create mode 100644 shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/MalformedEntryException.kt create mode 100644 shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/MissingRequiredFieldException.kt create mode 100644 shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/UnknownEntryTypeException.kt diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexParseException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexParseException.kt new file mode 100644 index 000000000..4f6108004 --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexParseException.kt @@ -0,0 +1,3 @@ +package br.all.domain.shared.exception.bibtex + +sealed class BibtexParseException(message: String) : RuntimeException(message) \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/InvalidFieldFormatException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/InvalidFieldFormatException.kt new file mode 100644 index 000000000..95b25e697 --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/InvalidFieldFormatException.kt @@ -0,0 +1,4 @@ +package br.all.domain.shared.exception.bibtex + +class InvalidFieldFormatException(val fieldName: String, val value: String, val expectedFormat: String) : + BibtexParseException("Invalid format for field '$fieldName'. Expected $expectedFormat, but got '$value'.") \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/MalformedEntryException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/MalformedEntryException.kt new file mode 100644 index 000000000..f5ad73030 --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/MalformedEntryException.kt @@ -0,0 +1,4 @@ +package br.all.domain.shared.exception.bibtex + +class MalformedEntryException(reason: String) : + BibtexParseException("Malformed BibTeX entry: $reason") \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/MissingRequiredFieldException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/MissingRequiredFieldException.kt new file mode 100644 index 000000000..968df0ac4 --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/MissingRequiredFieldException.kt @@ -0,0 +1,4 @@ +package br.all.domain.shared.exception.bibtex + +class MissingRequiredFieldException(val fieldName: String) : + BibtexParseException("Missing required field: '$fieldName'") \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/UnknownEntryTypeException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/UnknownEntryTypeException.kt new file mode 100644 index 000000000..32163845d --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/UnknownEntryTypeException.kt @@ -0,0 +1,4 @@ +package br.all.domain.shared.exception.bibtex + +class UnknownEntryTypeException(val typeName: String) : + BibtexParseException("Unknown BibTeX entry type: '$typeName'") \ No newline at end of file From 2a5ccd53e6fb35bcf1360efcf2e6aec7e0c5fb44 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 16 Sep 2025 12:56:18 -0300 Subject: [PATCH 088/120] refactor(bibtex-service): improve field parsing and add detailed error handling for invalid BibTeX entries --- .../domain/services/BibtexConverterService.kt | 114 ++++++++++-------- 1 file changed, 62 insertions(+), 52 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 936c39e11..8a31628a6 100644 --- a/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt +++ b/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt @@ -10,13 +10,16 @@ import br.all.domain.model.study.Study import br.all.domain.model.study.StudyReview import br.all.domain.model.study.StudyReviewId import br.all.domain.model.study.StudyType +import br.all.domain.shared.exception.bibtex.BibtexParseException +import br.all.domain.shared.exception.bibtex.MissingRequiredFieldException +import br.all.domain.shared.exception.bibtex.InvalidFieldFormatException +import br.all.domain.shared.exception.bibtex.UnknownEntryTypeException import java.util.Locale class BibtexConverterService(private val studyReviewIdGeneratorService: IdGeneratorService) { private val authorTypes = listOf("author", "authors", "editor") - private val venueTypes = listOf("journal", "booktitle", "institution", - "organization", "publisher", "series", "school", "howpublished") + private val venueTypes = listOf("journal", "booktitle", "institution", "organization", "publisher", "series", "school", "howpublished") fun convertManyToStudyReview( systematicStudyId: SystematicStudyId, @@ -34,7 +37,6 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera fun convertToStudyReview(systematicStudyId: SystematicStudyId, searchSessionId: SearchSessionID, study: Study, source: MutableSet): StudyReview { val studyReviewId = StudyReviewId(studyReviewIdGeneratorService.next()) - return StudyReview( studyReviewId, systematicStudyId, @@ -63,17 +65,18 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera val validStudies = mutableListOf() val invalidEntries = mutableListOf() - bibtex.splitToSequence("@") + bibtex.split(Regex("(?=\\s*@)")) .map { it.trim() } .filter { it.isNotBlank() } .forEach { entry -> + val entryKey = extractEntryKey(entry) try { val study = convert(entry) validStudies.add(study) + } catch (e: BibtexParseException) { + invalidEntries.add("Entry '$entryKey': ${e.message}") } catch (e: Exception) { - val entryKey = extractEntryKey(entry) - val reason = e.message ?: "Invalid entry" - invalidEntries.add("Entry '$entryKey': $reason") + invalidEntries.add("Entry '$entryKey': An unexpected error occurred during parsing. Details: ${e.message}") } } return Pair(validStudies, invalidEntries) @@ -82,83 +85,90 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera fun convert(bibtexEntry: String): Study { require(bibtexEntry.isNotBlank()) { "BibTeX entry must not be blank." } + val type = try { + extractStudyType(bibtexEntry) + } catch (e: IllegalArgumentException) { + val entryTypeName = bibtexEntry.substringBefore('{').trim().removePrefix("@") + throw UnknownEntryTypeException(entryTypeName.ifBlank { "N/A" }) + } + val fieldMap = parseBibtexFields(bibtexEntry) - val title = fieldMap["title"] ?: "" - val year = fieldMap["year"]?.toIntOrNull() ?: 0 - val authors = getValueFromFieldMap(fieldMap, authorTypes) - val venue = getValueFromFieldMap(fieldMap, venueTypes) - val abstract = fieldMap["abstract"] ?: " " + val title = fieldMap["title"]?.takeIf { it.isNotBlank() } + ?: throw MissingRequiredFieldException("title") + + val yearString = fieldMap["year"] + ?: throw MissingRequiredFieldException("year") + + val year = yearString.toIntOrNull() + ?: throw InvalidFieldFormatException("year", yearString, "an integer") + + val authors = getValueFromFieldMap(fieldMap, authorTypes).takeIf { it.isNotBlank() } + ?: throw MissingRequiredFieldException(authorTypes.joinToString(" or ")) + + val venue = getValueFromFieldMap(fieldMap, venueTypes).takeIf { it.isNotBlank() } + ?: throw MissingRequiredFieldException(venueTypes.joinToString(" or ")) + + val abstract = fieldMap["abstract"] ?: "" val keywords = parseKeywords(fieldMap["keywords"] ?: fieldMap["keyword"]) val references = parseReferences(fieldMap["references"]) + val doi = fieldMap["doi"]?.let { - val cleanDoi = it.replace(Regex("[{}]"), "").trim() - if (cleanDoi.isBlank()) throw IllegalArgumentException("Invalid DOI: empty value") - val fullUrl = if (cleanDoi.startsWith("http")) { - cleanDoi - } else { - if (!cleanDoi.startsWith("10.")) throw IllegalArgumentException("Invalid DOI '$cleanDoi'") - "https://doi.org/$cleanDoi" + try { + val cleanDoi = it.replace(Regex("[{}]"), "").trim() + val fullUrl = if (cleanDoi.startsWith("http")) cleanDoi else "https://doi.org/$cleanDoi" + Doi(fullUrl) + } catch (e: Exception) { + throw InvalidFieldFormatException("doi", it, "a valid DOI string or URL") } - Doi(fullUrl) } - val type = extractStudyType(bibtexEntry) return Study(type, title, year, authors, venue, abstract, keywords, references, doi) } private fun parseBibtexFields(bibtexEntry: String): Map { - val entry = bibtexEntry.replace("\n\t", " ") - val fields = entry.trim().split("\n").map { it.trim() } - val fieldMap = mutableMapOf() + val content = bibtexEntry.substringAfter('{', "").substringBeforeLast('}', "") + if (content.isBlank()) { + return emptyMap() + } - for (field in fields) { - val keyValuePair = field.split("=") - if (keyValuePair.size == 2) { - val key = keyValuePair[0].trim() - val value = cleanStringSurroundings(keyValuePair[1]) + val fieldMap = mutableMapOf() + val fieldSplitRegex = Regex(""",\s*(?=\w+\s*=)""") + + content.split(fieldSplitRegex).forEach { fieldString -> + val parts = fieldString.trim().split("=", limit = 2) + if (parts.size == 2) { + val key = parts[0].trim().lowercase(Locale.getDefault()) + val value = parts[1].trim() + .removeSurrounding("{", "}") + .removeSurrounding("\"", "\"") fieldMap[key] = value } } return fieldMap } - private fun cleanStringSurroundings(value: String): String { - return value.trim().removePrefix("{").removeSuffix("}") - .removeSuffix("},") - } - private fun getValueFromFieldMap(fieldMap: Map, keys: List): String { - for (key in keys) { - val value = fieldMap[key] - if (value != null) return value - } - return "" + return keys.firstNotNullOfOrNull { key -> fieldMap[key] } ?: "" } private fun parseKeywords(keywords: String?): Set { - return keywords?.split(",", ";")?.map { it.trim() }?.toSet() ?: emptySet() + return keywords?.split("[,;]".toRegex())?.map { it.trim() }?.filter { it.isNotEmpty() }?.toSet() ?: emptySet() } private fun parseReferences(references: String?): List { - return references?.split(",")?.map { it.trim() } ?: emptyList() + return references?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList() } private fun extractStudyType(bibtexEntry: String): StudyType { - val entryTypeRegex = Regex("""^\s*@?\s*([A-Za-z]+)\s*\{""") + val entryTypeRegex = Regex("""@(\w+)\s*\{""") val matchResult = entryTypeRegex.find(bibtexEntry) - val rawType = matchResult?.groupValues?.get(1) ?: "" - val studyTypeName = rawType.uppercase(Locale.getDefault()) - return try { - StudyType.valueOf(studyTypeName) - } catch (e: IllegalArgumentException) { - throw IllegalArgumentException("Invalid or unsupported type '$rawType'") - } + val studyTypeName = matchResult?.groupValues?.get(1)?.uppercase(Locale.getDefault()) ?: "UNKNOWN" + return StudyType.valueOf(studyTypeName) } private fun extractEntryKey(bibtexEntry: String): String { - val keyRegex = Regex("""^\s*@?\s*\w+\s*\{\s*([^,}]+)\s*,""", RegexOption.DOT_MATCHES_ALL) - val matchResult = keyRegex.find(bibtexEntry) - return matchResult?.groupValues?.get(1)?.trim() ?: "UNKNOWN" + val keyRegex = Regex("""@\w+\s*\{(.*?)\s*,""", RegexOption.DOT_MATCHES_ALL) + return keyRegex.find(bibtexEntry)?.groupValues?.get(1)?.trim() ?: "UNKNOWN" } } \ No newline at end of file From b7ee07023b62755a59d47311f9874745f9e3f862 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 16 Sep 2025 12:56:33 -0300 Subject: [PATCH 089/120] test(bibtex-service): replace IllegalArgumentException with BibtexParseException in tests and disable abstract validation test --- .../services/BibtexConverterServiceTest.kt | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/review/src/test/kotlin/br/all/domain/services/BibtexConverterServiceTest.kt b/review/src/test/kotlin/br/all/domain/services/BibtexConverterServiceTest.kt index 7b226fa5d..8504d9871 100644 --- a/review/src/test/kotlin/br/all/domain/services/BibtexConverterServiceTest.kt +++ b/review/src/test/kotlin/br/all/domain/services/BibtexConverterServiceTest.kt @@ -3,6 +3,7 @@ package br.all.domain.services import br.all.domain.model.review.SystematicStudyId import br.all.domain.model.search.SearchSessionID import br.all.domain.model.study.* +import br.all.domain.shared.exception.bibtex.BibtexParseException import org.junit.jupiter.api.* import java.util.* import kotlin.test.assertEquals @@ -439,57 +440,59 @@ class BibtexConverterServiceTest { } @Test - fun `should throw IllegalArgumentException for unknown type entry`() { + fun `should throw BibtexParseException for unknown type entry`() { val bibtex = BibtexTestData.testInputs["unknown type of bibtex"]!! - assertThrows { + assertThrows { sut.convert(bibtex) } } @Test - fun `should throw IllegalArgumentException for invalid title entry`() { + fun `should throw BibtexParseException for invalid title entry`() { val bibtex = BibtexTestData.testInputs["invalid title"]!! - assertThrows { + assertThrows { sut.convert(bibtex) } } @Test - fun `should throw IllegalArgumentException for invalid author entry`() { + fun `should throw BibtexParseException for invalid author entry`() { val bibtex = BibtexTestData.testInputs["invalid authors"]!! - assertThrows { + assertThrows { sut.convert(bibtex) } } @Test - fun `should throw IllegalArgumentException for invalid year entry`() { + fun `should throw BibtexParseException for invalid year entry`() { val bibtex = BibtexTestData.testInputs["invalid year"]!! - assertThrows { + assertThrows { sut.convert(bibtex) } } @Test - fun `should throw IllegalArgumentException for invalid venue entry`() { + fun `should throw BibtexParseException for invalid venue entry`() { val bibtex = BibtexTestData.testInputs["invalid venue"]!! - assertThrows { + assertThrows { sut.convert(bibtex) } } + // an empty abstract is valid! @Test - fun `should throw IllegalArgumentException for invalid abstract entry`() { + @Disabled + fun `should throw BibtexParseException for invalid abstract entry`() { val bibtex = BibtexTestData.testInputs["invalid abstract"]!! - assertThrows { + assertThrows { sut.convert(bibtex) } } @Test - fun `should throw IllegalArgumentException for invalid doi`() { + fun `should throw BibtexParseException for invalid doi`() { val bibtex = BibtexTestData.testInputs["invalid doi"]!! - assertThrows { + assertThrows { sut.convert(bibtex) } } @@ -510,10 +513,8 @@ class BibtexConverterServiceTest { println("Valid StudyReview: ${studyReview.title}") } assertAll( - {assertEquals(4, studyReviewList.first.size)}, - + {assertEquals(3, studyReviewList.first.size)}, ) - } } } \ No newline at end of file From abaa2d8977acf81e296337bb2af68eb0781043ad Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 16 Sep 2025 12:56:53 -0300 Subject: [PATCH 090/120] test(bibtex-testdata): add missing commas to BibTeX test data entries --- .../test/kotlin/br/all/domain/services/BibtexTestData.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt b/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt index cb78fa080..e1e14e1e9 100644 --- a/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt +++ b/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt @@ -164,7 +164,7 @@ object BibtexTestData { school = {Stanford University}, address = {Stanford, CA}, year = {1956}, - month = {jun} + month = {jun}, abstract = {Lorem Ipsum} } """, @@ -176,7 +176,7 @@ object BibtexTestData { school = {Massachusetts Institute of Technology}, year = {1996}, address = {Cambridge, MA}, - month = {sep} + month = {sep}, abstract = {Lorem Ipsum} } """, @@ -189,7 +189,7 @@ object BibtexTestData { year = {2016}, publisher = {Pearson}, address = {New York, NY}, - pages = {187--221} + pages = {187--221}, abstract = {Lorem Ipsum} } """, @@ -222,7 +222,7 @@ object BibtexTestData { author = {{NASA}}, howpublished = {\url{https://www.nasa.gov/nh/pluto-the-other-red-planet}}, year = {2015}, - note = {Accessed: 2018-12-06} + note = {Accessed: 2018-12-06}, abstract = {Lorem Ipsum} } """, From ebdaf05c879bc2f4b29d88231e824da921280711 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 16 Sep 2025 13:06:37 -0300 Subject: [PATCH 091/120] feat(ris-parser): add specific exceptions for RIS parsing --- .../shared/exception/ris/RisInvalidFieldFormatException.kt | 4 ++++ .../shared/exception/ris/RisMissingRequiredFieldException.kt | 4 ++++ .../br/all/domain/shared/exception/ris/RisParseException.kt | 3 +++ .../shared/exception/ris/RisUnknownEntryTypeException.kt | 4 ++++ 4 files changed, 15 insertions(+) create mode 100644 shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisInvalidFieldFormatException.kt create mode 100644 shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisMissingRequiredFieldException.kt create mode 100644 shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisParseException.kt create mode 100644 shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisUnknownEntryTypeException.kt diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisInvalidFieldFormatException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisInvalidFieldFormatException.kt new file mode 100644 index 000000000..0296231f5 --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisInvalidFieldFormatException.kt @@ -0,0 +1,4 @@ +package br.all.domain.shared.exception.ris + +class RisInvalidFieldFormatException(val fieldName: String, val value: String, val expectedFormat: String) : + RisParseException("Invalid format for field '$fieldName'. Expected $expectedFormat, but got '$value'.") \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisMissingRequiredFieldException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisMissingRequiredFieldException.kt new file mode 100644 index 000000000..30d1f7334 --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisMissingRequiredFieldException.kt @@ -0,0 +1,4 @@ +package br.all.domain.shared.exception.ris + +class RisMissingRequiredFieldException(val fieldName: String) : + RisParseException("Missing required field: '$fieldName'") \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisParseException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisParseException.kt new file mode 100644 index 000000000..d7313a399 --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisParseException.kt @@ -0,0 +1,3 @@ +package br.all.domain.shared.exception.ris + +sealed class RisParseException(message: String) : RuntimeException(message) \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisUnknownEntryTypeException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisUnknownEntryTypeException.kt new file mode 100644 index 000000000..cfdbae441 --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/ris/RisUnknownEntryTypeException.kt @@ -0,0 +1,4 @@ +package br.all.domain.shared.exception.ris + +class RisUnknownEntryTypeException(val typeName: String) : + RisParseException("Unknown or unsupported RIS entry type: '$typeName'") \ No newline at end of file From 98ace19faf1afe40072f6d35e35c69579c392b3c Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 16 Sep 2025 13:07:27 -0300 Subject: [PATCH 092/120] refactor(bibtex-exceptions): rename exception classes for consistency with BibTeX naming conventions --- .../domain/services/BibtexConverterService.kt | 20 +++++++++---------- ...t => BibtexInvalidFieldFormatException.kt} | 2 +- ...on.kt => BibtexMalformedEntryException.kt} | 2 +- ...=> BibtexMissingRequiredFieldException.kt} | 2 +- ....kt => BibtexUnknownEntryTypeException.kt} | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) rename shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/{InvalidFieldFormatException.kt => BibtexInvalidFieldFormatException.kt} (58%) rename shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/{MalformedEntryException.kt => BibtexMalformedEntryException.kt} (66%) rename shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/{MissingRequiredFieldException.kt => BibtexMissingRequiredFieldException.kt} (62%) rename shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/{UnknownEntryTypeException.kt => BibtexUnknownEntryTypeException.kt} (64%) 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 8a31628a6..e9ba8785d 100644 --- a/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt +++ b/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt @@ -11,9 +11,9 @@ import br.all.domain.model.study.StudyReview import br.all.domain.model.study.StudyReviewId import br.all.domain.model.study.StudyType import br.all.domain.shared.exception.bibtex.BibtexParseException -import br.all.domain.shared.exception.bibtex.MissingRequiredFieldException -import br.all.domain.shared.exception.bibtex.InvalidFieldFormatException -import br.all.domain.shared.exception.bibtex.UnknownEntryTypeException +import br.all.domain.shared.exception.bibtex.BibtexMissingRequiredFieldException +import br.all.domain.shared.exception.bibtex.BibtexInvalidFieldFormatException +import br.all.domain.shared.exception.bibtex.BibtexUnknownEntryTypeException import java.util.Locale class BibtexConverterService(private val studyReviewIdGeneratorService: IdGeneratorService) { @@ -89,25 +89,25 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera extractStudyType(bibtexEntry) } catch (e: IllegalArgumentException) { val entryTypeName = bibtexEntry.substringBefore('{').trim().removePrefix("@") - throw UnknownEntryTypeException(entryTypeName.ifBlank { "N/A" }) + throw BibtexUnknownEntryTypeException(entryTypeName.ifBlank { "N/A" }) } val fieldMap = parseBibtexFields(bibtexEntry) val title = fieldMap["title"]?.takeIf { it.isNotBlank() } - ?: throw MissingRequiredFieldException("title") + ?: throw BibtexMissingRequiredFieldException("title") val yearString = fieldMap["year"] - ?: throw MissingRequiredFieldException("year") + ?: throw BibtexMissingRequiredFieldException("year") val year = yearString.toIntOrNull() - ?: throw InvalidFieldFormatException("year", yearString, "an integer") + ?: throw BibtexInvalidFieldFormatException("year", yearString, "an integer") val authors = getValueFromFieldMap(fieldMap, authorTypes).takeIf { it.isNotBlank() } - ?: throw MissingRequiredFieldException(authorTypes.joinToString(" or ")) + ?: throw BibtexMissingRequiredFieldException(authorTypes.joinToString(" or ")) val venue = getValueFromFieldMap(fieldMap, venueTypes).takeIf { it.isNotBlank() } - ?: throw MissingRequiredFieldException(venueTypes.joinToString(" or ")) + ?: throw BibtexMissingRequiredFieldException(venueTypes.joinToString(" or ")) val abstract = fieldMap["abstract"] ?: "" val keywords = parseKeywords(fieldMap["keywords"] ?: fieldMap["keyword"]) @@ -119,7 +119,7 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera val fullUrl = if (cleanDoi.startsWith("http")) cleanDoi else "https://doi.org/$cleanDoi" Doi(fullUrl) } catch (e: Exception) { - throw InvalidFieldFormatException("doi", it, "a valid DOI string or URL") + throw BibtexInvalidFieldFormatException("doi", it, "a valid DOI string or URL") } } diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/InvalidFieldFormatException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexInvalidFieldFormatException.kt similarity index 58% rename from shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/InvalidFieldFormatException.kt rename to shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexInvalidFieldFormatException.kt index 95b25e697..7254a342c 100644 --- a/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/InvalidFieldFormatException.kt +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexInvalidFieldFormatException.kt @@ -1,4 +1,4 @@ package br.all.domain.shared.exception.bibtex -class InvalidFieldFormatException(val fieldName: String, val value: String, val expectedFormat: String) : +class BibtexInvalidFieldFormatException(val fieldName: String, val value: String, val expectedFormat: String) : BibtexParseException("Invalid format for field '$fieldName'. Expected $expectedFormat, but got '$value'.") \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/MalformedEntryException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexMalformedEntryException.kt similarity index 66% rename from shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/MalformedEntryException.kt rename to shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexMalformedEntryException.kt index f5ad73030..ff96a774b 100644 --- a/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/MalformedEntryException.kt +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexMalformedEntryException.kt @@ -1,4 +1,4 @@ package br.all.domain.shared.exception.bibtex -class MalformedEntryException(reason: String) : +class BibtexMalformedEntryException(reason: String) : BibtexParseException("Malformed BibTeX entry: $reason") \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/MissingRequiredFieldException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexMissingRequiredFieldException.kt similarity index 62% rename from shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/MissingRequiredFieldException.kt rename to shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexMissingRequiredFieldException.kt index 968df0ac4..01393bc49 100644 --- a/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/MissingRequiredFieldException.kt +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexMissingRequiredFieldException.kt @@ -1,4 +1,4 @@ package br.all.domain.shared.exception.bibtex -class MissingRequiredFieldException(val fieldName: String) : +class BibtexMissingRequiredFieldException(val fieldName: String) : BibtexParseException("Missing required field: '$fieldName'") \ No newline at end of file diff --git a/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/UnknownEntryTypeException.kt b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexUnknownEntryTypeException.kt similarity index 64% rename from shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/UnknownEntryTypeException.kt rename to shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexUnknownEntryTypeException.kt index 32163845d..f42cef1d9 100644 --- a/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/UnknownEntryTypeException.kt +++ b/shared/src/main/kotlin/br/all/domain/shared/exception/bibtex/BibtexUnknownEntryTypeException.kt @@ -1,4 +1,4 @@ package br.all.domain.shared.exception.bibtex -class UnknownEntryTypeException(val typeName: String) : +class BibtexUnknownEntryTypeException(val typeName: String) : BibtexParseException("Unknown BibTeX entry type: '$typeName'") \ No newline at end of file From a03e3e412aeae690284a4d993599c109eb83b25c Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 16 Sep 2025 13:33:01 -0300 Subject: [PATCH 093/120] refactor(ris-converter-service): improve field parsing, error handling, and add specific RIS exceptions --- .../domain/services/RisConverterService.kt | 277 +++++++----------- 1 file changed, 107 insertions(+), 170 deletions(-) diff --git a/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt b/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt index a424c50be..78e007bff 100644 --- a/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt +++ b/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt @@ -3,10 +3,32 @@ package br.all.domain.services import br.all.domain.model.review.SystematicStudyId import br.all.domain.model.search.SearchSessionID import br.all.domain.model.study.* +import br.all.domain.shared.exception.ris.RisParseException +import br.all.domain.shared.exception.ris.RisMissingRequiredFieldException +import br.all.domain.shared.exception.ris.RisInvalidFieldFormatException +import br.all.domain.shared.exception.ris.RisUnknownEntryTypeException class RisConverterService(private val studyReviewIdGeneratorService: IdGeneratorService) { private val titleTypes = listOf("TI", "T1") + private val authorTypes = listOf("AU", "A1") + private val yearTypes = listOf("PY", "Y1") + private val venueTypes = listOf("JO", "T2", "JF", "JA") + + fun convertManyToStudyReview( + systematicStudyId: SystematicStudyId, + searchSessionId: SearchSessionID, + ris: String, + source: MutableSet, + ): Pair, List> { + require(ris.isNotBlank()) { "RIS input must not be blank." } + + val (validStudies, invalidEntries) = convertMany(ris) + val studyReviews = validStudies.map { study -> convertToStudyReview(systematicStudyId, searchSessionId, study, source) } + + return Pair(studyReviews, invalidEntries) + } + fun convertToStudyReview(systematicStudyId: SystematicStudyId, searchSessionId: SearchSessionID, study: Study, source: MutableSet): StudyReview { val studyReviewId = StudyReviewId(studyReviewIdGeneratorService.next()) @@ -34,226 +56,141 @@ class RisConverterService(private val studyReviewIdGeneratorService: IdGenerator ) } - fun convertManyToStudyReview( - systematicStudyId: SystematicStudyId, - searchSessionId: SearchSessionID, - ris: String, - source: MutableSet, - ): Pair, List> { - require(ris.isNotBlank()) { "convertManyToStudyReview: RIS must not be blank." } - - val (validStudies, invalidEntries) = convertMany(ris) - val studyReviews = validStudies.map { study -> convertToStudyReview(systematicStudyId, searchSessionId, study, source) } - - return Pair(studyReviews, invalidEntries) - } private fun convertMany(ris: String): Pair, List> { val validStudies = mutableListOf() val invalidEntries = mutableListOf() - val regex = Regex( - "(?i)\\bTY\\b\\s*-.*?(?=(\\bTY\\b\\s*-|\\bER\\b\\s*-|\\z))", - RegexOption.DOT_MATCHES_ALL - ) - - val entries = regex.findAll(ris).map { it.value.trim() } + val entryRegex = Regex("""(^TY\s*-.+?)(?=^\s*TY\s*-|\Z)""", setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL)) - entries.forEach { entry -> + entryRegex.findAll(ris).forEach { matchResult -> + val entry = matchResult.value.trim() + val entryIdentifier = extractTitleForError(entry) try { - val study = convert(entry) - validStudies.add(study) + if (entry.isNotBlank()) { + val study = convert(entry) + validStudies.add(study) + } + } catch (e: RisParseException) { + invalidEntries.add("Entry '$entryIdentifier': ${e.message}") } catch (e: Exception) { - val entryName = extractInvalidRis(entry) - val reason = e.message ?: "Invalid entry" - invalidEntries.add("Entry '${summarizeRisEntry(entry)}': $reason") + invalidEntries.add("Entry '$entryIdentifier': An unexpected error occurred. ${e.message}") } } return Pair(validStudies, invalidEntries) } fun convert(ris: String): Study { - require(ris.isNotBlank()) { "convert: RIS must not be blank." } + require(ris.isNotBlank()) { "RIS entry must not be blank." } val fieldMap = parseRisFields(ris) - val venue = fieldMap["JO"]?.trim() ?: "" - if (venue.isBlank()) throw IllegalArgumentException("Missing or invalid field 'venue' (JO)") - - val primaryTitle = getValueFromFieldMap(fieldMap, titleTypes).trim() - val secondaryTitle = fieldMap["T2"]?.trim() ?: "" - val fullTitle = ("$primaryTitle $secondaryTitle").trim() - if (fullTitle.isBlank()) throw IllegalArgumentException("Missing or invalid field 'title' (TI/T1 or T2)") - - val year = fieldMap["PY"]?.trim()?.toIntOrNull() - ?: fieldMap["Y1"]?.let { extractYear(it) } - ?: throw IllegalArgumentException("Missing or invalid field 'year' (PY/Y1)") - - val authors = parseAuthors(fieldMap).trim() - if (authors.isBlank()) throw IllegalArgumentException("Missing or invalid field 'authors' (AU/A1)") - val type = extractStudyType(ris) - val abs = fieldMap["AB"]?.trim() ?: "" - if (abs.isBlank()) throw IllegalArgumentException("Missing or invalid field 'abstract' (AB)") + + val type = extractStudyType(fieldMap) + + val primaryTitle = getValueFromFieldMap(fieldMap, titleTypes) + .takeIf { it.isNotBlank() } ?: throw RisMissingRequiredFieldException("Title (TI or T1)") + val secondaryTitle = fieldMap["T2"] ?: "" + val title = "$primaryTitle $secondaryTitle".trim() + + val year = extractYear(fieldMap) + ?: throw RisMissingRequiredFieldException("Year (PY or Y1)") + + val authors = parseAuthors(fieldMap) + .takeIf { it.isNotBlank() } ?: throw RisMissingRequiredFieldException("Author (AU or A1)") + + val venue = getValueFromFieldMap(fieldMap, venueTypes) + val abstract = fieldMap["AB"] ?: "" val keywords = parseKeywords(fieldMap["KW"]) val references = parseReferences(fieldMap["CR"]) + val doi = fieldMap["DO"]?.let { - val clean = it.trim() - if (clean.isBlank()) throw IllegalArgumentException("Invalid DOI: empty value") - if (!clean.startsWith("10.")) throw IllegalArgumentException("Invalid DOI '$clean'") - Doi("https://doi.org/$clean") + try { + Doi("https://doi.org/${it.trim()}") + } catch (e: Exception) { + throw RisInvalidFieldFormatException("DOI (DO)", it, "a valid DOI identifier") + } } - return Study(type, fullTitle, year, authors, venue, treatAbstract(abs), keywords, references, doi) + return Study(type, title, year, authors, venue, abstract, keywords, references, doi) } - fun parseRisFields(ris: String): Map { - val fieldMap = mutableMapOf() + private fun parseRisFields(ris: String): Map { + val multiMap = mutableMapOf>() val lines = ris.trim().lines() + val tagRegex = Regex("""^([A-Z0-9]{2})\s*-\s?(.*)""") var currentKey: String? = null for (line in lines) { val trimmedLine = line.trim() - if (trimmedLine.contains(" - ")) { - val keyValuePair = trimmedLine.split(" - ", limit = 2) - if (keyValuePair.size == 2) { - currentKey = keyValuePair[0].trim() - val value = keyValuePair[1].trim() - if (currentKey == "AU" || currentKey == "A1") { - fieldMap[currentKey] = fieldMap.getOrDefault(currentKey, "") + value + "; " - } else if (currentKey == "KW"){ - fieldMap[currentKey] = fieldMap.getOrDefault(currentKey, "") + value + "; " - } else { - fieldMap[currentKey] = value - } - } + if (trimmedLine.isBlank()) continue + + val match = tagRegex.find(trimmedLine) + if (match != null) { + currentKey = match.groupValues[1] + val value = match.groupValues[2].trim() + multiMap.getOrPut(currentKey) { mutableListOf() }.add(value) } else if (currentKey != null) { - fieldMap[currentKey] = "${fieldMap[currentKey]} $trimmedLine".trim() + val values = multiMap[currentKey] + if (values != null && values.isNotEmpty()) { + val lastIndex = values.lastIndex + values[lastIndex] = values[lastIndex] + " " + trimmedLine + } } } - return fieldMap + + return multiMap.mapValues { (_, valueList) -> + valueList.joinToString(";") + } } - fun extractStudyType(risEntry: String): StudyType { - val entryTypeRegex = Regex("""(?m)^TY\s*-\s*(.+)$""") - val matchResult = entryTypeRegex.find(risEntry) - val studyTypeName = translateToStudyType(matchResult?.groupValues?.get(1) ?: "") - return studyTypeName + private fun extractStudyType(fieldMap: Map): StudyType { + val typeCode = fieldMap["TY"] ?: throw RisMissingRequiredFieldException("Entry Type (TY)") + return translateToStudyType(typeCode) } - private fun translateToStudyType(studyType: String): StudyType { - require(studyType.isNotBlank()) { "translateToStudyType: studytype must not be blank." } - val risMap = mapOf( - "ABST" to StudyType.ARTICLE, - "ADVS" to StudyType.MISC, - "AGGR" to StudyType.MISC, - "ANCIENT" to StudyType.MISC, - "ART" to StudyType.MISC, - "BILL" to StudyType.MISC, - "BLOG" to StudyType.MISC, - "BOOK" to StudyType.BOOK, - "CASE" to StudyType.MISC, - "CHAP" to StudyType.INBOOK, - "CHART" to StudyType.MISC, - "CLSWK" to StudyType.MISC, - "COMP" to StudyType.MISC, - "CONF" to StudyType.PROCEEDINGS, - "CPAPER" to StudyType.INPROCEEDINGS, - "CTLG" to StudyType.MISC, - "DATA" to StudyType.MISC, - "DBASE" to StudyType.MISC, - "DICT" to StudyType.MISC, - "EBOOK" to StudyType.BOOK, - "ECHAP" to StudyType.INBOOK, - "EDBOOK" to StudyType.BOOK, - "EJOUR" to StudyType.ARTICLE, - "ELEC" to StudyType.MISC, - "ENCYC" to StudyType.MISC, - "EQUA" to StudyType.MISC, - "FIGURE" to StudyType.MISC, - "GEN" to StudyType.MISC, - "GOVDOC" to StudyType.MISC, - "GRNT" to StudyType.MISC, - "HEAR" to StudyType.MISC, - "ICOMM" to StudyType.MISC, - "INPR" to StudyType.ARTICLE, - "INTV" to StudyType.MISC, - "JFULL" to StudyType.ARTICLE, - "JOUR" to StudyType.ARTICLE, - "LEGAL" to StudyType.MISC, - "MANSCPT" to StudyType.UNPUBLISHED, - "MAP" to StudyType.MISC, - "MGZN" to StudyType.ARTICLE, - "MPCT" to StudyType.MISC, - "MULTI" to StudyType.MISC, - "MUSIC" to StudyType.MISC, - "NEWS" to StudyType.ARTICLE, - "PAMP" to StudyType.BOOKLET, - "PAT" to StudyType.MISC, - "PCOMM" to StudyType.MISC, - "POD" to StudyType.MISC, - "PRESS" to StudyType.MISC, - "RPRT" to StudyType.TECHREPORT, - "SER" to StudyType.MISC, - "SLIDE" to StudyType.MISC, - "SOUND" to StudyType.MISC, - "STAND" to StudyType.MISC, - "STAT" to StudyType.MISC, - "STD" to StudyType.MISC, - "THES" to StudyType.MASTERSTHESIS, - "UNBILL" to StudyType.MISC, - "UNPB" to StudyType.UNPUBLISHED, - "UNPD" to StudyType.UNPUBLISHED, - "VIDEO" to StudyType.MISC - ) - val st = risMap.getOrElse(studyType) { throw IllegalArgumentException() } - return st + private fun translateToStudyType(risType: String): StudyType { + return when (risType.trim().uppercase()) { + "JOUR", "EJOUR", "JFULL", "ABST", "INPR", "MGZN", "NEWS" -> StudyType.ARTICLE + "BOOK", "EBOOK", "EDBOOK" -> StudyType.BOOK + "CHAP", "ECHAP" -> StudyType.INBOOK + "CONF" -> StudyType.PROCEEDINGS + "CPAPER" -> StudyType.INPROCEEDINGS + "RPRT" -> StudyType.TECHREPORT + "THES" -> StudyType.MASTERSTHESIS + "PAMP" -> StudyType.BOOKLET + "MANSCPT", "UNPB", "UNPD" -> StudyType.UNPUBLISHED + "ADVS", "AGGR", "ANCIENT", "ART", "BILL", "BLOG", "CASE", "CHART", "CLSWK", "COMP", "CTLG", "DATA", "DBASE", "DICT", "ELEC", "ENCYC", "EQUA", "FIGURE", "GEN", "GOVDOC", "GRNT", "HEAR", "ICOMM", "INTV", "LEGAL", "MAP", "MPCT", "MULTI", "MUSIC", "PAT", "PCOMM", "POD", "PRESS", "SER", "SLIDE", "SOUND", "STAND", "STAT", "STD", "UNBILL", "VIDEO" -> StudyType.MISC + else -> throw RisUnknownEntryTypeException(risType) + } } private fun getValueFromFieldMap(fieldMap: Map, keys: List): String { - for (key in keys) { - val value = fieldMap[key] - if (value != null) return value - } - return "" + return keys.firstNotNullOfOrNull { key -> fieldMap[key] } ?: "" } - private fun extractYear(y1: String): Int? { - val yearRegex = Regex("""\b\d{4}\b""") - return yearRegex.find(y1)?.value?.toIntOrNull() + private fun extractYear(fieldMap: Map): Int? { + val yearString = getValueFromFieldMap(fieldMap, yearTypes) + return yearString.let { Regex("""\b(\d{4})\b""").find(it)?.value?.toIntOrNull() } } private fun parseKeywords(keywords: String?): Set { - return keywords?.split(";")?.map { it.trim() }?.filter { it.isNotBlank() }?.toSet()?: emptySet() + return keywords?.split(';')?.map { it.trim() }?.filter { it.isNotBlank() }?.toSet() ?: emptySet() } private fun parseReferences(references: String?): List { - return references?.split(";")?.map { it.trim() }?.filter { it.isNotBlank() }?.toList()?: emptyList() + return references?.split(';')?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList() } private fun parseAuthors(fieldMap: Map): String { - val authorKeys = listOf("AU", "A1") - val authors = authorKeys.flatMap { key -> - fieldMap[key]?.split(";")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList() - } - return authors.joinToString(", ") - } - - private fun treatAbstract(abstract: String): String { - val AB = abstract.split("ER").first().trim() - return AB - } - - private fun extractInvalidRis(risEntry: String): String { - val risRegex = Regex("""TY\s*-\s*[\s\S]*?ER\s*-\s*""", RegexOption.MULTILINE) - val matchResult = risRegex.find(risEntry) - return matchResult?.value?.trim() ?: "UNKNOWN" + val authors = getValueFromFieldMap(fieldMap, authorTypes) + return authors.split(';') + .map { it.trim() } + .filter { it.isNotBlank() } + .joinToString(separator = ", ") } - private fun summarizeRisEntry(risEntry: String): String { - val map = parseRisFields(risEntry) - val ti = map["TI"] ?: map["T1"] ?: map["T2"] - if (!ti.isNullOrBlank()) return ti.trim().take(80) - val tyRegex = Regex("""(?m)^TY\s*-\s*(.+)$""") - val ty = tyRegex.find(risEntry)?.groupValues?.get(1)?.trim() - return ty ?: "UNKNOWN" + private fun extractTitleForError(risEntry: String): String { + val titleRegex = Regex("""^[T1|I]\s*-\s*(.+)$""", RegexOption.MULTILINE) + return titleRegex.find(risEntry)?.groupValues?.get(1)?.trim() ?: "Unknown Title" } } \ No newline at end of file From d73648dc16c9bc32ac9bc8150bf1c96215368c56 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 16 Sep 2025 13:33:07 -0300 Subject: [PATCH 094/120] test(ris-converter-service): replace IllegalArgumentException with RisParseException in tests and disable abstract validation test --- .../services/RisConverterServiceTest.kt | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/review/src/test/kotlin/br/all/domain/services/RisConverterServiceTest.kt b/review/src/test/kotlin/br/all/domain/services/RisConverterServiceTest.kt index dc1eda98d..42cfd99b3 100644 --- a/review/src/test/kotlin/br/all/domain/services/RisConverterServiceTest.kt +++ b/review/src/test/kotlin/br/all/domain/services/RisConverterServiceTest.kt @@ -4,6 +4,7 @@ import br.all.domain.model.review.SystematicStudyId import br.all.domain.model.search.SearchSessionID import br.all.domain.model.study.* import br.all.domain.services.RisTestData.testInput +import br.all.domain.shared.exception.ris.RisParseException import org.junit.jupiter.api.* import java.util.* import kotlin.test.assertEquals @@ -202,13 +203,14 @@ class RisConverterServiceTest { @Test fun `Should create a StudyReview list from multiple RIS entries as input`() { val ris = testInput["multiple RIS entries"]!! - val studyReviewList = sut.convertManyToStudyReview( + val (validStudies, invalidEntries) = sut.convertManyToStudyReview( SystematicStudyId(UUID.randomUUID()), SearchSessionID(UUID.randomUUID()), ris, source = mutableSetOf("Compendex") ) - assertEquals(3, studyReviewList.first.size) + assertEquals(0, invalidEntries.size) + assertEquals(3, validStudies.size) } @Test @@ -526,9 +528,9 @@ class RisConverterServiceTest { } @Test - fun `should throw IllegalArgumentException for unknown type entry`() { + fun `should throw RisParseException for unknown type entry`() { val ris = testInput["unknown ris"]!! - assertThrows { + assertThrows { val study = sut.convert(ris) sut.convertToStudyReview( SystematicStudyId(UUID.randomUUID()), @@ -540,9 +542,9 @@ class RisConverterServiceTest { } @Test - fun `should throw IllegalArgumentException for invalid title entry`() { + fun `should throw RisParseException for invalid title entry`() { val ris = testInput["invalid title"].toString() - assertThrows { + assertThrows { val study = sut.convert(ris) sut.convertToStudyReview( SystematicStudyId(UUID.randomUUID()), @@ -554,9 +556,9 @@ class RisConverterServiceTest { } @Test - fun `should throw IllegalArgumentException for invalid author entry`() { + fun `should throw RisParseException for invalid author entry`() { val ris = testInput["invalid authors"]!! - assertThrows { + assertThrows { val study = sut.convert(ris) sut.convertToStudyReview( SystematicStudyId(UUID.randomUUID()), @@ -568,9 +570,9 @@ class RisConverterServiceTest { } @Test - fun `should throw IllegalArgumentException for invalid year entry`() { + fun `should throw RisParseException for invalid year entry`() { val ris = testInput["invalid year"]!! - assertThrows { + assertThrows { val study = sut.convert(ris) sut.convertToStudyReview( SystematicStudyId(UUID.randomUUID()), @@ -582,9 +584,9 @@ class RisConverterServiceTest { } @Test - fun `should throw IllegalArgumentException for invalid venue entry`() { + fun `should throw RisParseException for invalid venue entry`() { val ris = testInput["invalid venue"]!! - assertThrows { + assertThrows { val study = sut.convert(ris) sut.convertToStudyReview( SystematicStudyId(UUID.randomUUID()), @@ -595,10 +597,12 @@ class RisConverterServiceTest { } } + // an empty abstract is valid! @Test - fun `should throw IllegalArgumentException for invalid abstract entry`() { + @Disabled + fun `should throw RisParseException for invalid abstract entry`() { val ris = testInput["invalid abstract"]!! - assertThrows { + assertThrows { val study = sut.convert(ris) sut.convertToStudyReview( SystematicStudyId(UUID.randomUUID()), @@ -610,9 +614,9 @@ class RisConverterServiceTest { } @Test - fun `should throw IllegalArgumentException for invalid doi`() { + fun `should throw RisParseException for invalid doi`() { val ris = testInput["invalid doi"]!! - assertThrows { + assertThrows { val study = sut.convert(ris) sut.convertToStudyReview( SystematicStudyId(UUID.randomUUID()), From 7760239b14ac678c03e519f609052cc6f751fb1b Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 16 Sep 2025 18:01:20 -0300 Subject: [PATCH 095/120] refactor(ris-converter-service): fix regex patterns for entry and title extraction --- .../main/kotlin/br/all/domain/services/RisConverterService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt b/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt index 78e007bff..c3a916db1 100644 --- a/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt +++ b/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt @@ -61,7 +61,7 @@ class RisConverterService(private val studyReviewIdGeneratorService: IdGenerator val validStudies = mutableListOf() val invalidEntries = mutableListOf() - val entryRegex = Regex("""(^TY\s*-.+?)(?=^\s*TY\s*-|\Z)""", setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL)) + val entryRegex = Regex("""(^\s*TY\s*-.+?)(?=^\s*TY\s*-|\Z)""", setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL)) entryRegex.findAll(ris).forEach { matchResult -> val entry = matchResult.value.trim() @@ -190,7 +190,7 @@ class RisConverterService(private val studyReviewIdGeneratorService: IdGenerator } private fun extractTitleForError(risEntry: String): String { - val titleRegex = Regex("""^[T1|I]\s*-\s*(.+)$""", RegexOption.MULTILINE) + val titleRegex = Regex("""^(?:TI|T1)\s*-\s*(.+)$""", RegexOption.MULTILINE) return titleRegex.find(risEntry)?.groupValues?.get(1)?.trim() ?: "Unknown Title" } } \ No newline at end of file From e9be97e2e3450c06c1f0f589f0a30b98f0ee5726 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 16 Sep 2025 18:14:28 -0300 Subject: [PATCH 096/120] test(ris-testdata, bibtex-testdata): update test data with corrected field types and missing author information --- .../src/test/kotlin/br/all/domain/services/BibtexTestData.kt | 1 + review/src/test/kotlin/br/all/domain/services/RisTestData.kt | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt b/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt index e1e14e1e9..aab8c8df3 100644 --- a/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt +++ b/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt @@ -64,6 +64,7 @@ object BibtexTestData { @article{nash51, title = {Non-cooperative Games}, year = {1951}, + author = {Nash, John}, journal = {Annals of Mathematics}, abstract = {Lorem Ipsum}, keywords = {keyword1, keyword2}, diff --git a/review/src/test/kotlin/br/all/domain/services/RisTestData.kt b/review/src/test/kotlin/br/all/domain/services/RisTestData.kt index d12ac4a0c..cf563ad54 100644 --- a/review/src/test/kotlin/br/all/domain/services/RisTestData.kt +++ b/review/src/test/kotlin/br/all/domain/services/RisTestData.kt @@ -370,10 +370,12 @@ object RisTestData { ER - """, - "invalid doi" to """TY - JOUR + "invalid doi" to """TY - VIDEO + TI - Pluto: The 'Other' Red Planet AU - NASA PY - 2015 KW - KW + DO - JO - \url{https://www.nasa.gov/nh/pluto-the-other-red-planet} IS - 1 Y1 - Jan.-Feb. 2017 From aa10c2088416cf3d4be29c6fd25018cd4d96b7df Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 16 Sep 2025 18:14:36 -0300 Subject: [PATCH 097/120] test(ris, bibtex-converter-service): disable invalid DOI tests and allow null for invalid DOIs in parser --- .../kotlin/br/all/domain/services/BibtexConverterService.kt | 2 +- .../main/kotlin/br/all/domain/services/RisConverterService.kt | 2 +- .../kotlin/br/all/domain/services/BibtexConverterServiceTest.kt | 2 ++ .../kotlin/br/all/domain/services/RisConverterServiceTest.kt | 2 ++ 4 files changed, 6 insertions(+), 2 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 e9ba8785d..f77e9f0d2 100644 --- a/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt +++ b/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt @@ -119,7 +119,7 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera val fullUrl = if (cleanDoi.startsWith("http")) cleanDoi else "https://doi.org/$cleanDoi" Doi(fullUrl) } catch (e: Exception) { - throw BibtexInvalidFieldFormatException("doi", it, "a valid DOI string or URL") + null } } diff --git a/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt b/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt index c3a916db1..b33b17085 100644 --- a/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt +++ b/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt @@ -107,7 +107,7 @@ class RisConverterService(private val studyReviewIdGeneratorService: IdGenerator try { Doi("https://doi.org/${it.trim()}") } catch (e: Exception) { - throw RisInvalidFieldFormatException("DOI (DO)", it, "a valid DOI identifier") + null } } diff --git a/review/src/test/kotlin/br/all/domain/services/BibtexConverterServiceTest.kt b/review/src/test/kotlin/br/all/domain/services/BibtexConverterServiceTest.kt index 8504d9871..29f8c09f3 100644 --- a/review/src/test/kotlin/br/all/domain/services/BibtexConverterServiceTest.kt +++ b/review/src/test/kotlin/br/all/domain/services/BibtexConverterServiceTest.kt @@ -489,7 +489,9 @@ class BibtexConverterServiceTest { } } + // an empty doi is valid! @Test + @Disabled fun `should throw BibtexParseException for invalid doi`() { val bibtex = BibtexTestData.testInputs["invalid doi"]!! assertThrows { diff --git a/review/src/test/kotlin/br/all/domain/services/RisConverterServiceTest.kt b/review/src/test/kotlin/br/all/domain/services/RisConverterServiceTest.kt index 42cfd99b3..dbdbdaf6f 100644 --- a/review/src/test/kotlin/br/all/domain/services/RisConverterServiceTest.kt +++ b/review/src/test/kotlin/br/all/domain/services/RisConverterServiceTest.kt @@ -613,7 +613,9 @@ class RisConverterServiceTest { } } + // an empty doi is valid! @Test + @Disabled fun `should throw RisParseException for invalid doi`() { val ris = testInput["invalid doi"]!! assertThrows { From 794bf866c31f3c627a78a9d6aed5ec13f6677ba3 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 16 Sep 2025 18:17:23 -0300 Subject: [PATCH 098/120] test(bibtex-testdata): add missing author fields to test data entries --- .../src/test/kotlin/br/all/domain/services/BibtexTestData.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt b/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt index aab8c8df3..4f0a3b628 100644 --- a/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt +++ b/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt @@ -30,6 +30,7 @@ object BibtexTestData { "invalid year" to """ @article{nash51, title = {Non-cooperative Games}, + author = {Nash, John}, journal = {Annals of Mathematics}, abstract = {Lorem Ipsum}, keywords = {keyword1, keyword2}, @@ -42,6 +43,7 @@ object BibtexTestData { @article{nash51, title = {Non-cooperative Games}, year = {1951}, + author = {Nash, John}, journal = {Annals of Mathematics}, keywords = {keyword1, keyword2}, references = {ref1, ref2}, @@ -53,6 +55,7 @@ object BibtexTestData { @article{nash51, title = {Non-cooperative Games}, year = {1951}, + author = {Nash, John}, abstract = {Lorem Ipsum}, keywords = {keyword1, keyword2}, references = {ref1, ref2}, From 3e44678e04d09fcf6322f29013bd5237a6c20a65 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Tue, 16 Sep 2025 18:30:43 -0300 Subject: [PATCH 099/120] feat(update-systematic-study): add support for objectives field in update requests --- .../review/update/services/UpdateSystematicStudyService.kt | 1 + .../update/services/UpdateSystematicStudyServiceImpl.kt | 1 + .../kotlin/br/all/application/review/util/TestDataFactory.kt | 3 ++- web/src/main/kotlin/br/all/review/requests/PutRequest.kt | 4 ++-- web/src/main/kotlin/br/all/utils/LinksFactory.kt | 2 +- 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/review/src/main/kotlin/br/all/application/review/update/services/UpdateSystematicStudyService.kt b/review/src/main/kotlin/br/all/application/review/update/services/UpdateSystematicStudyService.kt index 971d36f72..01a720e32 100644 --- a/review/src/main/kotlin/br/all/application/review/update/services/UpdateSystematicStudyService.kt +++ b/review/src/main/kotlin/br/all/application/review/update/services/UpdateSystematicStudyService.kt @@ -11,6 +11,7 @@ interface UpdateSystematicStudyService { val systematicStudy: UUID, val title: String?, val description: String?, + val objectives: String? ) data class ResponseModel( diff --git a/review/src/main/kotlin/br/all/application/review/update/services/UpdateSystematicStudyServiceImpl.kt b/review/src/main/kotlin/br/all/application/review/update/services/UpdateSystematicStudyServiceImpl.kt index 76de2a921..68fa2a93c 100644 --- a/review/src/main/kotlin/br/all/application/review/update/services/UpdateSystematicStudyServiceImpl.kt +++ b/review/src/main/kotlin/br/all/application/review/update/services/UpdateSystematicStudyServiceImpl.kt @@ -34,6 +34,7 @@ class UpdateSystematicStudyServiceImpl( val updated = SystematicStudy.fromDto(dto).apply { title = request.title ?: title description = request.description ?: description + objectives = request.objectives ?: objectives }.toDto() if (updated != dto) repository.saveOrUpdate(updated) diff --git a/review/src/test/kotlin/br/all/application/review/util/TestDataFactory.kt b/review/src/test/kotlin/br/all/application/review/util/TestDataFactory.kt index 845e9b97f..a12ad5c15 100644 --- a/review/src/test/kotlin/br/all/application/review/util/TestDataFactory.kt +++ b/review/src/test/kotlin/br/all/application/review/util/TestDataFactory.kt @@ -113,7 +113,8 @@ class TestDataFactory { systematicStudyId: UUID = systematicStudy, title: String? = null, description: String? = null, - ) = UpdateRequestModel(researcherId, systematicStudyId, title, description) + objectives: String? = null, + ) = UpdateRequestModel(researcherId, systematicStudyId, title, description, objectives) fun updateResponseModel( researcherId: UUID = this.researcher, diff --git a/web/src/main/kotlin/br/all/review/requests/PutRequest.kt b/web/src/main/kotlin/br/all/review/requests/PutRequest.kt index 9460bb53c..ba5dd2ed8 100644 --- a/web/src/main/kotlin/br/all/review/requests/PutRequest.kt +++ b/web/src/main/kotlin/br/all/review/requests/PutRequest.kt @@ -5,7 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema import java.util.* @Schema(name = "SystematicStudyPutRequest") -data class PutRequest(val title: String?, val description: String?) { +data class PutRequest(val title: String?, val description: String?, val objectives: String?) { fun toUpdateRequestModel(researcherId: UUID, systematicStudyId: UUID) = - UpdateSystematicStudyService.RequestModel(researcherId, systematicStudyId, title, description) + UpdateSystematicStudyService.RequestModel(researcherId, systematicStudyId, title, description, objectives) } \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/utils/LinksFactory.kt b/web/src/main/kotlin/br/all/utils/LinksFactory.kt index a7f7da6ff..ea7d1b7f7 100644 --- a/web/src/main/kotlin/br/all/utils/LinksFactory.kt +++ b/web/src/main/kotlin/br/all/utils/LinksFactory.kt @@ -51,7 +51,7 @@ class LinksFactory { fun updateReview(systematicStudyId: UUID): Link = linkTo { updateSystematicStudy( systematicStudyId, - br.all.review.requests.PutRequest("title", "description"), + br.all.review.requests.PutRequest("title", "description", "objectives"), ) }.withRel("update-review").withType("PUT") From 52872499eee0c11900e9e80fb27852ca04ed3a63 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 18 Sep 2025 19:56:08 -0300 Subject: [PATCH 100/120] feat: add UNKNOWN study type for consistency --- review/src/main/kotlin/br/all/domain/model/study/StudyType.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/review/src/main/kotlin/br/all/domain/model/study/StudyType.kt b/review/src/main/kotlin/br/all/domain/model/study/StudyType.kt index e199cb6ee..004bc5a39 100644 --- a/review/src/main/kotlin/br/all/domain/model/study/StudyType.kt +++ b/review/src/main/kotlin/br/all/domain/model/study/StudyType.kt @@ -5,5 +5,5 @@ enum class StudyType { INBOOK, INCOLLECTION, INPROCEEDINGS, MANUAL, MASTERSTHESIS, MISC, PHDTHESIS, PROCEEDINGS, TECHREPORT, - UNPUBLISHED + UNPUBLISHED, UNKNOWN } \ No newline at end of file From 537272f08d540716cd1be9493d12191dfc132e63 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 18 Sep 2025 19:56:45 -0300 Subject: [PATCH 101/120] refactor(bib-parser): make bibtex id the only obligatory field and refine extract methods --- .../domain/services/BibtexConverterService.kt | 47 +++++++------------ 1 file changed, 17 insertions(+), 30 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 f77e9f0d2..132d53d24 100644 --- a/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt +++ b/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt @@ -10,10 +10,8 @@ import br.all.domain.model.study.Study import br.all.domain.model.study.StudyReview import br.all.domain.model.study.StudyReviewId import br.all.domain.model.study.StudyType -import br.all.domain.shared.exception.bibtex.BibtexParseException import br.all.domain.shared.exception.bibtex.BibtexMissingRequiredFieldException -import br.all.domain.shared.exception.bibtex.BibtexInvalidFieldFormatException -import br.all.domain.shared.exception.bibtex.BibtexUnknownEntryTypeException +import br.all.domain.shared.exception.bibtex.BibtexParseException import java.util.Locale class BibtexConverterService(private val studyReviewIdGeneratorService: IdGeneratorService) { @@ -69,14 +67,15 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera .map { it.trim() } .filter { it.isNotBlank() } .forEach { entry -> - val entryKey = extractEntryKey(entry) try { val study = convert(entry) validStudies.add(study) } catch (e: BibtexParseException) { - invalidEntries.add("Entry '$entryKey': ${e.message}") + val entryIdentifier = extractBibtexId(entry) ?: "starting with '${entry.take(40)}...'" + invalidEntries.add("Failed to parse entry '$entryIdentifier': ${e.message}") } catch (e: Exception) { - invalidEntries.add("Entry '$entryKey': An unexpected error occurred during parsing. Details: ${e.message}") + val entryIdentifier = extractBibtexId(entry) ?: "starting with '${entry.take(40)}...'" + invalidEntries.add("An unexpected error occurred for entry '$entryIdentifier'. Details: ${e.message}") } } return Pair(validStudies, invalidEntries) @@ -85,29 +84,16 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera fun convert(bibtexEntry: String): Study { require(bibtexEntry.isNotBlank()) { "BibTeX entry must not be blank." } - val type = try { - extractStudyType(bibtexEntry) - } catch (e: IllegalArgumentException) { - val entryTypeName = bibtexEntry.substringBefore('{').trim().removePrefix("@") - throw BibtexUnknownEntryTypeException(entryTypeName.ifBlank { "N/A" }) - } + val bibtexId = extractBibtexId(bibtexEntry) + ?: throw BibtexMissingRequiredFieldException("BibTeX ID") + val type = extractStudyType(bibtexEntry) val fieldMap = parseBibtexFields(bibtexEntry) - val title = fieldMap["title"]?.takeIf { it.isNotBlank() } - ?: throw BibtexMissingRequiredFieldException("title") - - val yearString = fieldMap["year"] - ?: throw BibtexMissingRequiredFieldException("year") - - val year = yearString.toIntOrNull() - ?: throw BibtexInvalidFieldFormatException("year", yearString, "an integer") - - val authors = getValueFromFieldMap(fieldMap, authorTypes).takeIf { it.isNotBlank() } - ?: throw BibtexMissingRequiredFieldException(authorTypes.joinToString(" or ")) - - val venue = getValueFromFieldMap(fieldMap, venueTypes).takeIf { it.isNotBlank() } - ?: throw BibtexMissingRequiredFieldException(venueTypes.joinToString(" or ")) + val title = fieldMap["title"]?.takeIf { it.isNotBlank() } ?: "" + val year = fieldMap["year"]?.toIntOrNull() ?: 0 + val authors = getValueFromFieldMap(fieldMap, authorTypes).takeIf { it.isNotBlank() } ?: "" + val venue = getValueFromFieldMap(fieldMap, venueTypes).takeIf { it.isNotBlank() } ?: "d" val abstract = fieldMap["abstract"] ?: "" val keywords = parseKeywords(fieldMap["keywords"] ?: fieldMap["keyword"]) @@ -163,12 +149,13 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera private fun extractStudyType(bibtexEntry: String): StudyType { val entryTypeRegex = Regex("""@(\w+)\s*\{""") val matchResult = entryTypeRegex.find(bibtexEntry) - val studyTypeName = matchResult?.groupValues?.get(1)?.uppercase(Locale.getDefault()) ?: "UNKNOWN" - return StudyType.valueOf(studyTypeName) + val studyTypeName = matchResult?.groupValues?.get(1)?.uppercase(Locale.getDefault()) ?: return StudyType.UNKNOWN + + return runCatching { StudyType.valueOf(studyTypeName) }.getOrDefault(StudyType.UNKNOWN) } - private fun extractEntryKey(bibtexEntry: String): String { + private fun extractBibtexId(bibtexEntry: String): String? { val keyRegex = Regex("""@\w+\s*\{(.*?)\s*,""", RegexOption.DOT_MATCHES_ALL) - return keyRegex.find(bibtexEntry)?.groupValues?.get(1)?.trim() ?: "UNKNOWN" + return keyRegex.find(bibtexEntry)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() } } } \ No newline at end of file From 611df7f0cdb83226be70a3ba5375c8b330959571 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 18 Sep 2025 19:56:59 -0300 Subject: [PATCH 102/120] test(bib-parser): remove old tests that lost their meaning --- .../services/BibtexConverterServiceTest.kt | 54 +------------------ 1 file changed, 1 insertion(+), 53 deletions(-) diff --git a/review/src/test/kotlin/br/all/domain/services/BibtexConverterServiceTest.kt b/review/src/test/kotlin/br/all/domain/services/BibtexConverterServiceTest.kt index 29f8c09f3..fc802c4c7 100644 --- a/review/src/test/kotlin/br/all/domain/services/BibtexConverterServiceTest.kt +++ b/review/src/test/kotlin/br/all/domain/services/BibtexConverterServiceTest.kt @@ -447,58 +447,6 @@ class BibtexConverterServiceTest { } } - @Test - fun `should throw BibtexParseException for invalid title entry`() { - val bibtex = BibtexTestData.testInputs["invalid title"]!! - assertThrows { - sut.convert(bibtex) - } - } - - @Test - fun `should throw BibtexParseException for invalid author entry`() { - val bibtex = BibtexTestData.testInputs["invalid authors"]!! - assertThrows { - sut.convert(bibtex) - } - } - - @Test - fun `should throw BibtexParseException for invalid year entry`() { - val bibtex = BibtexTestData.testInputs["invalid year"]!! - assertThrows { - sut.convert(bibtex) - } - } - - @Test - fun `should throw BibtexParseException for invalid venue entry`() { - val bibtex = BibtexTestData.testInputs["invalid venue"]!! - assertThrows { - sut.convert(bibtex) - } - } - - // an empty abstract is valid! - @Test - @Disabled - fun `should throw BibtexParseException for invalid abstract entry`() { - val bibtex = BibtexTestData.testInputs["invalid abstract"]!! - assertThrows { - sut.convert(bibtex) - } - } - - // an empty doi is valid! - @Test - @Disabled - fun `should throw BibtexParseException for invalid doi`() { - val bibtex = BibtexTestData.testInputs["invalid doi"]!! - assertThrows { - sut.convert(bibtex) - } - } - @Test fun `Should create a StudyReview list from multiple bibtex entries even when there are invalid entries`() { val bibtex = BibtexTestData.testInputs["multiple bibtex entries with some invalid"]!! @@ -515,7 +463,7 @@ class BibtexConverterServiceTest { println("Valid StudyReview: ${studyReview.title}") } assertAll( - {assertEquals(3, studyReviewList.first.size)}, + {assertEquals(4, studyReviewList.first.size)}, ) } } From 3843f0bf0000ed975fee38e1fd9f279d3d6b6a35 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 18 Sep 2025 20:16:38 -0300 Subject: [PATCH 103/120] chore(bib-parser): change error message --- .../kotlin/br/all/domain/services/BibtexConverterService.kt | 3 +-- 1 file changed, 1 insertion(+), 2 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 132d53d24..99cf0db07 100644 --- a/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt +++ b/review/src/main/kotlin/br/all/domain/services/BibtexConverterService.kt @@ -74,8 +74,7 @@ class BibtexConverterService(private val studyReviewIdGeneratorService: IdGenera val entryIdentifier = extractBibtexId(entry) ?: "starting with '${entry.take(40)}...'" invalidEntries.add("Failed to parse entry '$entryIdentifier': ${e.message}") } catch (e: Exception) { - val entryIdentifier = extractBibtexId(entry) ?: "starting with '${entry.take(40)}...'" - invalidEntries.add("An unexpected error occurred for entry '$entryIdentifier'. Details: ${e.message}") + invalidEntries.add("An unexpected error occurred. Details: ${e.message}") } } return Pair(validStudies, invalidEntries) From 908f97d383a9f44bfce1fcdcea4f4bb03d076f00 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 18 Sep 2025 20:29:40 -0300 Subject: [PATCH 104/120] chore(bib-parser): edit bibtex test data to invalidate some bibtex --- review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt b/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt index 4f0a3b628..f5ef9a2b9 100644 --- a/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt +++ b/review/src/test/kotlin/br/all/domain/services/BibtexTestData.kt @@ -319,7 +319,7 @@ object BibtexTestData { "multiple bibtex entries with some invalid" to """ //invalid - @article{nash51, + {nash51, title = {Non-cooperative Games}, abstract = {Lorem Ipsum}, keywords = {keyword1, keyword2}, From efe16849c935af645ec2aa146486857b9f9dce44 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 18 Sep 2025 20:30:04 -0300 Subject: [PATCH 105/120] refactor(study): now study allow blank title, authors, year and venue --- .../main/kotlin/br/all/domain/model/study/Study.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/review/src/main/kotlin/br/all/domain/model/study/Study.kt b/review/src/main/kotlin/br/all/domain/model/study/Study.kt index 737e86a82..a616b757e 100644 --- a/review/src/main/kotlin/br/all/domain/model/study/Study.kt +++ b/review/src/main/kotlin/br/all/domain/model/study/Study.kt @@ -22,11 +22,13 @@ data class Study( override fun validate(): Notification { val notification = Notification() - if (title.isBlank()) notification.addError("Title field must not be blank.") - if (authors.isBlank()) notification.addError("Authors field must not be blank.") - if (year == 0) notification.addError("Publication year must not be zero.") - if (venue.isBlank()) notification.addError("Journal field must not be blank.") - //if (abstract.isBlank()) notification.addError("Abstract field must not be blank.") + // every field can be blank now, the only thing that matter for bibTex is the ID + // and for Ris is the title, but it is already treated on their services +// if (title.isBlank()) notification.addError("Title field must not be blank.") +// if (authors.isBlank()) notification.addError("Authors field must not be blank.") +// if (year == 0) notification.addError("Publication year must not be zero.") +// if (venue.isBlank()) notification.addError("Journal field must not be blank.") +// if (abstract.isBlank()) notification.addError("Abstract field must not be blank.") return notification } } \ No newline at end of file From f8e5c9280d736b36217ced26e8985cc31664a18e Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 18 Sep 2025 20:31:54 -0300 Subject: [PATCH 106/120] chore(ris-parser): invalidate some ris from test data --- review/src/test/kotlin/br/all/domain/services/RisTestData.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/review/src/test/kotlin/br/all/domain/services/RisTestData.kt b/review/src/test/kotlin/br/all/domain/services/RisTestData.kt index cf563ad54..2723f9eaf 100644 --- a/review/src/test/kotlin/br/all/domain/services/RisTestData.kt +++ b/review/src/test/kotlin/br/all/domain/services/RisTestData.kt @@ -343,7 +343,7 @@ object RisTestData { DO - 10.1109/MCG.2017.6 JO - \url{https://www.nasa.gov/nh/pluto-the-other-red-planet} IS - 1 - Y1 - Jan.-Feb. 2017 + Y1 - blablabla AB - Lorem Ipsum ER - """, From d1e4f89de0b22c3093f874c9afa1ffe3bfe60938 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 18 Sep 2025 20:32:32 -0300 Subject: [PATCH 107/120] refactor(ris-parser): make the title the only obligatory field and refine extract methods --- .../domain/services/RisConverterService.kt | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt b/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt index b33b17085..dde82d06f 100644 --- a/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt +++ b/review/src/main/kotlin/br/all/domain/services/RisConverterService.kt @@ -3,10 +3,8 @@ package br.all.domain.services import br.all.domain.model.review.SystematicStudyId import br.all.domain.model.search.SearchSessionID import br.all.domain.model.study.* -import br.all.domain.shared.exception.ris.RisParseException import br.all.domain.shared.exception.ris.RisMissingRequiredFieldException -import br.all.domain.shared.exception.ris.RisInvalidFieldFormatException -import br.all.domain.shared.exception.ris.RisUnknownEntryTypeException +import br.all.domain.shared.exception.ris.RisParseException class RisConverterService(private val studyReviewIdGeneratorService: IdGeneratorService) { @@ -56,7 +54,6 @@ class RisConverterService(private val studyReviewIdGeneratorService: IdGenerator ) } - private fun convertMany(ris: String): Pair, List> { val validStudies = mutableListOf() val invalidEntries = mutableListOf() @@ -65,16 +62,17 @@ class RisConverterService(private val studyReviewIdGeneratorService: IdGenerator entryRegex.findAll(ris).forEach { matchResult -> val entry = matchResult.value.trim() - val entryIdentifier = extractTitleForError(entry) try { if (entry.isNotBlank()) { val study = convert(entry) validStudies.add(study) } } catch (e: RisParseException) { - invalidEntries.add("Entry '$entryIdentifier': ${e.message}") + val identifier = extractTitleForError(entry).takeIf { it != "Unknown Title" } + ?: "starting with '${entry.take(40)}...'" + invalidEntries.add("Failed to parse entry '$identifier': ${e.message}") } catch (e: Exception) { - invalidEntries.add("Entry '$entryIdentifier': An unexpected error occurred. ${e.message}") + invalidEntries.add("An unexpected error occurred. Details: ${e.message}") } } return Pair(validStudies, invalidEntries) @@ -88,15 +86,11 @@ class RisConverterService(private val studyReviewIdGeneratorService: IdGenerator val type = extractStudyType(fieldMap) val primaryTitle = getValueFromFieldMap(fieldMap, titleTypes) - .takeIf { it.isNotBlank() } ?: throw RisMissingRequiredFieldException("Title (TI or T1)") val secondaryTitle = fieldMap["T2"] ?: "" - val title = "$primaryTitle $secondaryTitle".trim() - - val year = extractYear(fieldMap) - ?: throw RisMissingRequiredFieldException("Year (PY or Y1)") + val title = "$primaryTitle $secondaryTitle".trim().ifBlank { "" } - val authors = parseAuthors(fieldMap) - .takeIf { it.isNotBlank() } ?: throw RisMissingRequiredFieldException("Author (AU or A1)") + val year = extractYear(fieldMap) ?: 0 + val authors = parseAuthors(fieldMap).takeIf { it.isNotBlank() } ?: "" val venue = getValueFromFieldMap(fieldMap, venueTypes) val abstract = fieldMap["AB"] ?: "" @@ -105,7 +99,8 @@ class RisConverterService(private val studyReviewIdGeneratorService: IdGenerator val doi = fieldMap["DO"]?.let { try { - Doi("https://doi.org/${it.trim()}") + val cleanDoi = it.trim().removePrefix("https://doi.org/") + Doi("https://doi.org/$cleanDoi") } catch (e: Exception) { null } @@ -160,7 +155,7 @@ class RisConverterService(private val studyReviewIdGeneratorService: IdGenerator "PAMP" -> StudyType.BOOKLET "MANSCPT", "UNPB", "UNPD" -> StudyType.UNPUBLISHED "ADVS", "AGGR", "ANCIENT", "ART", "BILL", "BLOG", "CASE", "CHART", "CLSWK", "COMP", "CTLG", "DATA", "DBASE", "DICT", "ELEC", "ENCYC", "EQUA", "FIGURE", "GEN", "GOVDOC", "GRNT", "HEAR", "ICOMM", "INTV", "LEGAL", "MAP", "MPCT", "MULTI", "MUSIC", "PAT", "PCOMM", "POD", "PRESS", "SER", "SLIDE", "SOUND", "STAND", "STAT", "STD", "UNBILL", "VIDEO" -> StudyType.MISC - else -> throw RisUnknownEntryTypeException(risType) + else -> StudyType.UNKNOWN } } From acea51c98e9f62ed17b51b139fd9aa7774ae0a80 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 18 Sep 2025 20:32:52 -0300 Subject: [PATCH 108/120] test(ris-parser): adapt tests to satisfy new parser logic --- .../services/RisConverterServiceTest.kt | 139 ++++-------------- 1 file changed, 27 insertions(+), 112 deletions(-) diff --git a/review/src/test/kotlin/br/all/domain/services/RisConverterServiceTest.kt b/review/src/test/kotlin/br/all/domain/services/RisConverterServiceTest.kt index dbdbdaf6f..c21ed088f 100644 --- a/review/src/test/kotlin/br/all/domain/services/RisConverterServiceTest.kt +++ b/review/src/test/kotlin/br/all/domain/services/RisConverterServiceTest.kt @@ -4,7 +4,6 @@ import br.all.domain.model.review.SystematicStudyId import br.all.domain.model.search.SearchSessionID import br.all.domain.model.study.* import br.all.domain.services.RisTestData.testInput -import br.all.domain.shared.exception.ris.RisParseException import org.junit.jupiter.api.* import java.util.* import kotlin.test.assertEquals @@ -67,8 +66,8 @@ class RisConverterServiceTest { source = mutableSetOf("Compendex") ) assertEquals( - studyReview.title, - "Sampling for Scalable Visual Analytics IEEE Computer Graphics and Applications" + "Sampling for Scalable Visual Analytics IEEE Computer Graphics and Applications", + studyReview.title ) } @@ -83,8 +82,8 @@ class RisConverterServiceTest { source = mutableSetOf("Compendex") ) assertEquals( - studyReview.title, - "Sampling for Scalable Visual Analytics IEEE Computer Graphics and Applications" + "Sampling for Scalable Visual Analytics IEEE Computer Graphics and Applications", + studyReview.title ) } @@ -98,7 +97,7 @@ class RisConverterServiceTest { study, source = mutableSetOf("Compendex") ) - assertEquals(studyReview.year, 2017) + assertEquals(2017, studyReview.year) } @Test @@ -111,7 +110,7 @@ class RisConverterServiceTest { study, source = mutableSetOf("Compendex") ) - assertEquals(studyReview.year, 2017) + assertEquals(2017, studyReview.year) } @Test @@ -501,7 +500,7 @@ class RisConverterServiceTest { } @Nested - inner class InvalidClasses { + inner class LenientParsingTests { @Test fun `convertManyToStudyReview should not accept a blank ris entry as input`() { assertThrows { @@ -515,136 +514,52 @@ class RisConverterServiceTest { } @Test - fun `convertToStudyReview should not accept a blank ris entry as input`() { + fun `convert should not accept a blank ris entry as input`() { assertThrows { - val study = sut.convert("") - sut.convertToStudyReview( - SystematicStudyId(UUID.randomUUID()), - SearchSessionID(UUID.randomUUID()), - study, - source = mutableSetOf("Compendex") - ) + sut.convert("") } } @Test - fun `should throw RisParseException for unknown type entry`() { + fun `should create study with UNKNOWN type for unknown type entry`() { val ris = testInput["unknown ris"]!! - assertThrows { - val study = sut.convert(ris) - sut.convertToStudyReview( - SystematicStudyId(UUID.randomUUID()), - SearchSessionID(UUID.randomUUID()), - study, - source = mutableSetOf("Compendex") - ) - } + val study = sut.convert(ris) + assertEquals(StudyType.UNKNOWN, study.type) } @Test - fun `should throw RisParseException for invalid title entry`() { - val ris = testInput["invalid title"].toString() - assertThrows { - val study = sut.convert(ris) - sut.convertToStudyReview( - SystematicStudyId(UUID.randomUUID()), - SearchSessionID(UUID.randomUUID()), - study, - source = mutableSetOf("Compendex") - ) - } + fun `should create study with default title for missing title entry`() { + val ris = testInput["invalid title"]!! + val study = sut.convert(ris) + assertEquals("", study.title) } @Test - fun `should throw RisParseException for invalid author entry`() { + fun `should create study with default authors for missing author entry`() { val ris = testInput["invalid authors"]!! - assertThrows { - val study = sut.convert(ris) - sut.convertToStudyReview( - SystematicStudyId(UUID.randomUUID()), - SearchSessionID(UUID.randomUUID()), - study, - source = mutableSetOf("Compendex") - ) - } + val study = sut.convert(ris) + assertEquals("", study.authors) } @Test - fun `should throw RisParseException for invalid year entry`() { + fun `should create study with default year for missing year entry`() { val ris = testInput["invalid year"]!! - assertThrows { - val study = sut.convert(ris) - sut.convertToStudyReview( - SystematicStudyId(UUID.randomUUID()), - SearchSessionID(UUID.randomUUID()), - study, - source = mutableSetOf("Compendex") - ) - } - } - - @Test - fun `should throw RisParseException for invalid venue entry`() { - val ris = testInput["invalid venue"]!! - assertThrows { - val study = sut.convert(ris) - sut.convertToStudyReview( - SystematicStudyId(UUID.randomUUID()), - SearchSessionID(UUID.randomUUID()), - study, - source = mutableSetOf("Compendex") - ) - } - } - - // an empty abstract is valid! - @Test - @Disabled - fun `should throw RisParseException for invalid abstract entry`() { - val ris = testInput["invalid abstract"]!! - assertThrows { - val study = sut.convert(ris) - sut.convertToStudyReview( - SystematicStudyId(UUID.randomUUID()), - SearchSessionID(UUID.randomUUID()), - study, - source = mutableSetOf("Compendex") - ) - } - } - - // an empty doi is valid! - @Test - @Disabled - fun `should throw RisParseException for invalid doi`() { - val ris = testInput["invalid doi"]!! - assertThrows { - val study = sut.convert(ris) - sut.convertToStudyReview( - SystematicStudyId(UUID.randomUUID()), - SearchSessionID(UUID.randomUUID()), - study, - source = mutableSetOf("Compendex") - ) - } + val study = sut.convert(ris) + assertEquals(0, study.year) } @Test - fun `should return the correct number of invalid ris files`() { + fun `should correctly parse entries that are missing non-essential fields`() { val ris = testInput["three error ris"]!! - val studyReviewList = sut.convertManyToStudyReview( + val (validStudies, invalidEntries) = sut.convertManyToStudyReview( SystematicStudyId(UUID.randomUUID()), SearchSessionID(UUID.randomUUID()), ris, source = mutableSetOf("Compendex") ) - println("Valid Study Reviews: ${studyReviewList.first}") - println("Invalid RIS Entries: ${studyReviewList.second}") - - assertEquals(2, studyReviewList.first.size) - - assertEquals(3, studyReviewList.second.size) + assertEquals(5, validStudies.size) + assertEquals(0, invalidEntries.size) } } -} +} \ No newline at end of file From ad9190d46b4323fcacacac3bfbf3a638f7982c85 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Wed, 24 Sep 2025 20:05:57 -0300 Subject: [PATCH 109/120] feat(change-account-password): introduce service and presenter interfaces for password update logic --- .../update/ChangeAccountPasswordPresenter.kt | 6 ++++++ .../update/ChangeAccountPasswordService.kt | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordPresenter.kt create mode 100644 account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt diff --git a/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordPresenter.kt b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordPresenter.kt new file mode 100644 index 000000000..7e1db8a90 --- /dev/null +++ b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordPresenter.kt @@ -0,0 +1,6 @@ +package br.all.application.user.update + +import br.all.application.user.update.ChangeAccountPasswordService.ResponseModel +import br.all.domain.shared.presenter.GenericPresenter + +interface ChangeAccountPasswordPresenter : GenericPresenter \ No newline at end of file diff --git a/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt new file mode 100644 index 000000000..80987bddf --- /dev/null +++ b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt @@ -0,0 +1,18 @@ +package br.all.application.user.update + +import java.util.UUID + +interface ChangeAccountPasswordService { + fun changePassword() + + data class RequestModel( + val userId: UUID, + val oldPassword: String, + val newPassword: String, + val confirmPassword: String + ) + + data class ResponseModel( + val userId: UUID + ) +} \ No newline at end of file From 292e928b84686143cd2582869e447af11050d5c5 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 25 Sep 2025 16:51:22 -0300 Subject: [PATCH 110/120] chore(change-account-password): add missing service method params --- .../application/user/update/ChangeAccountPasswordService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt index 80987bddf..74bff1475 100644 --- a/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt +++ b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt @@ -3,8 +3,9 @@ package br.all.application.user.update import java.util.UUID interface ChangeAccountPasswordService { - fun changePassword() + fun changePassword(presenter: ChangeAccountPasswordPresenter, request: RequestModel) + // The passwords already come encrypted from the controller! data class RequestModel( val userId: UUID, val oldPassword: String, From 6b62bb97166144b7eb29f299946227cf0c95fda5 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 25 Sep 2025 17:31:27 -0300 Subject: [PATCH 111/120] feat(security): add PasswordEncoderPort abstraction and its adapter implementation to utilize BCrypt encoding outside web module --- .../shared/service/PasswordEncoderPort.kt | 6 ++++++ .../security/service/PasswordEncoderAdapter.kt | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 shared/src/main/kotlin/br/all/domain/shared/service/PasswordEncoderPort.kt create mode 100644 web/src/main/kotlin/br/all/security/service/PasswordEncoderAdapter.kt diff --git a/shared/src/main/kotlin/br/all/domain/shared/service/PasswordEncoderPort.kt b/shared/src/main/kotlin/br/all/domain/shared/service/PasswordEncoderPort.kt new file mode 100644 index 000000000..6d72fc0d3 --- /dev/null +++ b/shared/src/main/kotlin/br/all/domain/shared/service/PasswordEncoderPort.kt @@ -0,0 +1,6 @@ +package br.all.domain.shared.service + +interface PasswordEncoderPort { + fun encode(rawPassword: String): String + fun matches(rawPassword: String, encodedPassword: String): Boolean +} \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/security/service/PasswordEncoderAdapter.kt b/web/src/main/kotlin/br/all/security/service/PasswordEncoderAdapter.kt new file mode 100644 index 000000000..01a5afbd9 --- /dev/null +++ b/web/src/main/kotlin/br/all/security/service/PasswordEncoderAdapter.kt @@ -0,0 +1,18 @@ +package br.all.security.service + +import br.all.domain.shared.service.PasswordEncoderPort +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Component + +@Component +class PasswordEncoderAdapter( + private val encoder: PasswordEncoder +) : PasswordEncoderPort { + override fun encode(rawPassword: String): String { + return encoder.encode(rawPassword) + } + + override fun matches(rawPassword: String, encodedPassword: String): Boolean { + return encoder.matches(rawPassword, encodedPassword) + } +} \ No newline at end of file From be6f498d6aba230c43639c964195331cf54f1e50 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 25 Sep 2025 17:34:01 -0300 Subject: [PATCH 112/120] chore(change-account-password): remove now unneeded comment --- .../all/application/user/update/ChangeAccountPasswordService.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt index 74bff1475..04a2b8ddd 100644 --- a/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt +++ b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt @@ -5,7 +5,6 @@ import java.util.UUID interface ChangeAccountPasswordService { fun changePassword(presenter: ChangeAccountPasswordPresenter, request: RequestModel) - // The passwords already come encrypted from the controller! data class RequestModel( val userId: UUID, val oldPassword: String, From 9293a9f61760ad117f176dcb44013c2299f60c5e Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 25 Sep 2025 18:40:09 -0300 Subject: [PATCH 113/120] feat(change-account-password): add repository method to update user password --- .../application/user/repository/UserAccountRepository.kt | 1 + .../all/infrastructure/user/UserAccountRepositoryImpl.kt | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/account/src/main/kotlin/br/all/application/user/repository/UserAccountRepository.kt b/account/src/main/kotlin/br/all/application/user/repository/UserAccountRepository.kt index 763670422..c90caf229 100644 --- a/account/src/main/kotlin/br/all/application/user/repository/UserAccountRepository.kt +++ b/account/src/main/kotlin/br/all/application/user/repository/UserAccountRepository.kt @@ -13,4 +13,5 @@ interface UserAccountRepository { fun deleteById(id: UUID) fun loadUserProfileById(id: UUID): UserProfileDto? fun loadFullUserAccountById(id: UUID): UserAccountDto? + fun updatePassword(id: UUID, newHashedPassword: String) } \ No newline at end of file diff --git a/account/src/main/kotlin/br/all/infrastructure/user/UserAccountRepositoryImpl.kt b/account/src/main/kotlin/br/all/infrastructure/user/UserAccountRepositoryImpl.kt index 57f1c1d53..cff1ae6fc 100644 --- a/account/src/main/kotlin/br/all/infrastructure/user/UserAccountRepositoryImpl.kt +++ b/account/src/main/kotlin/br/all/infrastructure/user/UserAccountRepositoryImpl.kt @@ -48,6 +48,14 @@ class UserAccountRepositoryImpl( ) } + override fun updatePassword(id: UUID, newHashedPassword: String) { + val credentials = credentialsRepository.findById(id).orElse(null) + ?: throw NoSuchElementException("Cannot update password. Credentials for user with id $id not found!") + + credentials.password = newHashedPassword + credentialsRepository.save(credentials) + } + override fun loadCredentialsByUsername(username: String) = credentialsRepository.findByUsername(username)?.toAccountCredentialsDto() From 7039a72ba4f783a57f4d10fecc176944a0e732c1 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 25 Sep 2025 18:46:02 -0300 Subject: [PATCH 114/120] feat(change-account-password): implement ChangeAccountPasswordService with validation and update logic --- .../ChangeAccountPasswordServiceImpl.kt | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImpl.kt diff --git a/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImpl.kt b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImpl.kt new file mode 100644 index 000000000..cca8ef7d3 --- /dev/null +++ b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImpl.kt @@ -0,0 +1,47 @@ +package br.all.application.user.update + +import br.all.application.user.repository.UserAccountRepository +import br.all.domain.shared.service.PasswordEncoderPort + +class ChangeAccountPasswordServiceImpl( + private val repository: UserAccountRepository, + private val encoder: PasswordEncoderPort +) : ChangeAccountPasswordService { + override fun changePassword( + presenter: ChangeAccountPasswordPresenter, + request: ChangeAccountPasswordService.RequestModel + ) { + val userCredentials = repository.loadCredentialsById(request.userId) + if (userCredentials == null) { + presenter.prepareFailView(NoSuchElementException("User with id ${request.userId} doesn't exist!")) + return + } + + if (!encoder.matches(request.oldPassword, userCredentials.password)) { + presenter.prepareFailView(IllegalArgumentException("Invalid old password provided!")) + return + } + + if (request.newPassword != request.confirmPassword) { + presenter.prepareFailView(IllegalArgumentException("Confirm password does not match new password!")) + return + } + + if (encoder.matches(request.newPassword, userCredentials.password)) { + presenter.prepareFailView(IllegalArgumentException("New password cannot be the same as the old password!")) + return + } + + val newHashedPassword = encoder.encode(request.newPassword) + + try { + repository.updatePassword(request.userId, newHashedPassword) + presenter.prepareSuccessView(ChangeAccountPasswordService.ResponseModel( + request.userId, + )) + } catch (e: Exception) { + presenter.prepareFailView(e) + } + } + +} \ No newline at end of file From e5034ac030897fca8f616c8bbe9c6a781e0b478b Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 25 Sep 2025 18:50:51 -0300 Subject: [PATCH 115/120] feat(change-account-password): add request and restful presenter for password update logic --- .../RestfulChangeAccountPasswordPresenter.kt | 29 +++++++++++++++++++ .../requests/ChangeAccountPasswordRequest.kt | 7 +++++ 2 files changed, 36 insertions(+) create mode 100644 web/src/main/kotlin/br/all/user/presenter/RestfulChangeAccountPasswordPresenter.kt create mode 100644 web/src/main/kotlin/br/all/user/requests/ChangeAccountPasswordRequest.kt diff --git a/web/src/main/kotlin/br/all/user/presenter/RestfulChangeAccountPasswordPresenter.kt b/web/src/main/kotlin/br/all/user/presenter/RestfulChangeAccountPasswordPresenter.kt new file mode 100644 index 000000000..7a639421f --- /dev/null +++ b/web/src/main/kotlin/br/all/user/presenter/RestfulChangeAccountPasswordPresenter.kt @@ -0,0 +1,29 @@ +package br.all.user.presenter + +import br.all.application.user.update.ChangeAccountPasswordPresenter +import br.all.application.user.update.ChangeAccountPasswordService.ResponseModel +import br.all.shared.error.createErrorResponseFrom +import org.springframework.hateoas.RepresentationModel +import org.springframework.http.ResponseEntity +import org.springframework.http.ResponseEntity.ok +import java.util.UUID + +class RestfulChangeAccountPasswordPresenter : ChangeAccountPasswordPresenter { + + var responseEntity: ResponseEntity<*>? = null + + override fun prepareSuccessView(response: ResponseModel) { + val restfulResponse = ViewModel( + userId = response.userId + ) + responseEntity = ok(restfulResponse) + } + + override fun prepareFailView(throwable: Throwable) = run { responseEntity = createErrorResponseFrom(throwable) } + + override fun isDone() = responseEntity != null + + private data class ViewModel( + val userId: UUID + ) : RepresentationModel() +} \ No newline at end of file diff --git a/web/src/main/kotlin/br/all/user/requests/ChangeAccountPasswordRequest.kt b/web/src/main/kotlin/br/all/user/requests/ChangeAccountPasswordRequest.kt new file mode 100644 index 000000000..7ce198d03 --- /dev/null +++ b/web/src/main/kotlin/br/all/user/requests/ChangeAccountPasswordRequest.kt @@ -0,0 +1,7 @@ +package br.all.user.requests + +data class ChangeAccountPasswordRequest( + val oldPassword: String, + val newPassword: String, + val confirmPassword: String +) \ No newline at end of file From fd3befeec286d3d25d02a124f1a1fb0d1f3b7271 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 25 Sep 2025 19:01:16 -0300 Subject: [PATCH 116/120] feat(change-account-password): register ChangeAccountPasswordServiceImpl as a Spring bean --- .../br/all/user/controller/UserAccountConfiguration.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/src/main/kotlin/br/all/user/controller/UserAccountConfiguration.kt b/web/src/main/kotlin/br/all/user/controller/UserAccountConfiguration.kt index 04b92878d..3f2c117e1 100644 --- a/web/src/main/kotlin/br/all/user/controller/UserAccountConfiguration.kt +++ b/web/src/main/kotlin/br/all/user/controller/UserAccountConfiguration.kt @@ -3,7 +3,9 @@ package br.all.user.controller import br.all.application.user.create.RegisterUserAccountServiceImpl import br.all.application.user.find.RetrieveUserProfileServiceImpl import br.all.application.user.repository.UserAccountRepository +import br.all.application.user.update.ChangeAccountPasswordServiceImpl import br.all.application.user.update.PatchUserProfileServiceImpl +import br.all.domain.shared.service.PasswordEncoderPort import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -18,4 +20,7 @@ class UserAccountConfiguration { @Bean fun patchUserProfile(repository: UserAccountRepository) = PatchUserProfileServiceImpl(repository) + + @Bean + fun changeAccountPassword(repository: UserAccountRepository, encoder: PasswordEncoderPort) = ChangeAccountPasswordServiceImpl(repository, encoder) } \ No newline at end of file From 5849ceff4ae2db47571752b1aeab82d4c3033fc3 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 25 Sep 2025 19:01:20 -0300 Subject: [PATCH 117/120] feat(change-account-password): add endpoint to update user account password --- .../user/controller/UserAccountController.kt | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt b/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt index b3dbaaea7..afc2a9437 100644 --- a/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt +++ b/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt @@ -4,11 +4,14 @@ import br.all.application.user.CredentialsService import br.all.application.user.create.RegisterUserAccountService import br.all.application.user.create.RegisterUserAccountService.RequestModel import br.all.application.user.find.RetrieveUserProfileService +import br.all.application.user.update.ChangeAccountPasswordService import br.all.application.user.update.PatchUserProfileService import br.all.security.service.AuthenticationInfoService +import br.all.user.presenter.RestfulChangeAccountPasswordPresenter import br.all.user.presenter.RestfulPatchUserProfilePresenter import br.all.user.presenter.RestfulRegisterUserAccountPresenter import br.all.user.presenter.RestfulRetrieveUserProfilePresenter +import br.all.user.requests.ChangeAccountPasswordRequest import br.all.user.requests.PatchUserProfileRequest import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.media.Content @@ -21,6 +24,7 @@ import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -32,7 +36,8 @@ class UserAccountController( private val encoder: PasswordEncoder, private val retrieveUserProfileService: RetrieveUserProfileService, private val authenticationInfoService: AuthenticationInfoService, - private val patchUserProfileService: PatchUserProfileService + private val patchUserProfileService: PatchUserProfileService, + private val changeAccountPasswordService: ChangeAccountPasswordService ) { @PostMapping @@ -146,4 +151,46 @@ class UserAccountController( patchUserProfileService.patchProfile(presenter, request) return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) } + + @PutMapping("/change-password") + @Operation(summary = "Update password of an user account") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Success updating account password", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ChangeAccountPasswordService.ResponseModel::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Fail updating account password - unauthenticated collaborator", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "403", + description = "Fail updating account password - unauthorized collaborator", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "404", + description = "Fail updating account password - nonexistent user", + content = [Content(schema = Schema(hidden = true))] + ), + ]) + fun putAccountPassword(@RequestBody body: ChangeAccountPasswordRequest): ResponseEntity<*> { + val presenter = RestfulChangeAccountPasswordPresenter() + val userId = authenticationInfoService.getAuthenticatedUserId() + val request = ChangeAccountPasswordService.RequestModel( + userId = userId, + oldPassword = body.oldPassword, + newPassword = body.newPassword, + confirmPassword = body.confirmPassword + ) + + changeAccountPasswordService.changePassword(presenter, request) + return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) + } } \ No newline at end of file From cd02117df7d5b14ec37791a0838813f82c3e7fcd Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 25 Sep 2025 19:14:51 -0300 Subject: [PATCH 118/120] feat(change-account-password): add OpenAPI schema annotation to ResponseModel --- .../all/application/user/update/ChangeAccountPasswordService.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt index 04a2b8ddd..4e02c3a11 100644 --- a/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt +++ b/account/src/main/kotlin/br/all/application/user/update/ChangeAccountPasswordService.kt @@ -1,5 +1,6 @@ package br.all.application.user.update +import io.swagger.v3.oas.annotations.media.Schema import java.util.UUID interface ChangeAccountPasswordService { @@ -12,6 +13,7 @@ interface ChangeAccountPasswordService { val confirmPassword: String ) + @Schema(name = "ChangeAccountPasswordServiceResponseModel", description = "Response model for Change Account Password Service") data class ResponseModel( val userId: UUID ) From c3e70a03f536d911195939aaa04c3c29ef5f4541 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 25 Sep 2025 20:42:53 -0300 Subject: [PATCH 119/120] feat(change-account-password): add logout functionality after password change --- .../user/controller/UserAccountController.kt | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt b/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt index afc2a9437..e75982101 100644 --- a/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt +++ b/web/src/main/kotlin/br/all/user/controller/UserAccountController.kt @@ -7,6 +7,7 @@ import br.all.application.user.find.RetrieveUserProfileService import br.all.application.user.update.ChangeAccountPasswordService import br.all.application.user.update.PatchUserProfileService import br.all.security.service.AuthenticationInfoService +import br.all.security.service.AuthenticationService import br.all.user.presenter.RestfulChangeAccountPasswordPresenter import br.all.user.presenter.RestfulPatchUserProfilePresenter import br.all.user.presenter.RestfulRegisterUserAccountPresenter @@ -18,6 +19,8 @@ import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.crypto.password.PasswordEncoder @@ -37,7 +40,8 @@ class UserAccountController( private val retrieveUserProfileService: RetrieveUserProfileService, private val authenticationInfoService: AuthenticationInfoService, private val patchUserProfileService: PatchUserProfileService, - private val changeAccountPasswordService: ChangeAccountPasswordService + private val changeAccountPasswordService: ChangeAccountPasswordService, + private val authenticationService: AuthenticationService ) { @PostMapping @@ -153,7 +157,7 @@ class UserAccountController( } @PutMapping("/change-password") - @Operation(summary = "Update password of an user account") + @Operation(summary = "Update password of an user account and logout") @ApiResponses( value = [ ApiResponse( @@ -180,17 +184,28 @@ class UserAccountController( content = [Content(schema = Schema(hidden = true))] ), ]) - fun putAccountPassword(@RequestBody body: ChangeAccountPasswordRequest): ResponseEntity<*> { + fun putAccountPassword( + @RequestBody body: ChangeAccountPasswordRequest, + request: HttpServletRequest, + response: HttpServletResponse + ): ResponseEntity<*> { val presenter = RestfulChangeAccountPasswordPresenter() val userId = authenticationInfoService.getAuthenticatedUserId() - val request = ChangeAccountPasswordService.RequestModel( + val changePasswordRequest = ChangeAccountPasswordService.RequestModel( userId = userId, oldPassword = body.oldPassword, newPassword = body.newPassword, confirmPassword = body.confirmPassword ) - changeAccountPasswordService.changePassword(presenter, request) + changeAccountPasswordService.changePassword(presenter, changePasswordRequest) + + if (presenter.responseEntity?.statusCode?.isError == true) { + return ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) + } + + authenticationService.logout(request, response) + return presenter.responseEntity ?: ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) } } \ No newline at end of file From cac0368d75159f85db9dbeb293c19c6c68403127 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Thu, 25 Sep 2025 20:49:21 -0300 Subject: [PATCH 120/120] test(change-account-password): add unit tests for ChangeAccountPasswordServiceImpl --- .../ChangeAccountPasswordServiceImplTest.kt | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 account/src/test/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImplTest.kt diff --git a/account/src/test/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImplTest.kt b/account/src/test/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImplTest.kt new file mode 100644 index 000000000..07d2b3a90 --- /dev/null +++ b/account/src/test/kotlin/br/all/application/user/update/ChangeAccountPasswordServiceImplTest.kt @@ -0,0 +1,181 @@ +package br.all.application.user.update + +import br.all.application.user.repository.UserAccountRepository +import br.all.application.user.utils.TestDataFactory +import br.all.domain.shared.service.PasswordEncoderPort +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import java.util.* +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@Tag("UnitTest") +@Tag("ServiceTest") +@ExtendWith(MockKExtension::class) +class ChangeAccountPasswordServiceImplTest { + + @MockK(relaxUnitFun = true) + private lateinit var repository: UserAccountRepository + + @MockK + private lateinit var encoder: PasswordEncoderPort + + @MockK(relaxUnitFun = true) + private lateinit var presenter: ChangeAccountPasswordPresenter + + private lateinit var sut: ChangeAccountPasswordServiceImpl + private lateinit var factory: TestDataFactory + + @BeforeEach + fun setup() { + sut = ChangeAccountPasswordServiceImpl(repository, encoder) + factory = TestDataFactory() + } + + @Nested + @DisplayName("When changing a user's password") + inner class WhenChangingPassword { + + @Test + fun `should update password when all inputs are valid`() { + val userCredentials = factory.accountCredentials().copy(password = "oldHashedPassword") + val request = ChangeAccountPasswordService.RequestModel( + userId = userCredentials.id, + oldPassword = "plainOldPassword", + newPassword = "plainNewPassword", + confirmPassword = "plainNewPassword" + ) + val newHashedPassword = "newHashedPassword" + + every { repository.loadCredentialsById(userCredentials.id) } returns userCredentials + every { encoder.matches(request.oldPassword, userCredentials.password) } returns true + every { encoder.matches(request.newPassword, userCredentials.password) } returns false + every { encoder.encode(request.newPassword) } returns newHashedPassword + + val userIdSlot = slot() + val passwordSlot = slot() + every { repository.updatePassword(capture(userIdSlot), capture(passwordSlot)) } returns Unit + + val responseSlot = slot() + every { presenter.prepareSuccessView(capture(responseSlot)) } returns Unit + + sut.changePassword(presenter, request) + + verify(exactly = 1) { presenter.prepareSuccessView(any()) } + verify(exactly = 0) { presenter.prepareFailView(any()) } + verify(exactly = 1) { repository.updatePassword(userCredentials.id, newHashedPassword) } + + assertEquals(userCredentials.id, userIdSlot.captured) + assertEquals(newHashedPassword, passwordSlot.captured) + assertEquals(userCredentials.id, responseSlot.captured.userId) + } + + @Test + fun `should prepare fail view when user does not exist`() { + val nonExistentUserId = UUID.randomUUID() + val request = ChangeAccountPasswordService.RequestModel( + userId = nonExistentUserId, + oldPassword = "any", newPassword = "any", confirmPassword = "any" + ) + + every { repository.loadCredentialsById(nonExistentUserId) } returns null + + val exceptionSlot = slot() + every { presenter.prepareFailView(capture(exceptionSlot)) } returns Unit + + sut.changePassword(presenter, request) + + verify(exactly = 1) { presenter.prepareFailView(any()) } + verify(exactly = 0) { presenter.prepareSuccessView(any()) } + verify(exactly = 0) { repository.updatePassword(any(), any()) } + + assertIs(exceptionSlot.captured) + assertEquals("User with id $nonExistentUserId doesn't exist!", exceptionSlot.captured.message) + } + + @Test + fun `should prepare fail view when old password is incorrect`() { + val userCredentials = factory.accountCredentials().copy(password = "oldHashedPassword") + val request = ChangeAccountPasswordService.RequestModel( + userId = userCredentials.id, + oldPassword = "wrongOldPassword", + newPassword = "plainNewPassword", + confirmPassword = "plainNewPassword" + ) + + every { repository.loadCredentialsById(userCredentials.id) } returns userCredentials + every { encoder.matches(request.oldPassword, userCredentials.password) } returns false + + val exceptionSlot = slot() + every { presenter.prepareFailView(capture(exceptionSlot)) } returns Unit + + sut.changePassword(presenter, request) + + verify(exactly = 1) { presenter.prepareFailView(any()) } + verify(exactly = 0) { presenter.prepareSuccessView(any()) } + + assertIs(exceptionSlot.captured) + assertEquals("Invalid old password provided!", exceptionSlot.captured.message) + } + + @Test + fun `should prepare fail view when new password and confirmation do not match`() { + val userCredentials = factory.accountCredentials().copy(password = "oldHashedPassword") + val request = ChangeAccountPasswordService.RequestModel( + userId = userCredentials.id, + oldPassword = "plainOldPassword", + newPassword = "plainNewPassword", + confirmPassword = "doesNotMatch" + ) + + every { repository.loadCredentialsById(userCredentials.id) } returns userCredentials + every { encoder.matches(request.oldPassword, userCredentials.password) } returns true + + val exceptionSlot = slot() + every { presenter.prepareFailView(capture(exceptionSlot)) } returns Unit + + sut.changePassword(presenter, request) + + verify(exactly = 1) { presenter.prepareFailView(any()) } + verify(exactly = 0) { presenter.prepareSuccessView(any()) } + + assertIs(exceptionSlot.captured) + assertEquals("Confirm password does not match new password!", exceptionSlot.captured.message) + } + + @Test + fun `should prepare fail view when new password is the same as the old one`() { + val userCredentials = factory.accountCredentials().copy(password = "oldHashedPassword") + val request = ChangeAccountPasswordService.RequestModel( + userId = userCredentials.id, + oldPassword = "plainOldPassword", + newPassword = "plainOldPassword", + confirmPassword = "plainOldPassword" + ) + + every { repository.loadCredentialsById(userCredentials.id) } returns userCredentials + every { encoder.matches(request.oldPassword, userCredentials.password) } returns true + every { encoder.matches(request.newPassword, userCredentials.password) } returns true + + val exceptionSlot = slot() + every { presenter.prepareFailView(capture(exceptionSlot)) } returns Unit + + sut.changePassword(presenter, request) + + verify(exactly = 1) { presenter.prepareFailView(any()) } + verify(exactly = 0) { presenter.prepareSuccessView(any()) } + + assertIs(exceptionSlot.captured) + assertEquals("New password cannot be the same as the old password!", exceptionSlot.captured.message) + } + } +}