From ad9190d46b4323fcacacac3bfbf3a638f7982c85 Mon Sep 17 00:00:00 2001 From: matheusspacifico Date: Wed, 24 Sep 2025 20:05:57 -0300 Subject: [PATCH 01/12] 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 00000000..7e1db8a9 --- /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 00000000..80987bdd --- /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 02/12] 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 80987bdd..74bff147 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 03/12] 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 00000000..6d72fc0d --- /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 00000000..01a5afbd --- /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 04/12] 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 74bff147..04a2b8dd 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 05/12] 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 76367042..c90caf22 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 57f1c1d5..cff1ae6f 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 06/12] 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 00000000..cca8ef7d --- /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 07/12] 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 00000000..7a639421 --- /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 00000000..7ce198d0 --- /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 08/12] 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 04b92878..3f2c117e 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 09/12] 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 b3dbaaea..afc2a943 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 10/12] 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 04a2b8dd..4e02c3a1 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 11/12] 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 afc2a943..e7598210 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 12/12] 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 00000000..07d2b3a9 --- /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) + } + } +}