From e89ba7adcead9c1b1c2db36a31e61fb03448b870 Mon Sep 17 00:00:00 2001 From: KuoChe Date: Mon, 4 Nov 2024 10:18:44 +0800 Subject: [PATCH 1/2] feature: game comment --- .../repositories/GameCommentRepository.kt | 11 ++ .../usecases/CommentGameUseCase.kt | 66 ++++++++ .../usecases/UpdateGameCommentUseCase.kt | 52 +++++++ .../tw/waterballsa/gaas/domain/GameComment.kt | 19 +++ .../gaas/domain/GameRegistration.kt | 16 ++ .../kotlin/tw/waterballsa/gaas/domain/User.kt | 4 +- .../gaas/events/CommentGameEvent.kt | 11 ++ .../gaas/exceptions/enums/PlatformError.kt | 4 + .../controllers/GameCommentController.kt | 67 +++++++++ .../controllers/GameRegistrationController.kt | 4 + .../controllers/presenter/GetUserPresenter.kt | 3 +- .../controllers/viewmodel/GetUserViewModel.kt | 1 + .../eventbus/CommentGameEventListener.kt | 25 +++ .../SpringGameCommentRepository.kt | 26 ++++ .../spring/repositories/dao/GameCommentDAO.kt | 10 ++ .../repositories/dao/GameRegistrationDAO.kt | 4 + .../repositories/data/GameCommentData.kt | 29 ++++ .../repositories/data/GameRegistrationData.kt | 6 +- .../gaas/spring/repositories/data/UserData.kt | 9 +- .../controllers/GameCommentControllerTest.kt | 142 ++++++++++++++++++ .../spring/models/TestCommentGameRequest.kt | 7 + .../models/TestUpdateGameCommentRequest.kt | 6 + .../tw/waterballsa/gaas/spring/utils/Users.kt | 19 ++- 23 files changed, 530 insertions(+), 11 deletions(-) create mode 100644 application/src/main/kotlin/tw/waterballsa/gaas/application/repositories/GameCommentRepository.kt create mode 100644 application/src/main/kotlin/tw/waterballsa/gaas/application/usecases/CommentGameUseCase.kt create mode 100644 application/src/main/kotlin/tw/waterballsa/gaas/application/usecases/UpdateGameCommentUseCase.kt create mode 100644 domain/src/main/kotlin/tw/waterballsa/gaas/domain/GameComment.kt create mode 100644 domain/src/main/kotlin/tw/waterballsa/gaas/events/CommentGameEvent.kt create mode 100644 spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/GameCommentController.kt create mode 100644 spring/src/main/kotlin/tw/waterballsa/gaas/spring/eventbus/CommentGameEventListener.kt create mode 100644 spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/SpringGameCommentRepository.kt create mode 100644 spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/dao/GameCommentDAO.kt create mode 100644 spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/data/GameCommentData.kt create mode 100644 spring/src/test/kotlin/tw/waterballsa/gaas/spring/it/controllers/GameCommentControllerTest.kt create mode 100644 spring/src/test/kotlin/tw/waterballsa/gaas/spring/models/TestCommentGameRequest.kt create mode 100644 spring/src/test/kotlin/tw/waterballsa/gaas/spring/models/TestUpdateGameCommentRequest.kt diff --git a/application/src/main/kotlin/tw/waterballsa/gaas/application/repositories/GameCommentRepository.kt b/application/src/main/kotlin/tw/waterballsa/gaas/application/repositories/GameCommentRepository.kt new file mode 100644 index 00000000..341259f2 --- /dev/null +++ b/application/src/main/kotlin/tw/waterballsa/gaas/application/repositories/GameCommentRepository.kt @@ -0,0 +1,11 @@ +package tw.waterballsa.gaas.application.repositories + +import tw.waterballsa.gaas.domain.GameComment +import tw.waterballsa.gaas.domain.GameRegistration +import tw.waterballsa.gaas.domain.User + +interface GameCommentRepository { + fun commentGame(gameComment: GameComment) + fun updateGameComment(gameComment: GameComment) + fun findByGameIdAndUserId(gameId: GameRegistration.Id, userId: User.Id): GameComment? +} diff --git a/application/src/main/kotlin/tw/waterballsa/gaas/application/usecases/CommentGameUseCase.kt b/application/src/main/kotlin/tw/waterballsa/gaas/application/usecases/CommentGameUseCase.kt new file mode 100644 index 00000000..954a11c3 --- /dev/null +++ b/application/src/main/kotlin/tw/waterballsa/gaas/application/usecases/CommentGameUseCase.kt @@ -0,0 +1,66 @@ +package tw.waterballsa.gaas.application.usecases + +import tw.waterballsa.gaas.application.eventbus.EventBus +import tw.waterballsa.gaas.application.repositories.GameCommentRepository +import tw.waterballsa.gaas.application.repositories.UserRepository +import tw.waterballsa.gaas.domain.GameComment +import tw.waterballsa.gaas.domain.GameRegistration +import tw.waterballsa.gaas.domain.User +import tw.waterballsa.gaas.events.CommentGameEvent +import tw.waterballsa.gaas.exceptions.NotFoundException.Companion.notFound +import tw.waterballsa.gaas.exceptions.PlatformException +import tw.waterballsa.gaas.exceptions.enums.PlatformError +import javax.inject.Named + +@Named +class CommentGameUseCase( + private val userRepository: UserRepository, + private val gameRatingRepository: GameCommentRepository, + private val eventBus: EventBus, +) { + + fun execute(request: Request) { + val commentUser = getCommentUser(request.identityProviderId) + val gameId = GameRegistration.Id(request.gameId) + val userId = commentUser.id!! + + validateCommentEligibility(commentUser, gameId) + createGameComment(gameId, userId, request.rating, request.comment) + eventBus.broadcast(CommentGameEvent(gameId, userId, request.rating.toLong(), 1)) + } + + private fun getCommentUser(identityProviderId: String): User { + return userRepository.findByIdentity(identityProviderId) + ?: throw notFound(PlatformError.USER_NOT_FOUND, User::class).message() + } + + private fun validateCommentEligibility(user: User, gameId: GameRegistration.Id) { + user.validateGamePlayed(gameId) + validateNoExistingRating(gameId, user.id!!) + } + + private fun createGameComment(gameId: GameRegistration.Id, userId: User.Id, rating: Int, comment: String) { + val newRating = GameComment(gameId, userId, rating, comment) + gameRatingRepository.commentGame(newRating) + } + + private fun User.validateGamePlayed(gameId: GameRegistration.Id) { + val playedGamesIds = playedGamesIds ?: emptySet() + if (gameId !in playedGamesIds) { + throw PlatformException(PlatformError.GAME_NOT_PLAYED, "Must play game before comment.") + } + } + + private fun validateNoExistingRating(gameId: GameRegistration.Id, userId: User.Id) { + if (gameRatingRepository.findByGameIdAndUserId(gameId, userId) != null) { + throw PlatformException(PlatformError.GAME_COMMENT_DUPLICATED, "Game already commented.") + } + } + + data class Request( + val identityProviderId: String, + val gameId: String, + val rating: Int, + val comment: String, + ) +} diff --git a/application/src/main/kotlin/tw/waterballsa/gaas/application/usecases/UpdateGameCommentUseCase.kt b/application/src/main/kotlin/tw/waterballsa/gaas/application/usecases/UpdateGameCommentUseCase.kt new file mode 100644 index 00000000..398fb7b7 --- /dev/null +++ b/application/src/main/kotlin/tw/waterballsa/gaas/application/usecases/UpdateGameCommentUseCase.kt @@ -0,0 +1,52 @@ +package tw.waterballsa.gaas.application.usecases + +import tw.waterballsa.gaas.application.eventbus.EventBus +import tw.waterballsa.gaas.application.repositories.GameCommentRepository +import tw.waterballsa.gaas.application.repositories.UserRepository +import tw.waterballsa.gaas.domain.GameComment +import tw.waterballsa.gaas.domain.GameRegistration +import tw.waterballsa.gaas.domain.User +import tw.waterballsa.gaas.events.CommentGameEvent +import tw.waterballsa.gaas.exceptions.NotFoundException.Companion.notFound +import tw.waterballsa.gaas.exceptions.enums.PlatformError +import java.time.Instant +import javax.inject.Named + +@Named +class UpdateGameCommentUseCase( + private val userRepository: UserRepository, + private val gameRatingRepository: GameCommentRepository, + private val eventBus: EventBus, +) { + + fun execute(request: Request) { + val commentUser = getCommentUser(request.identityProviderId) + val gameId = GameRegistration.Id(request.gameId) + val userId = commentUser.id!! + + val gameComment = gameRatingRepository.findByGameIdAndUserId(gameId, userId) + ?: throw notFound(PlatformError.GAME_COMMENT_NOT_FOUND, GameComment::class).message() + val originRating = gameComment.rating + + gameComment.apply { + rating = request.rating + comment = request.comment + lastUpdatedTime = Instant.now() + } + + gameRatingRepository.updateGameComment(gameComment) + eventBus.broadcast(CommentGameEvent(gameId, userId, originRating - request.rating.toLong(), 0)) + } + + private fun getCommentUser(identityProviderId: String): User { + return userRepository.findByIdentity(identityProviderId) + ?: throw notFound(PlatformError.USER_NOT_FOUND, User::class).message() + } + + data class Request( + val identityProviderId: String, + val gameId: String, + val rating: Int, + val comment: String, + ) +} diff --git a/domain/src/main/kotlin/tw/waterballsa/gaas/domain/GameComment.kt b/domain/src/main/kotlin/tw/waterballsa/gaas/domain/GameComment.kt new file mode 100644 index 00000000..02422bd7 --- /dev/null +++ b/domain/src/main/kotlin/tw/waterballsa/gaas/domain/GameComment.kt @@ -0,0 +1,19 @@ +package tw.waterballsa.gaas.domain + +import java.time.Instant + +class GameComment( + val id: Id? = null, + val gameId: GameRegistration.Id, + val userId: User.Id, + var rating: Int, + var comment: String, + var lastUpdatedTime: Instant, + val createdTime: Instant, +) { + constructor(gameId: GameRegistration.Id, userId: User.Id, rating: Int, comment: String) : + this(null, gameId, userId, rating, comment, Instant.now(), Instant.now()) + + @JvmInline + value class Id(val value: String) +} diff --git a/domain/src/main/kotlin/tw/waterballsa/gaas/domain/GameRegistration.kt b/domain/src/main/kotlin/tw/waterballsa/gaas/domain/GameRegistration.kt index 0df8c9db..c5f07dac 100644 --- a/domain/src/main/kotlin/tw/waterballsa/gaas/domain/GameRegistration.kt +++ b/domain/src/main/kotlin/tw/waterballsa/gaas/domain/GameRegistration.kt @@ -1,5 +1,7 @@ package tw.waterballsa.gaas.domain +import java.math.BigDecimal +import java.math.RoundingMode import java.time.Instant class GameRegistration( @@ -14,7 +16,21 @@ class GameRegistration( var frontEndUrl: String, var backEndUrl: String, val createdOn: Instant, + val totalRating: Long? = null, + val numberOfComments: Long? = null, ) { @JvmInline value class Id(val value: String) + + fun rating(): Double { + val total = totalRating ?: 0 + val number = numberOfComments ?: 0 + return if (number == 0L) { + 0.0 + } else { + BigDecimal.valueOf(total) + .divide(BigDecimal.valueOf(number), 1, RoundingMode.HALF_UP) + .toDouble() + } + } } 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 ab427fbc..155ee7c4 100644 --- a/domain/src/main/kotlin/tw/waterballsa/gaas/domain/User.kt +++ b/domain/src/main/kotlin/tw/waterballsa/gaas/domain/User.kt @@ -8,8 +8,8 @@ class User( val email: String = "", var nickname: String = "", val identities: MutableList = mutableListOf(), - val lastPlayedGameId: String? = null, - val playedGamesIds: Set? = null, + val lastPlayedGameId: GameRegistration.Id? = null, + val playedGamesIds: Set? = null, ) { @JvmInline value class Id(val value: String) diff --git a/domain/src/main/kotlin/tw/waterballsa/gaas/events/CommentGameEvent.kt b/domain/src/main/kotlin/tw/waterballsa/gaas/events/CommentGameEvent.kt new file mode 100644 index 00000000..afce755f --- /dev/null +++ b/domain/src/main/kotlin/tw/waterballsa/gaas/events/CommentGameEvent.kt @@ -0,0 +1,11 @@ +package tw.waterballsa.gaas.events + +import tw.waterballsa.gaas.domain.GameRegistration +import tw.waterballsa.gaas.domain.User + +data class CommentGameEvent( + val gameId: GameRegistration.Id, + val userId: User.Id, + val incrementRating: Long, + val incrementNumberOfComments: Long, +) : DomainEvent() diff --git a/domain/src/main/kotlin/tw/waterballsa/gaas/exceptions/enums/PlatformError.kt b/domain/src/main/kotlin/tw/waterballsa/gaas/exceptions/enums/PlatformError.kt index 849fe744..cbb27473 100644 --- a/domain/src/main/kotlin/tw/waterballsa/gaas/exceptions/enums/PlatformError.kt +++ b/domain/src/main/kotlin/tw/waterballsa/gaas/exceptions/enums/PlatformError.kt @@ -17,6 +17,10 @@ enum class PlatformError( GAME_START_FAILED("G005"), GAME_NOT_STARTED("G006"), + GAME_NOT_PLAYED("G007"), + GAME_COMMENT_DUPLICATED("G008"), + GAME_COMMENT_NOT_FOUND("G009"), + USER_NOT_FOUND("U001"), USER_INPUT_INVALID("U002"), USER_NAME_DUPLICATED("U003"), diff --git a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/GameCommentController.kt b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/GameCommentController.kt new file mode 100644 index 00000000..6e1db4c1 --- /dev/null +++ b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/GameCommentController.kt @@ -0,0 +1,67 @@ +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.* +import tw.waterballsa.gaas.application.usecases.CommentGameUseCase +import tw.waterballsa.gaas.application.usecases.UpdateGameCommentUseCase +import tw.waterballsa.gaas.exceptions.PlatformException +import tw.waterballsa.gaas.exceptions.enums.PlatformError.JWT_ERROR +import tw.waterballsa.gaas.spring.controllers.viewmodel.PlatformViewModel + +@RestController +@RequestMapping("/comments") +class GameCommentController( + private val commentGameUserCase: CommentGameUseCase, + private val updateGameCommentUseCase: UpdateGameCommentUseCase, +) { + + @PostMapping + fun commentGame( + @AuthenticationPrincipal jwt: Jwt, + @RequestBody request: CommentGameRequest + ): PlatformViewModel { + commentGameUserCase.execute( + CommentGameUseCase.Request( + jwt.identityProviderId, + request.gameId, + request.rating, + request.comment + ) + ) + return PlatformViewModel.success() + } + + + @PostMapping("/games/{gameId}") + fun updateGameComment( + @AuthenticationPrincipal jwt: Jwt, + @PathVariable gameId: String, + @RequestBody request: UpdateGameCommentRequest + ): PlatformViewModel { + updateGameCommentUseCase.execute( + UpdateGameCommentUseCase.Request( + jwt.identityProviderId, + gameId, + request.rating, + request.comment, + ) + ) + return PlatformViewModel.success() + } + + + data class CommentGameRequest( + val gameId: String, + val rating: Int, + val comment: String, + ) + + data class UpdateGameCommentRequest( + val rating: Int, + val comment: String, + ) +} + +private val Jwt.identityProviderId: String + get() = subject ?: throw PlatformException(JWT_ERROR, "identityProviderId should exist.") \ No newline at end of file diff --git a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/GameRegistrationController.kt b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/GameRegistrationController.kt index b5317cca..3e43e0af 100644 --- a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/GameRegistrationController.kt +++ b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/GameRegistrationController.kt @@ -133,6 +133,8 @@ class GetGameRegistrationPresenter : GetGameRegistrationsUsecase.Presenter { minPlayers = minPlayers, maxPlayers = maxPlayers, createdOn = createdOn, + rating = rating(), + numberOfComments = numberOfComments ?: 0L ) data class GetGamesViewModel( @@ -142,5 +144,7 @@ class GetGameRegistrationPresenter : GetGameRegistrationsUsecase.Presenter { val minPlayers: Int, val maxPlayers: Int, val createdOn: Instant, + val rating: Double, + val numberOfComments: Long, ) } diff --git a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/presenter/GetUserPresenter.kt b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/presenter/GetUserPresenter.kt index e13b9abb..e429d809 100644 --- a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/presenter/GetUserPresenter.kt +++ b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/presenter/GetUserPresenter.kt @@ -16,6 +16,7 @@ class GetUserPresenter : GetUserUseCase.Presenter { id = id!!.value, email = email, nickname = nickname, - lastPlayedGameId = lastPlayedGameId, + lastPlayedGameId = lastPlayedGameId?.value, + playedGamesIds = playedGamesIds?.map { it.value }?.toSet(), ) } \ No newline at end of file diff --git a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/viewmodel/GetUserViewModel.kt b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/viewmodel/GetUserViewModel.kt index 8a5c7d5f..de32d7c3 100644 --- a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/viewmodel/GetUserViewModel.kt +++ b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/controllers/viewmodel/GetUserViewModel.kt @@ -5,4 +5,5 @@ data class GetUserViewModel( val email: String, val nickname: String, val lastPlayedGameId: String?, + val playedGamesIds: Set?, ) diff --git a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/eventbus/CommentGameEventListener.kt b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/eventbus/CommentGameEventListener.kt new file mode 100644 index 00000000..22efeaa0 --- /dev/null +++ b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/eventbus/CommentGameEventListener.kt @@ -0,0 +1,25 @@ +package tw.waterballsa.gaas.spring.eventbus + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Component +import tw.waterballsa.gaas.events.CommentGameEvent +import tw.waterballsa.gaas.spring.repositories.dao.GameRegistrationDAO +import kotlin.reflect.KClass + +@Component +class CommentGameEventListener( + override val eventType: KClass, + private val gameRegistrationDAO: GameRegistrationDAO, +) : EventListener { + + @Autowired + constructor(gameRegistrationDAO: GameRegistrationDAO): this(CommentGameEvent::class, gameRegistrationDAO) + + override fun onEvents(events: List) { + events.forEach { + gameRegistrationDAO.incrementTotalRatingAndNumberOfCommentsById( + it.gameId.value, it.incrementRating, it.incrementNumberOfComments + ) + } + } +} \ No newline at end of file diff --git a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/SpringGameCommentRepository.kt b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/SpringGameCommentRepository.kt new file mode 100644 index 00000000..a6221b50 --- /dev/null +++ b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/SpringGameCommentRepository.kt @@ -0,0 +1,26 @@ +package tw.waterballsa.gaas.spring.repositories + +import org.springframework.stereotype.Component +import tw.waterballsa.gaas.application.repositories.GameCommentRepository +import tw.waterballsa.gaas.domain.GameComment +import tw.waterballsa.gaas.domain.GameRegistration +import tw.waterballsa.gaas.domain.User +import tw.waterballsa.gaas.spring.repositories.dao.GameCommentDAO +import tw.waterballsa.gaas.spring.repositories.data.toData + +@Component +class SpringGameCommentRepository( + private val gameCommentDAO: GameCommentDAO, +): GameCommentRepository { + override fun commentGame(gameComment: GameComment) { + gameCommentDAO.save(gameComment.toData()).toDomain() + } + + override fun updateGameComment(gameComment: GameComment) { + gameCommentDAO.save(gameComment.toData()).toDomain() + } + + override fun findByGameIdAndUserId(gameId: GameRegistration.Id, userId: User.Id): GameComment? { + return gameCommentDAO.findByGameIdAndUserId(gameId.value, userId.value)?.toDomain() + } +} \ No newline at end of file diff --git a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/dao/GameCommentDAO.kt b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/dao/GameCommentDAO.kt new file mode 100644 index 00000000..b7ed5468 --- /dev/null +++ b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/dao/GameCommentDAO.kt @@ -0,0 +1,10 @@ +package tw.waterballsa.gaas.spring.repositories.dao + +import org.springframework.data.mongodb.repository.MongoRepository +import org.springframework.stereotype.Repository +import tw.waterballsa.gaas.spring.repositories.data.GameCommentData + +@Repository +interface GameCommentDAO: MongoRepository { + fun findByGameIdAndUserId(gameId: String, userId: String): GameCommentData? +} \ No newline at end of file diff --git a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/dao/GameRegistrationDAO.kt b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/dao/GameRegistrationDAO.kt index ded3ef43..b068fe35 100644 --- a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/dao/GameRegistrationDAO.kt +++ b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/dao/GameRegistrationDAO.kt @@ -14,4 +14,8 @@ interface GameRegistrationDAO : MongoRepository { @Query("{ '_id' : ?0 }") @Update("{ '\$inc' : { 'timesPlayed' : ?1 } }") fun incrementTimesPlayedById(id: String, increment: Long = 1) + + @Query("{ '_id' : ?0 }") + @Update("{ '\$inc' : { 'totalRating' : ?1, 'numberOfComments' : ?2 } }") + fun incrementTotalRatingAndNumberOfCommentsById(id: String, totalRating: Long, numberOfComments: Long) } diff --git a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/data/GameCommentData.kt b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/data/GameCommentData.kt new file mode 100644 index 00000000..9f6c4914 --- /dev/null +++ b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/data/GameCommentData.kt @@ -0,0 +1,29 @@ +package tw.waterballsa.gaas.spring.repositories.data + +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.mapping.Document +import tw.waterballsa.gaas.domain.GameComment +import tw.waterballsa.gaas.domain.GameRegistration +import tw.waterballsa.gaas.domain.User +import java.time.Instant + +@Document +class GameCommentData( + @Id + var id: String?, + var gameId: String, + var userId: String, + var rating: Int, + var comment: String, + var createdTime: Instant, + var lastUpdatedTime: Instant, +) { + fun toDomain(): GameComment = GameComment( + id?.let { GameComment.Id(it) }, GameRegistration.Id(gameId), User.Id(userId), rating, comment, + lastUpdatedTime, createdTime, + ) +} + +fun GameComment.toData(): GameCommentData = GameCommentData( + id?.value, gameId.value, userId.value, rating, comment, createdTime, lastUpdatedTime +) \ No newline at end of file diff --git a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/data/GameRegistrationData.kt b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/data/GameRegistrationData.kt index bec1611c..b968de59 100644 --- a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/data/GameRegistrationData.kt +++ b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/data/GameRegistrationData.kt @@ -23,6 +23,8 @@ class GameRegistrationData( var backEndUrl: String?, val createdOn: Instant?, var timesPlayed: Long? = null, + var totalRating: Long? = null, + var numberOfComments: Long? = null, ) { @DBRef var logs: MutableList = mutableListOf() @@ -39,7 +41,9 @@ class GameRegistrationData( maxPlayers!!, frontEndUrl!!, backEndUrl!!, - createdOn!! + createdOn!!, + totalRating, + numberOfComments, ) } diff --git a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/data/UserData.kt b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/data/UserData.kt index 4583da63..2dd32575 100644 --- a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/data/UserData.kt +++ b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/data/UserData.kt @@ -2,6 +2,7 @@ package tw.waterballsa.gaas.spring.repositories.data import org.springframework.data.annotation.Id import org.springframework.data.mongodb.core.mapping.Document +import tw.waterballsa.gaas.domain.GameRegistration import tw.waterballsa.gaas.domain.User @Document @@ -21,8 +22,8 @@ class UserData( email, nickname, identities.toMutableList(), - lastPlayedGameId, - playedGamesIds, + lastPlayedGameId?.let { GameRegistration.Id(it) }, + playedGamesIds?.map { GameRegistration.Id(it) }?.toSet(), ) } @@ -32,6 +33,6 @@ fun User.toData(): UserData = email = email, nickname = nickname, identities = identities, - lastPlayedGameId = lastPlayedGameId, - playedGamesIds = playedGamesIds, + lastPlayedGameId = lastPlayedGameId?.value, + playedGamesIds = playedGamesIds?.map { it.value }?.toSet(), ) diff --git a/spring/src/test/kotlin/tw/waterballsa/gaas/spring/it/controllers/GameCommentControllerTest.kt b/spring/src/test/kotlin/tw/waterballsa/gaas/spring/it/controllers/GameCommentControllerTest.kt new file mode 100644 index 00000000..d000091e --- /dev/null +++ b/spring/src/test/kotlin/tw/waterballsa/gaas/spring/it/controllers/GameCommentControllerTest.kt @@ -0,0 +1,142 @@ +package tw.waterballsa.gaas.spring.it.controllers + +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import tw.waterballsa.gaas.application.repositories.GameRegistrationRepository +import tw.waterballsa.gaas.application.repositories.UserRepository +import tw.waterballsa.gaas.domain.GameRegistration +import tw.waterballsa.gaas.domain.User +import tw.waterballsa.gaas.spring.it.AbstractSpringBootTest +import tw.waterballsa.gaas.spring.models.TestCommentGameRequest +import tw.waterballsa.gaas.spring.models.TestUpdateGameCommentRequest +import tw.waterballsa.gaas.spring.utils.Users.Companion.defaultUserBuilder +import java.time.Instant +import java.util.UUID.randomUUID + +class GameCommentControllerTest @Autowired constructor( + val userRepository: UserRepository, + val gameRegistrationRepository: GameRegistrationRepository, +) : AbstractSpringBootTest() { + lateinit var testGame: GameRegistration + + @BeforeEach + fun setUp() { + userRepository.deleteAll() + gameRegistrationRepository.deleteAll() + + testGame = registerGame() + } + + @Test + fun givenUserHasPlayedMahjongGame_whenUserCommentGame_thenShouldSuccessful() { + val gameId = testGame.id!! + val user = prepareUser(listOf(gameId)) + user.whenCommentGame(gameId.value) + .thenActionSuccessfully() + } + + + @Test + fun givenUserHasNotPlayedMahjongGame_whenUserCommentGame_thenShouldFail(){ + val gameId = testGame.id!! + val user = prepareUser(emptyList()) + user.whenCommentGame(gameId.value) + .thenShouldFail("Must play game before comment.") + } + + @Test + fun givenUserAlreadyCommentMahjongGame_whenUserComment_thenShouldFail(){ + val gameId = testGame.id!! + val user = prepareUser(listOf(gameId)) + user.whenCommentGame(gameId.value) + user.whenCommentGame(gameId.value) + .thenShouldFail("Game already commented.") + } + + @Test + fun givenUserAlreadyCommentMahjongGame_whenUserUpdateComment_thenShouldSuccessful(){ + val gameId = testGame.id!! + val user = prepareUser(listOf(gameId)) + user.whenCommentGame(gameId.value) + user.whenUpdateComment(gameId.value) + .thenActionSuccessfully() + } + + @Test + fun givenUserNotCommentMahjongGame_whenUserUpdateComment_thenShouldNotFound(){ + val gameId = testGame.id!! + val user = prepareUser(listOf(gameId)) + user.whenUpdateComment(gameId.value) + .thenShouldNotFound("GameComment not found") + } + + private fun prepareUser(playedGamesIds: List): User { + val user = defaultUserBuilder("1") + .nickname("user-${randomUUID()}") + .identities("google-oauth2|102527320242660434908") + .lastPlayedGameId(playedGamesIds.lastOrNull()) + .playedGamesIds(playedGamesIds.toSet()) + .build() + userRepository.createUser(user) + return user + } + + private fun registerGame(): GameRegistration = gameRegistrationRepository.registerGame( + GameRegistration( + uniqueName = "Mahjong-python", + displayName = "麻將-Python", + shortDescription = "A simple game.", + rule = "Follow the rules to win.", + imageUrl = "https://example.com/game01.jpg", + minPlayers = 2, + maxPlayers = 4, + frontEndUrl = "https://example.com/play/game01", + backEndUrl = "https://example.com/api/game01", + createdOn = Instant.parse("2024-03-01T14:00:00.00Z"), + ) + ) + + private fun User.whenCommentGame( + gameId: String, + rating: Int = 5, + comment: String = "It's goooood!" + ): ResultActions { + return mockMvc.perform( + post("/comments") + .withJwt(toJwt()) + .withJson(TestCommentGameRequest(gameId, rating, comment)) + ) + } + + private fun User.whenUpdateComment( + gameId: String, + rating: Int = 3, + comment: String = "Normal!" + ): ResultActions { + return mockMvc.perform( + post("/comments/games/${gameId}") + .withJwt(toJwt()) + .withJson(TestUpdateGameCommentRequest(rating, comment)) + ) + } + + private fun ResultActions.thenActionSuccessfully(): ResultActions { + return andExpect(status().isOk) + .andExpect(jsonPath("$.message").value("success")) + } + + private fun ResultActions.thenShouldFail(message: String): ResultActions { + return andExpect(status().isBadRequest) + .andExpect(jsonPath("$.message").value(message)) + } + + private fun ResultActions.thenShouldNotFound(message: String): ResultActions{ + return andExpect(status().isNotFound) + .andExpect(jsonPath("$.message").value(message)) + } +} \ No newline at end of file diff --git a/spring/src/test/kotlin/tw/waterballsa/gaas/spring/models/TestCommentGameRequest.kt b/spring/src/test/kotlin/tw/waterballsa/gaas/spring/models/TestCommentGameRequest.kt new file mode 100644 index 00000000..1bb83617 --- /dev/null +++ b/spring/src/test/kotlin/tw/waterballsa/gaas/spring/models/TestCommentGameRequest.kt @@ -0,0 +1,7 @@ +package tw.waterballsa.gaas.spring.models + +class TestCommentGameRequest ( + val gameId: String, + val rating: Int, + val comment: String, +) \ No newline at end of file diff --git a/spring/src/test/kotlin/tw/waterballsa/gaas/spring/models/TestUpdateGameCommentRequest.kt b/spring/src/test/kotlin/tw/waterballsa/gaas/spring/models/TestUpdateGameCommentRequest.kt new file mode 100644 index 00000000..b178feec --- /dev/null +++ b/spring/src/test/kotlin/tw/waterballsa/gaas/spring/models/TestUpdateGameCommentRequest.kt @@ -0,0 +1,6 @@ +package tw.waterballsa.gaas.spring.models + +class TestUpdateGameCommentRequest( + val rating: Int, + val comment: String, +) \ No newline at end of file diff --git a/spring/src/test/kotlin/tw/waterballsa/gaas/spring/utils/Users.kt b/spring/src/test/kotlin/tw/waterballsa/gaas/spring/utils/Users.kt index ad6f9219..3384949d 100644 --- a/spring/src/test/kotlin/tw/waterballsa/gaas/spring/utils/Users.kt +++ b/spring/src/test/kotlin/tw/waterballsa/gaas/spring/utils/Users.kt @@ -1,5 +1,6 @@ package tw.waterballsa.gaas.spring.utils +import tw.waterballsa.gaas.domain.GameRegistration import tw.waterballsa.gaas.domain.User class Users private constructor() { @@ -9,7 +10,7 @@ class Users private constructor() { } - fun defaultUser(id: String): User{ + fun defaultUser(id: String): User { return defaultUserBuilder(id).build() } } @@ -18,6 +19,8 @@ class Users private constructor() { var email: String = "user${id.value}@gmail.com" var nickname: String = "user-${id.value}" var identities: MutableList = mutableListOf("google-oauth2|${id.value}") + var lastPlayedGameId: GameRegistration.Id? = null + var playedGamesIds: Set? = null fun id(id: String): UserBuilder { this.id = User.Id(id) @@ -39,9 +42,19 @@ class Users private constructor() { return this } - fun build(): User{ + fun lastPlayedGameId(lastPlayedGameId: GameRegistration.Id?): UserBuilder { + this.lastPlayedGameId = lastPlayedGameId + return this + } + + fun playedGamesIds(playedGamesIds: Set?): UserBuilder { + this.playedGamesIds = playedGamesIds + return this + } + + fun build(): User { return User( - id, email, nickname, identities + id, email, nickname, identities, lastPlayedGameId, playedGamesIds ) } } From 923b0aee51193f0151ddbf20ec6494a5ec1e5d4a Mon Sep 17 00:00:00 2001 From: KuoChe Date: Sat, 9 Nov 2024 02:41:39 +0800 Subject: [PATCH 2/2] feature: game sort by rating --- .../SpringGameRegistrationRepository.kt | 28 +++++-- .../GameRegistrationControllerTest.kt | 81 +++++++++++++++---- 2 files changed, 88 insertions(+), 21 deletions(-) diff --git a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/SpringGameRegistrationRepository.kt b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/SpringGameRegistrationRepository.kt index c2bcc316..8403dde6 100644 --- a/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/SpringGameRegistrationRepository.kt +++ b/spring/src/main/kotlin/tw/waterballsa/gaas/spring/repositories/SpringGameRegistrationRepository.kt @@ -2,7 +2,8 @@ package tw.waterballsa.gaas.spring.repositories import org.springframework.data.domain.Sort import org.springframework.data.domain.Sort.Order -import org.springframework.data.mongodb.core.query.Update +import org.springframework.data.mongodb.core.MongoTemplate +import org.springframework.data.mongodb.core.aggregation.Aggregation.* import org.springframework.stereotype.Component import tw.waterballsa.gaas.application.repositories.GameRegistrationRepository import tw.waterballsa.gaas.domain.GameRegistration @@ -14,7 +15,8 @@ import tw.waterballsa.gaas.spring.repositories.data.toData @Component class SpringGameRegistrationRepository( - private val gameRegistrationDAO: GameRegistrationDAO + private val gameRegistrationDAO: GameRegistrationDAO, + private val mongoTemplate: MongoTemplate, ) : GameRegistrationRepository { override fun registerGame(gameRegistration: GameRegistration): GameRegistration = gameRegistrationDAO.save(gameRegistration.toData()).toDomain() @@ -31,10 +33,21 @@ class SpringGameRegistrationRepository( override fun existsByUniqueName(uniqueName: String): Boolean = gameRegistrationDAO.existsByUniqueName(uniqueName) override fun findGameRegistrations(sortBy: String?): List { - return SortBy.from(sortBy) - ?.let { Sort.by(it.orders) } - ?.run { gameRegistrationDAO.findAll(this).map { it.toDomain() } } - ?: gameRegistrationDAO.findAll().map { it.toDomain() } + val agg = + SortBy.from(sortBy)?.let { + newAggregation( + addFields() + .addField("rating").withValueOfExpression("totalRating / numberOfComments") + .build(), + sort(Sort.by(it.orders)) + ) + } ?: newAggregation( + addFields() + .addField("rating").withValueOfExpression("totalRating / numberOfComments") + .build(), + ) + return mongoTemplate.aggregate(agg, "gameRegistrationData", GameRegistrationData::class.java) + .mappedResults.map { it.toDomain() } } override fun findById(id: Id): GameRegistration? = @@ -45,7 +58,8 @@ class SpringGameRegistrationRepository( enum class SortBy(val value: String, val orders: List) { CREATED_ON("createdOn", listOf(Order.desc("createdOn"), Order.desc("_id"))), - TIMES_PLAYED("timesPlayed", listOf(Order.desc("timesPlayed"), Order.desc("_id"))) + TIMES_PLAYED("timesPlayed", listOf(Order.desc("timesPlayed"), Order.desc("_id"))), + RATING("rating", listOf(Order.desc("ratio"), Order.desc("_id"))) ; companion object { diff --git a/spring/src/test/kotlin/tw/waterballsa/gaas/spring/it/controllers/GameRegistrationControllerTest.kt b/spring/src/test/kotlin/tw/waterballsa/gaas/spring/it/controllers/GameRegistrationControllerTest.kt index 7bf580ca..bf81f94b 100644 --- a/spring/src/test/kotlin/tw/waterballsa/gaas/spring/it/controllers/GameRegistrationControllerTest.kt +++ b/spring/src/test/kotlin/tw/waterballsa/gaas/spring/it/controllers/GameRegistrationControllerTest.kt @@ -10,8 +10,14 @@ import org.springframework.http.MediaType.APPLICATION_JSON import org.springframework.test.web.servlet.ResultActions import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import tw.waterballsa.gaas.application.eventbus.EventBus import tw.waterballsa.gaas.application.repositories.GameRegistrationRepository import tw.waterballsa.gaas.domain.GameRegistration +import tw.waterballsa.gaas.domain.Room +import tw.waterballsa.gaas.domain.User +import tw.waterballsa.gaas.events.CommentGameEvent +import tw.waterballsa.gaas.events.StartedGameEvent +import tw.waterballsa.gaas.events.enums.EventMessageType import tw.waterballsa.gaas.spring.controllers.GetGameRegistrationPresenter.* import tw.waterballsa.gaas.spring.controllers.RegisterGamePresenter.RegisterGameViewModel import tw.waterballsa.gaas.spring.controllers.viewmodel.UpdateGameRegistrationViewModel @@ -22,6 +28,7 @@ import java.util.UUID.randomUUID @AutoConfigureMockMvc(addFilters = false) class GameRegistrationControllerTest @Autowired constructor( val gameRegistrationRepository: GameRegistrationRepository, + val eventBus: EventBus, ) : AbstractSpringBootTest() { @BeforeEach @@ -104,22 +111,63 @@ class GameRegistrationControllerTest @Autowired constructor( .getBody(object : TypeReference>() {}) } + @Test + fun givenBig2TimesPlayedIsGreaterThanUnoTimesPlayed_WhenThenUserViewsGameListByTimesPlayed_ThenBig2RankInFront() { + val unoRequest = createGameRegistrationRequest("uno-java", "UNO Java") + val big2Request = createGameRegistrationRequest("big2-Java", "Big2 Java") + registerGameSuccessfully(unoRequest) + val big2 = registerGameSuccessfully(big2Request) + eventBus.broadcast( + StartedGameEvent( + EventMessageType.GAME_STARTED, + StartedGameEvent.Data("", Room.Id(""), big2.id, emptyList()) + ) + ) + mockMvc.perform(get("/games?sort_by=timesPlayed")) + .andExpect(status().isOk) + .andExpect(jsonPath("$").isArray) + .andExpect(jsonPath("$.size()").value(2)) + .validateSpecificElement(0, big2Request) + .validateSpecificElement(1, unoRequest) + .getBody(object : TypeReference>() {}) + } + + @Test + fun givenBig2RatingIsGreaterThanUnoRating_WhenThenUserViewsGameListByRating_ThenBig2RankInFront() { + val unoRequest = createGameRegistrationRequest("uno-java", "UNO Java") + val big2Request = createGameRegistrationRequest("big2-Java", "Big2 Java") + registerGameSuccessfully(unoRequest) + val big2 = registerGameSuccessfully(big2Request) + + eventBus.broadcast(CommentGameEvent(big2.id, User.Id(""), 5, 1)) + + mockMvc.perform(get("/games?sort_by=rating")) + .andExpect(status().isOk) + .andExpect(jsonPath("$").isArray) + .andExpect(jsonPath("$.size()").value(2)) + .validateSpecificElement(0, big2Request) + .validateSpecificElement(1, unoRequest) + .getBody(object : TypeReference>() {}) + } + + @Test fun givenBig2HasRegistered_whenUpdateGameRegistrationWithWrongId_thenShouldReturnGameRegistrationNotFound() { givenGameHasRegistered("big2", "Big2") whenUpdateGameRegistration( "not-exist-game-id", TestGameRegistrationRequest( - "big2", - "updated big2", - "updated big2 description", - "updated big2 rules", - "updated big2 image url", - 3, - 8, - "updated big2 frontend url", - "updated big2 backend url", - )) + "big2", + "updated big2", + "updated big2 description", + "updated big2 rules", + "updated big2 image url", + 3, + 8, + "updated big2 frontend url", + "updated big2 backend url", + ) + ) .thenShouldReturnGameRegistrationNotFound() } @@ -140,7 +188,8 @@ class GameRegistrationControllerTest @Autowired constructor( 8, "updated big2 frontend url", "updated big2 backend url", - )) + ) + ) .thenShouldReturnGameAlreadyExists() } @@ -284,9 +333,13 @@ class GameRegistrationControllerTest @Autowired constructor( return registerGameSuccessfully(createGameRegistrationRequest).id } - private fun whenUpdateGameRegistration(gameId: String, updateGameRegistrationRequest: TestGameRegistrationRequest): ResultActions { - return mockMvc.perform(put("/games/$gameId") - .withJson(updateGameRegistrationRequest) + private fun whenUpdateGameRegistration( + gameId: String, + updateGameRegistrationRequest: TestGameRegistrationRequest + ): ResultActions { + return mockMvc.perform( + put("/games/$gameId") + .withJson(updateGameRegistrationRequest) ) }