Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: PUT /users/me #109

Merged
merged 10 commits into from
Jul 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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))
}

}