Skip to content

Commit

Permalink
feature: PUT /users/me (#109)
Browse files Browse the repository at this point in the history
* feature: update user info

* refactor: ktlint

* refactor: nickname validation

* refactor: jwt property extension

* refactor: remove view model setter

* refactor: coding style

* refactor: jwt property extension

* refactor: coding style

* refactor: coding style

* refactor: coding style
  • Loading branch information
lohas1107 authored Jul 9, 2023
1 parent 2fa1775 commit b199716
Show file tree
Hide file tree
Showing 12 changed files with 226 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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<Id>): List<User>
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
26 changes: 25 additions & 1 deletion domain/src/main/kotlin/tw/waterballsa/gaas/domain/User.kt
Original file line number Diff line number Diff line change
@@ -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<String> = 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<String>) :
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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)

}
Original file line number Diff line number Diff line change
@@ -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)

}
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ import tw.waterballsa.gaas.spring.repositories.data.UserData
@Repository
interface UserDAO : MongoRepository<UserData, String> {
fun existsByEmail(email: String): Boolean
fun existsByNickname(nickname: String): Boolean
fun findByEmail(email: String): UserData?
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T> ResultActions.getBody(type: Class<T>): T =
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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("[email protected]", nickname, mockUser.identities)
return userRepository.createUser(user)
}

private fun givenAnotherUserNickname(nickname: String): User {
val user = User("[email protected]", 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))
Expand All @@ -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))
}

}

0 comments on commit b199716

Please sign in to comment.