diff --git a/application/src/main/kotlin/tw/waterballsa/gaas/application/repositories/UserRepository.kt b/application/src/main/kotlin/tw/waterballsa/gaas/application/repositories/UserRepository.kt index cebc0d80..3c3eca6a 100644 --- a/application/src/main/kotlin/tw/waterballsa/gaas/application/repositories/UserRepository.kt +++ b/application/src/main/kotlin/tw/waterballsa/gaas/application/repositories/UserRepository.kt @@ -6,6 +6,7 @@ import tw.waterballsa.gaas.domain.User.Id interface UserRepository { fun findById(id: Id): User? fun existsUserByEmail(email: String): Boolean + fun existsUserByNickname(nickname: String): Boolean fun createUser(user: User): User fun deleteAll() fun findAllById(ids: Collection): List diff --git a/application/src/main/kotlin/tw/waterballsa/gaas/application/usecases/UpdateUserUseCase.kt b/application/src/main/kotlin/tw/waterballsa/gaas/application/usecases/UpdateUserUseCase.kt new file mode 100644 index 00000000..aec07c5f --- /dev/null +++ b/application/src/main/kotlin/tw/waterballsa/gaas/application/usecases/UpdateUserUseCase.kt @@ -0,0 +1,43 @@ +package tw.waterballsa.gaas.application.usecases + +import tw.waterballsa.gaas.application.eventbus.EventBus +import tw.waterballsa.gaas.application.repositories.UserRepository +import tw.waterballsa.gaas.domain.User +import tw.waterballsa.gaas.events.UserUpdatedEvent +import tw.waterballsa.gaas.exceptions.NotFoundException.Companion.notFound +import tw.waterballsa.gaas.exceptions.PlatformException +import javax.inject.Named + +@Named +class UpdateUserUseCase( + private val userRepository: UserRepository, + private val eventBus: EventBus, +) { + fun execute(request: Request, presenter: Presenter) { + with(request) { + validateNicknameDuplicated(nickname) + val user = findUserByEmail(email) + user.changeNickname(nickname) + val updatedUser = userRepository.update(user) + + val event = updatedUser.toUserUpdatedEvent() + presenter.present(event) + eventBus.broadcast(event) + } + } + + private fun validateNicknameDuplicated(nickname: String) { + if (userRepository.existsUserByNickname(nickname)) { + throw PlatformException("invalid nickname: duplicated") + } + } + + private fun findUserByEmail(email: String) = + userRepository.findByEmail(email) + ?: throw notFound(User::class).identifyBy("email", email) + + data class Request(val email: String, val nickname: String) +} + +private fun User.toUserUpdatedEvent(): UserUpdatedEvent = + UserUpdatedEvent(id!!, email, nickname) diff --git a/domain/src/main/kotlin/tw/waterballsa/gaas/domain/User.kt b/domain/src/main/kotlin/tw/waterballsa/gaas/domain/User.kt index 2510db80..c36aefc8 100644 --- a/domain/src/main/kotlin/tw/waterballsa/gaas/domain/User.kt +++ b/domain/src/main/kotlin/tw/waterballsa/gaas/domain/User.kt @@ -1,14 +1,38 @@ package tw.waterballsa.gaas.domain +import tw.waterballsa.gaas.exceptions.PlatformException + class User( val id: Id? = null, val email: String = "", - val nickname: String = "", + var nickname: String = "", val identities: MutableList = mutableListOf(), ) { @JvmInline value class Id(val value: String) + companion object { + private const val NICKNAME_MINIMUM_BYTE_SIZE = 4 + private const val NICKNAME_MAXIMUM_BYTE_SIZE = 16 + } + + constructor(email: String, nickname: String, identities: MutableList) : + this(null, email, nickname, identities) + + fun changeNickname(nickname: String) { + val nicknameByteSize = nickname.toByteArray().size + + if (nicknameByteSize < NICKNAME_MINIMUM_BYTE_SIZE) { + throw PlatformException("invalid nickname: too short") + } + + if (nicknameByteSize > NICKNAME_MAXIMUM_BYTE_SIZE) { + throw PlatformException("invalid nickname: too long") + } + + this.nickname = nickname + } + fun hasIdentity(identityProviderId: String): Boolean { return identities.contains(identityProviderId) } diff --git a/domain/src/main/kotlin/tw/waterballsa/gaas/events/UserUpdatedEvent.kt b/domain/src/main/kotlin/tw/waterballsa/gaas/events/UserUpdatedEvent.kt new file mode 100644 index 00000000..c9b3c2e2 --- /dev/null +++ b/domain/src/main/kotlin/tw/waterballsa/gaas/events/UserUpdatedEvent.kt @@ -0,0 +1,9 @@ +package tw.waterballsa.gaas.events + +import tw.waterballsa.gaas.domain.User + +class UserUpdatedEvent( + val id: User.Id, + val email: String, + val nickname: String, +) : DomainEvent() diff --git a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/OAuth2Controller.kt b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/OAuth2Controller.kt index 20ed67c9..5944a298 100644 --- a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/OAuth2Controller.kt +++ b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/OAuth2Controller.kt @@ -44,8 +44,13 @@ class OAuth2Controller( } } +val Jwt.email: String + get() = claims["email"]?.toString() + ?: throw PlatformException("JWT email should exist.") + +val Jwt.identityProviderId: String + get() = subject + ?: throw PlatformException("JWT subject should exist.") + private fun Jwt.toRequest(): CreateUserUseCase.Request = - CreateUserUseCase.Request( - email = claims["email"] as String? ?: throw PlatformException("JWT email should exist."), - identityProviderId = subject ?: throw PlatformException("JWT subject should exist.") - ) + CreateUserUseCase.Request(email, identityProviderId) diff --git a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/UserController.kt b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/UserController.kt index a4812668..6b570716 100644 --- a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/UserController.kt +++ b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/UserController.kt @@ -2,17 +2,19 @@ package tw.waterballsa.gaas.spring.controllers import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.oauth2.jwt.Jwt -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* import tw.waterballsa.gaas.application.usecases.GetUserUseCase +import tw.waterballsa.gaas.application.usecases.UpdateUserUseCase import tw.waterballsa.gaas.spring.controllers.presenter.GetUserPresenter +import tw.waterballsa.gaas.spring.controllers.presenter.UpdateUserPresenter import tw.waterballsa.gaas.spring.controllers.viewmodel.GetUserViewModel +import tw.waterballsa.gaas.spring.controllers.viewmodel.UpdateUserViewModel @RestController @RequestMapping("/users") class UserController( - private val getUserUseCase: GetUserUseCase + private val getUserUseCase: GetUserUseCase, + private val updateUserUseCase: UpdateUserUseCase, ) { @GetMapping("/me") fun getUser(@AuthenticationPrincipal principal: Jwt): GetUserViewModel { @@ -21,7 +23,25 @@ class UserController( getUserUseCase.execute(request, presenter) return presenter.viewModel } + + @PutMapping("/me") + fun updateUser( + @AuthenticationPrincipal principal: Jwt, + @RequestBody updateUserRequest: UpdateUserRequest, + ): UpdateUserViewModel { + val request = updateUserRequest.toRequest(principal.email) + val presenter = UpdateUserPresenter() + updateUserUseCase.execute(request, presenter) + return presenter.viewModel + } } private fun Jwt.toRequest(): GetUserUseCase.Request = - GetUserUseCase.Request(claims["email"] as String) + GetUserUseCase.Request(email) + +data class UpdateUserRequest(val nickname: String) { + + fun toRequest(email: String): UpdateUserUseCase.Request = + UpdateUserUseCase.Request(email, nickname) + +} diff --git a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/presenter/UpdateUserPresenter.kt b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/presenter/UpdateUserPresenter.kt new file mode 100644 index 00000000..fa3cdf65 --- /dev/null +++ b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/presenter/UpdateUserPresenter.kt @@ -0,0 +1,20 @@ +package tw.waterballsa.gaas.spring.controllers.presenter + +import tw.waterballsa.gaas.application.usecases.Presenter +import tw.waterballsa.gaas.events.DomainEvent +import tw.waterballsa.gaas.events.UserUpdatedEvent +import tw.waterballsa.gaas.spring.controllers.viewmodel.UpdateUserViewModel +import tw.waterballsa.gaas.spring.extensions.getEvent + +class UpdateUserPresenter : Presenter { + lateinit var viewModel: UpdateUserViewModel + private set + + override fun present(vararg events: DomainEvent) { + viewModel = events.getEvent(UserUpdatedEvent::class)!!.toViewModel() + } + + private fun UserUpdatedEvent.toViewModel(): UpdateUserViewModel = + UpdateUserViewModel(id, email, nickname) + +} \ No newline at end of file diff --git a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/viewmodel/UpdateUserViewModel.kt b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/viewmodel/UpdateUserViewModel.kt new file mode 100644 index 00000000..38889729 --- /dev/null +++ b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/viewmodel/UpdateUserViewModel.kt @@ -0,0 +1,9 @@ +package tw.waterballsa.gaas.spring.controllers.viewmodel + +import tw.waterballsa.gaas.domain.User + +data class UpdateUserViewModel( + val id: User.Id, + val email: String, + val nickname: String +) \ No newline at end of file diff --git a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/SpringUserRepository.kt b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/SpringUserRepository.kt index d24c50ca..de11359e 100644 --- a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/SpringUserRepository.kt +++ b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/SpringUserRepository.kt @@ -17,6 +17,9 @@ class SpringUserRepository( override fun existsUserByEmail(email: String): Boolean = userDAO.existsByEmail(email) + override fun existsUserByNickname(nickname: String): Boolean = + userDAO.existsByNickname(nickname) + override fun createUser(user: User): User = userDAO.save(user.toData()).toDomain() override fun deleteAll() { diff --git a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/dao/UserDAO.kt b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/dao/UserDAO.kt index 05165457..0316159c 100644 --- a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/dao/UserDAO.kt +++ b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/dao/UserDAO.kt @@ -7,5 +7,6 @@ import tw.waterballsa.gaas.spring.repositories.data.UserData @Repository interface UserDAO : MongoRepository { fun existsByEmail(email: String): Boolean + fun existsByNickname(nickname: String): Boolean fun findByEmail(email: String): UserData? } diff --git a/spring/src/test/kotlin/tw/waterballsa/gaas/spring/it/AbstractSpringBootTest.kt b/spring/src/test/kotlin/tw/waterballsa/gaas/spring/it/AbstractSpringBootTest.kt index d2982417..02f4ce46 100644 --- a/spring/src/test/kotlin/tw/waterballsa/gaas/spring/it/AbstractSpringBootTest.kt +++ b/spring/src/test/kotlin/tw/waterballsa/gaas/spring/it/AbstractSpringBootTest.kt @@ -29,11 +29,15 @@ abstract class AbstractSpringBootTest { mutableListOf("google-oauth2|102527320242660434908") ) - protected final fun String.toJwt(): Jwt = + protected final fun String.toJwt(): Jwt = generateJwt(this, mockUser.email) + + protected final fun User.toJwt(): Jwt = generateJwt(identities.first(), email) + + private fun generateJwt(id: String, email: String): Jwt = Jwt.withTokenValue("mock-token") .header("alg", "none") - .subject(this) - .claim("email", mockUser.email) + .subject(id) + .claim("email", email) .build() protected fun ResultActions.getBody(type: Class): T = diff --git a/spring/src/test/kotlin/tw/waterballsa/gaas/spring/it/controllers/UserControllerTest.kt b/spring/src/test/kotlin/tw/waterballsa/gaas/spring/it/controllers/UserControllerTest.kt index 2a97f7f1..200abbd0 100644 --- a/spring/src/test/kotlin/tw/waterballsa/gaas/spring/it/controllers/UserControllerTest.kt +++ b/spring/src/test/kotlin/tw/waterballsa/gaas/spring/it/controllers/UserControllerTest.kt @@ -1,14 +1,19 @@ package tw.waterballsa.gaas.spring.it.controllers +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType import org.springframework.test.web.servlet.ResultActions import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import tw.waterballsa.gaas.application.repositories.UserRepository import tw.waterballsa.gaas.domain.User +import tw.waterballsa.gaas.spring.controllers.UpdateUserRequest +import tw.waterballsa.gaas.spring.controllers.viewmodel.UpdateUserViewModel import tw.waterballsa.gaas.spring.it.AbstractSpringBootTest class UserControllerTest @Autowired constructor( @@ -34,17 +39,70 @@ class UserControllerTest @Autowired constructor( .thenUserNotFound() } + @Test + fun givenUserNamedNeverever_whenChangeUserNicknameToMyNickName_thenUserNicknameShouldBeMyNickName() { + givenUserNickname("Neverever") + .whenChangeUserNickname(UpdateUserRequest("my nick name")) + .thenUserNicknameShouldBeChanged("my nick name") + } + + @Test + fun givenUserNamedNeverever_whenChangeUserNicknameTo周杰倫_thenUserNicknameShouldBe周杰倫() { + givenUserNickname("Neverever") + .whenChangeUserNickname(UpdateUserRequest("周杰倫")) + .thenUserNicknameShouldBeChanged("周杰倫") + } + + @Test + fun givenUserNamedNeverever_whenChangeUserNicknameTooShort_thenShouldChangeNicknameFailed() { + givenUserNickname("Neverever") + .whenChangeUserNickname(UpdateUserRequest("abc")) + .thenShouldChangeNicknameFailed("invalid nickname: too short") + } + + @Test + fun givenUserNamedNeverever_whenChangeUserNicknameTooLong_thenShouldChangeNicknameFailed() { + givenUserNickname("Neverever") + .whenChangeUserNickname(UpdateUserRequest("This is a very long nickname")) + .thenShouldChangeNicknameFailed("invalid nickname: too long") + } + + @Test + fun givenUserNamedNeverever_whenAnotherUserChangeToNeverever_thenShouldChangeNicknameFailed() { + givenUserNickname("Neverever") + givenAnotherUserNickname("周杰倫") + .whenChangeUserNickname(UpdateUserRequest("Neverever")) + .thenShouldChangeNicknameFailed("invalid nickname: duplicated") + } + private fun givenUserDoesNotLogIn(): User = this.mockUser private fun givenUserHasLoggedIn(): User { return userRepository.createUser(mockUser) } + private fun givenUserNickname(nickname: String): User { + val user = User("userA@example.com", nickname, mockUser.identities) + return userRepository.createUser(user) + } + + private fun givenAnotherUserNickname(nickname: String): User { + val user = User("userB@example.com", nickname, mockUser.identities) + return userRepository.createUser(user) + } + private fun User.whenGetUserSelf(): ResultActions { - val jwt = identities.first().toJwt() - return mockMvc.perform(get("/users/me").withJwt(jwt)) + return mockMvc.perform(get("/users/me").withJwt(toJwt())) } + private fun User.whenChangeUserNickname(updateUserRequest: UpdateUserRequest): ResultActions = + mockMvc.perform( + put("/users/me") + .contentType(MediaType.APPLICATION_JSON) + .content(updateUserRequest.toJson()) + .withJwt(toJwt()) + ) + private fun ResultActions.thenGetUserSuccessfully() { this.andExpect(status().isOk) .andExpect(jsonPath("$.id").value(mockUser.id!!.value)) @@ -59,4 +117,18 @@ class UserControllerTest @Autowired constructor( .andExpect(jsonPath("$.nickname").doesNotExist()) } + private fun ResultActions.thenUserNicknameShouldBeChanged(nickname: String) { + val user = andExpect(status().isOk) + .getBody(UpdateUserViewModel::class.java) + + userRepository.findById(user.id) + .also { assertThat(it).isNotNull } + .also { assertThat(it!!.nickname).isEqualTo(nickname) } + } + + private fun ResultActions.thenShouldChangeNicknameFailed(message: String) { + andExpect(status().isBadRequest) + .andExpect(jsonPath("$.message").value(message)) + } + }