diff --git a/src/main/kotlin/org/gitanimals/guild/app/AcceptJoinGuildFacade.kt b/src/main/kotlin/org/gitanimals/guild/app/AcceptJoinGuildFacade.kt new file mode 100644 index 0000000..494480e --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/app/AcceptJoinGuildFacade.kt @@ -0,0 +1,21 @@ +package org.gitanimals.guild.app + +import org.gitanimals.guild.domain.GuildService +import org.springframework.stereotype.Service + +@Service +class AcceptJoinGuildFacade( + private val identityApi: IdentityApi, + private val guildService: GuildService, +) { + + fun acceptJoin(token: String, guildId: Long, acceptUserId: Long) { + val user = identityApi.getUserByToken(token) + + guildService.acceptJoin( + acceptorId = user.id.toLong(), + guildId = guildId, + acceptUserId = acceptUserId, + ) + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/app/ChangeGuildFacade.kt b/src/main/kotlin/org/gitanimals/guild/app/ChangeGuildFacade.kt new file mode 100644 index 0000000..8e9ae83 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/app/ChangeGuildFacade.kt @@ -0,0 +1,22 @@ +package org.gitanimals.guild.app + +import org.gitanimals.guild.domain.GuildService +import org.gitanimals.guild.domain.request.ChangeGuildRequest +import org.springframework.stereotype.Service + +@Service +class ChangeGuildFacade( + private val identityApi: IdentityApi, + private val guildService: GuildService, +) { + + fun changeGuild(token: String, guildId: Long, changeGuildRequest: ChangeGuildRequest) { + val user = identityApi.getUserByToken(token) + + guildService.changeGuild( + changeRequesterId = user.id.toLong(), + guildId = guildId, + request = changeGuildRequest, + ) + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/app/ChangeMainPersonaFacade.kt b/src/main/kotlin/org/gitanimals/guild/app/ChangeMainPersonaFacade.kt new file mode 100644 index 0000000..0cceeb6 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/app/ChangeMainPersonaFacade.kt @@ -0,0 +1,27 @@ +package org.gitanimals.guild.app + +import org.gitanimals.guild.domain.GuildService +import org.springframework.stereotype.Component + +@Component +class ChangeMainPersonaFacade( + private val renderApi: RenderApi, + private val identityApi: IdentityApi, + private val guildService: GuildService, +) { + + fun changeMainPersona(token: String, guildId: Long, personaId: Long) { + val user = identityApi.getUserByToken(token) + val personas = renderApi.getUserByName(user.username).personas + + val changedPersona = personas.firstOrNull { it.id.toLong() == personaId } + ?: throw IllegalArgumentException("Cannot change persona to \"$personaId\" from user \"${user.username}\"") + + guildService.changeMainPersona( + guildId = guildId, + userId = user.id.toLong(), + personaId = changedPersona.id.toLong(), + personaType = changedPersona.type, + ) + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/app/CreateGuildFacade.kt b/src/main/kotlin/org/gitanimals/guild/app/CreateGuildFacade.kt new file mode 100644 index 0000000..d802527 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/app/CreateGuildFacade.kt @@ -0,0 +1,100 @@ +package org.gitanimals.guild.app + +import org.gitanimals.guild.app.request.CreateGuildRequest +import org.gitanimals.guild.domain.GuildService +import org.gitanimals.guild.domain.request.CreateLeaderRequest +import org.rooftop.netx.api.Orchestrator +import org.rooftop.netx.api.OrchestratorFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.util.* + +@Service +class CreateGuildFacade( + private val guildService: GuildService, + private val identityApi: IdentityApi, + private val renderApi: RenderApi, + @Value("\${internal.secret}") internalSecret: String, + orchestratorFactory: OrchestratorFactory, +) { + + private lateinit var createGuildOrchestrator: Orchestrator + + fun createGuild( + token: String, + createGuildRequest: CreateGuildRequest, + ) { + createGuildOrchestrator.sagaSync( + createGuildRequest, + context = mapOf("token" to token, IDEMPOTENCY_KEY to UUID.randomUUID().toString()), + ).decodeResultOrThrow(Unit::class) + } + + init { + createGuildOrchestrator = + orchestratorFactory.create("Create guild orchestrator") + .startWithContext( + contextOrchestrate = { context, createGuildRequest -> + val token = context.decodeContext("token", String::class) + val idempotencyKey = context.decodeContext(IDEMPOTENCY_KEY, String::class) + + val leader = identityApi.getUserByToken(token) + require(leader.points.toInt() >= CREATE_GUILD_COST) { + "Cannot create guild cause not enough points. points: \"${leader.points}\"" + } + + identityApi.decreasePoint( + token = token, + internalSecret = internalSecret, + idempotencyKey = idempotencyKey, + point = CREATE_GUILD_COST.toString(), + ) + createGuildRequest + }, + contextRollback = { context, _ -> + val token = context.decodeContext("token", String::class) + val idempotencyKey = context.decodeContext(IDEMPOTENCY_KEY, String::class) + + identityApi.increasePoint( + token = token, + internalSecret = internalSecret, + idempotencyKey = idempotencyKey, + point = CREATE_GUILD_COST.toString(), + ) + } + ) + .commitWithContext( + contextOrchestrate = { context, createGuildRequest -> + val token = context.decodeContext("token", String::class) + + val leader = identityApi.getUserByToken(token) + val renderUser = + renderApi.getUserByName(leader.username) + + + val createLeaderRequest = CreateLeaderRequest( + userId = leader.id.toLong(), + name = leader.username, + personaId = renderUser.personas.firstOrNull { it.id == createGuildRequest.personaId }?.id?.toLong() + ?: throw IllegalArgumentException("Cannot find persona by id \"${createGuildRequest.personaId}\""), + contributions = renderUser.totalContributions.toLong(), + personaType = renderUser.personas.find { it.id == createGuildRequest.personaId }!!.type, + ) + + guildService.createGuild( + title = createGuildRequest.title, + body = createGuildRequest.body, + guildIcon = createGuildRequest.guildIcon, + farmType = createGuildRequest.farmType, + autoJoin = createGuildRequest.autoJoin, + createLeaderRequest = createLeaderRequest, + ) + } + ) + } + + private companion object { + private const val IDEMPOTENCY_KEY = "IDEMPOTENCY_KEY" + private const val CREATE_GUILD_COST = 30_000 + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/app/DenyJoinGuildFacade.kt b/src/main/kotlin/org/gitanimals/guild/app/DenyJoinGuildFacade.kt new file mode 100644 index 0000000..0067b5a --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/app/DenyJoinGuildFacade.kt @@ -0,0 +1,17 @@ +package org.gitanimals.guild.app + +import org.gitanimals.guild.domain.GuildService +import org.springframework.stereotype.Service + +@Service +class DenyJoinGuildFacade( + private val identityApi: IdentityApi, + private val guildService: GuildService, +) { + + fun denyJoin(token: String, guildId: Long, denyUserId: Long) { + val user = identityApi.getUserByToken(token) + + guildService.denyJoin(denierId = user.id.toLong(), guildId = guildId, denyUserId = denyUserId) + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/app/GetJoinedGuildFacade.kt b/src/main/kotlin/org/gitanimals/guild/app/GetJoinedGuildFacade.kt new file mode 100644 index 0000000..d118424 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/app/GetJoinedGuildFacade.kt @@ -0,0 +1,18 @@ +package org.gitanimals.guild.app + +import org.gitanimals.guild.domain.Guild +import org.gitanimals.guild.domain.GuildService +import org.springframework.stereotype.Service + +@Service +class GetJoinedGuildFacade( + private val identityApi: IdentityApi, + private val guildService: GuildService, +) { + + fun getJoinedGuilds(token: String): List { + val user = identityApi.getUserByToken(token) + + return guildService.findAllGuildByUserId(user.id.toLong()) + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/app/IdentityApi.kt b/src/main/kotlin/org/gitanimals/guild/app/IdentityApi.kt new file mode 100644 index 0000000..56f092a --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/app/IdentityApi.kt @@ -0,0 +1,40 @@ +package org.gitanimals.guild.app + +import org.springframework.http.HttpHeaders +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.service.annotation.GetExchange +import org.springframework.web.service.annotation.PostExchange + +interface IdentityApi { + + @GetExchange("/users") + fun getUserByToken(@RequestHeader(HttpHeaders.AUTHORIZATION) token: String): UserResponse + + @PostExchange("/internals/users/points/decreases") + fun decreasePoint( + @RequestHeader(HttpHeaders.AUTHORIZATION) token: String, + @RequestHeader(INTERNAL_SECRET_KEY) internalSecret: String, + @RequestParam("idempotency-key") idempotencyKey: String, + @RequestParam("point") point: String, + ) + + @PostExchange("/internals/users/points/increases") + fun increasePoint( + @RequestHeader(HttpHeaders.AUTHORIZATION) token: String, + @RequestHeader(INTERNAL_SECRET_KEY) internalSecret: String, + @RequestParam("idempotency-key") idempotencyKey: String, + @RequestParam("point") point: String, + ) + + data class UserResponse( + val id: String, + val username: String, + val points: String, + val profileImage: String, + ) + + private companion object { + private const val INTERNAL_SECRET_KEY = "Internal-Secret" + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/app/JoinGuildFacade.kt b/src/main/kotlin/org/gitanimals/guild/app/JoinGuildFacade.kt new file mode 100644 index 0000000..91f4397 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/app/JoinGuildFacade.kt @@ -0,0 +1,91 @@ +package org.gitanimals.guild.app + +import org.gitanimals.guild.app.event.InboxInputEvent +import org.gitanimals.guild.domain.Guild +import org.gitanimals.guild.domain.GuildService +import org.rooftop.netx.api.SagaManager +import org.springframework.stereotype.Service + +@Service +class JoinGuildFacade( + private val renderApi: RenderApi, + private val identityApi: IdentityApi, + private val guildService: GuildService, + private val sagaManager: SagaManager, +) { + + fun joinGuild( + token: String, + guildId: Long, + memberPersonaId: Long, + ) { + val member = identityApi.getUserByToken(token) + val renderInfo = renderApi.getUserByName(member.username) + + require(memberPersonaId in renderInfo.personas.map { it.id.toLong() }) { + "Cannot join guild cause user does not have request member persona id. personaId: \"$memberPersonaId\"" + } + + guildService.joinGuild( + guildId = guildId, + memberUserId = member.id.toLong(), + memberName = member.username, + memberPersonaId = memberPersonaId, + memberContributions = renderInfo.totalContributions.toLong(), + memberPersonaType = renderInfo.personas.find { it.id.toLong() == memberPersonaId }!!.type, + ) + + val guild = guildService.getGuildById(guildId) + if (guild.isAutoJoin()) { + publishNewUserJoinEvents(guild, member) + return + } + + publicGuildJoinRequest(guild, member) + publishSentJoinRequest(guild, member) + } + + private fun publishNewUserJoinEvents( + guild: Guild, + member: IdentityApi.UserResponse, + ) { + guild.getMembers() + .filter { it.userId != member.id.toLong() } + .forEach { + sagaManager.startSync( + InboxInputEvent.newUserJoined( + userId = it.userId, + newUserImage = member.profileImage, + newUserName = member.username, + guildTitle = guild.getTitle(), + ) + ) + } + } + + private fun publicGuildJoinRequest( + guild: Guild, + member: IdentityApi.UserResponse + ) { + sagaManager.startSync( + InboxInputEvent.guildJoinRequest( + userId = guild.getLeaderId(), + newUserImage = member.profileImage, + newUserName = member.username, + guildTitle = guild.getTitle(), + ) + ) + } + + private fun publishSentJoinRequest( + guild: Guild, + member: IdentityApi.UserResponse, + ) { + sagaManager.startSync( + InboxInputEvent.sentJoinRequest( + userId = member.id.toLong(), + guildTitle = guild.getTitle(), + ) + ) + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/app/KickGuildFacade.kt b/src/main/kotlin/org/gitanimals/guild/app/KickGuildFacade.kt new file mode 100644 index 0000000..66add63 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/app/KickGuildFacade.kt @@ -0,0 +1,21 @@ +package org.gitanimals.guild.app + +import org.gitanimals.guild.domain.GuildService +import org.springframework.stereotype.Service + +@Service +class KickGuildFacade( + private val identityApi: IdentityApi, + private val guildService: GuildService, +) { + + fun kickMember(token: String, guildId: Long, kickUserId: Long) { + val user = identityApi.getUserByToken(token) + + guildService.kickMember( + kickerId = user.id.toLong(), + guildId = guildId, + kickUserId = kickUserId, + ) + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/app/LeaveGuildFacade.kt b/src/main/kotlin/org/gitanimals/guild/app/LeaveGuildFacade.kt new file mode 100644 index 0000000..cc0a2ce --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/app/LeaveGuildFacade.kt @@ -0,0 +1,17 @@ +package org.gitanimals.guild.app + +import org.gitanimals.guild.domain.GuildService +import org.springframework.stereotype.Component + +@Component +class LeaveGuildFacade( + private val identityApi: IdentityApi, + private val guildService: GuildService, +) { + + fun leave(token: String, guildId: Long) { + val user = identityApi.getUserByToken(token) + + guildService.leave(guildId, user.id.toLong()) + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/app/RenderApi.kt b/src/main/kotlin/org/gitanimals/guild/app/RenderApi.kt new file mode 100644 index 0000000..9052785 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/app/RenderApi.kt @@ -0,0 +1,24 @@ +package org.gitanimals.guild.app + +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.service.annotation.GetExchange + +fun interface RenderApi { + + @GetExchange("/users/{username}") + fun getUserByName(@PathVariable("username") username: String): UserResponse + + data class UserResponse( + val id: String, + val name: String, + val totalContributions: String, + val personas: List, + ) { + + data class PersonaResponse( + val id: String, + val level: String, + val type: String, + ) + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/app/SearchGuildFacade.kt b/src/main/kotlin/org/gitanimals/guild/app/SearchGuildFacade.kt new file mode 100644 index 0000000..507a80b --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/app/SearchGuildFacade.kt @@ -0,0 +1,32 @@ +package org.gitanimals.guild.app + +import org.gitanimals.guild.domain.Guild +import org.gitanimals.guild.domain.GuildService +import org.gitanimals.guild.domain.RandomGuildCache +import org.gitanimals.guild.domain.SearchFilter +import org.springframework.data.domain.Page +import org.springframework.stereotype.Service + +@Service +class SearchGuildFacade( + private val randomGuildCache: RandomGuildCache, + private val guildService: GuildService, +) { + + fun search(key: Int, text: String, pageNumber: Int, filter: SearchFilter): Page { + if (filter == SearchFilter.RANDOM) { + return randomGuildCache.get( + key = key, + text = text, + pageNumber = pageNumber, + filter = filter, + ) + } + + return guildService.search( + text = text, + pageNumber = pageNumber, + filter = filter, + ) + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/app/event/InboxInputEvent.kt b/src/main/kotlin/org/gitanimals/guild/app/event/InboxInputEvent.kt new file mode 100644 index 0000000..bf69ca0 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/app/event/InboxInputEvent.kt @@ -0,0 +1,88 @@ +package org.gitanimals.guild.app.event + +import org.gitanimals.guild.core.clock +import java.time.Instant + +data class InboxInputEvent( + val inboxData: InboxData, + val publisher: Publisher, +) { + + data class Publisher( + val publisher: String, + val publishedAt: Instant, + ) + + data class InboxData( + val userId: Long, + val type: String = "INBOX", + val title: String, + val body: String, + val image: String, + val redirectTo: String, + ) + + companion object { + + fun newUserJoined( + userId: Long, + newUserImage: String, + newUserName: String, + guildTitle: String, + ): InboxInputEvent { + return InboxInputEvent( + publisher = Publisher( + publisher = "GUILD", + publishedAt = clock.instant(), + ), + inboxData = InboxData( + userId = userId, + title = "New user join", + body = "$newUserName join $guildTitle guild.", + image = newUserImage, + redirectTo = "", + ) + ) + } + + fun guildJoinRequest( + userId: Long, + newUserImage: String, + newUserName: String, + guildTitle: String, + ): InboxInputEvent { + return InboxInputEvent( + publisher = Publisher( + publisher = "GUILD", + publishedAt = clock.instant(), + ), + inboxData = InboxData( + userId = userId, + title = "Guild join request", + body = "$newUserName has sent a join request to the $guildTitle guild.", + image = newUserImage, + redirectTo = "", + ) + ) + } + + fun sentJoinRequest( + userId: Long, + guildTitle: String, + ): InboxInputEvent { + return InboxInputEvent( + publisher = Publisher( + publisher = "GUILD", + publishedAt = clock.instant(), + ), + inboxData = InboxData( + userId = userId, + title = "Guild join request sent", + body = "Guild join request sent to $guildTitle.", + image = "guild-image", // guild 이미지 추가 + redirectTo = "", + ) + ) + } + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/app/request/CreateGuildRequest.kt b/src/main/kotlin/org/gitanimals/guild/app/request/CreateGuildRequest.kt new file mode 100644 index 0000000..33cc584 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/app/request/CreateGuildRequest.kt @@ -0,0 +1,12 @@ +package org.gitanimals.guild.app.request + +import org.gitanimals.guild.domain.GuildFarmType + +data class CreateGuildRequest( + val title: String, + val body: String, + val guildIcon: String, + val autoJoin: Boolean, + val farmType: GuildFarmType, + val personaId: String, +) diff --git a/src/main/kotlin/org/gitanimals/guild/controller/GuildController.kt b/src/main/kotlin/org/gitanimals/guild/controller/GuildController.kt new file mode 100644 index 0000000..383e3c5 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/controller/GuildController.kt @@ -0,0 +1,138 @@ +package org.gitanimals.guild.controller + +import org.gitanimals.guild.app.* +import org.gitanimals.guild.app.request.CreateGuildRequest +import org.gitanimals.guild.controller.request.JoinGuildRequest +import org.gitanimals.guild.controller.response.GuildIconsResponse +import org.gitanimals.guild.controller.response.GuildPagingResponse +import org.gitanimals.guild.controller.response.GuildResponse +import org.gitanimals.guild.controller.response.GuildsResponse +import org.gitanimals.guild.domain.GuildIcons +import org.gitanimals.guild.domain.GuildService +import org.gitanimals.guild.domain.SearchFilter +import org.gitanimals.guild.domain.request.ChangeGuildRequest +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.* + +@RestController +class GuildController( + private val guildService: GuildService, + private val createGuildFacade: CreateGuildFacade, + private val joinGuildFacade: JoinGuildFacade, + private val acceptJoinGuildFacade: AcceptJoinGuildFacade, + private val kickGuildFacade: KickGuildFacade, + private val changeGuildFacade: ChangeGuildFacade, + private val joinedGuildFacade: GetJoinedGuildFacade, + private val searchGuildFacade: SearchGuildFacade, + private val changeMainPersonaFacade: ChangeMainPersonaFacade, + private val leaveGuildFacade: LeaveGuildFacade, +) { + + @ResponseStatus(HttpStatus.OK) + @PostMapping("/guilds") + fun createGuild( + @RequestHeader(HttpHeaders.AUTHORIZATION) token: String, + @RequestBody createGuildRequest: CreateGuildRequest, + ) = createGuildFacade.createGuild(token, createGuildRequest) + + @ResponseStatus(HttpStatus.OK) + @PostMapping("/guilds/{guildId}") + fun joinGuild( + @RequestHeader(HttpHeaders.AUTHORIZATION) token: String, + @PathVariable("guildId") guildId: Long, + @RequestBody joinGuildRequest: JoinGuildRequest, + ) = joinGuildFacade.joinGuild(token, guildId, joinGuildRequest.personaId.toLong()) + + + @ResponseStatus(HttpStatus.OK) + @PostMapping("/guilds/{guildId}/accepts") + fun acceptJoinGuild( + @RequestHeader(HttpHeaders.AUTHORIZATION) token: String, + @PathVariable("guildId") guildId: Long, + @RequestParam("user-id") acceptUserId: Long, + ) = acceptJoinGuildFacade.acceptJoin(token, guildId, acceptUserId) + + + @ResponseStatus(HttpStatus.OK) + @DeleteMapping("/guilds/{guildId}") + fun kickFromGuild( + @RequestHeader(HttpHeaders.AUTHORIZATION) token: String, + @PathVariable("guildId") guildId: Long, + @RequestParam("user-id") kickUserId: Long, + ) = kickGuildFacade.kickMember(token, guildId, kickUserId) + + + @ResponseStatus(HttpStatus.OK) + @PatchMapping("/guilds/{guildId}") + fun changeGuild( + @RequestHeader(HttpHeaders.AUTHORIZATION) token: String, + @PathVariable("guildId") guildId: Long, + @RequestBody changeGuildRequest: ChangeGuildRequest, + ) = changeGuildFacade.changeGuild(token, guildId, changeGuildRequest) + + @ResponseStatus(HttpStatus.OK) + @GetMapping("/guilds/{guildId}") + fun getGuildById(guildId: Long): GuildResponse { + val guild = guildService.getGuildById( + guildId, + GuildService.loadMembers, + GuildService.loadWaitMembers, + ) + + return GuildResponse.from(guild) + } + + @ResponseStatus(HttpStatus.OK) + @GetMapping("/guilds") + fun getAllJoinedGuild( + @RequestHeader(HttpHeaders.AUTHORIZATION) token: String, + ): GuildsResponse { + val guilds = joinedGuildFacade.getJoinedGuilds(token) + + return GuildsResponse.from(guilds) + } + + @ResponseStatus(HttpStatus.OK) + @GetMapping("/guilds/search") + fun searchGuilds( + @RequestParam(name = "text", defaultValue = "") text: String, + @RequestParam(name = "page-number", defaultValue = "0") pageNumber: Int, + @RequestParam(name = "filter", defaultValue = "RANDOM") filter: SearchFilter, + @RequestParam(name = "key", defaultValue = "0") key: Int, + ): GuildPagingResponse { + val guilds = searchGuildFacade.search( + key = key, + text = text, + pageNumber = pageNumber, + filter = filter, + ) + + return GuildPagingResponse.from(guilds) + } + + @GetMapping("/guilds/icons") + @ResponseStatus(HttpStatus.OK) + fun findAllGuildIcons(): GuildIconsResponse { + return GuildIconsResponse( + GuildIcons.entries.map { it.getImagePath() }.toList() + ) + } + + @PostMapping("/guilds/{guildId}/personas") + fun changeMainPersona( + @RequestHeader(HttpHeaders.AUTHORIZATION) token: String, + @PathVariable("guildId") guildId: Long, + @RequestParam("persona-id") personaId: Long, + ) = changeMainPersonaFacade.changeMainPersona( + token = token, + guildId = guildId, + personaId = personaId, + ) + + @DeleteMapping("/guilds/{guildId}/leave") + fun leaveGuild( + @RequestHeader(HttpHeaders.AUTHORIZATION) token: String, + @PathVariable("guildId") guildId: Long, + ) = leaveGuildFacade.leave(token, guildId) +} diff --git a/src/main/kotlin/org/gitanimals/guild/controller/request/JoinGuildRequest.kt b/src/main/kotlin/org/gitanimals/guild/controller/request/JoinGuildRequest.kt new file mode 100644 index 0000000..cfb5bba --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/controller/request/JoinGuildRequest.kt @@ -0,0 +1,5 @@ +package org.gitanimals.guild.controller.request + +data class JoinGuildRequest( + val personaId: String, +) diff --git a/src/main/kotlin/org/gitanimals/guild/controller/response/GuildIconsResponse.kt b/src/main/kotlin/org/gitanimals/guild/controller/response/GuildIconsResponse.kt new file mode 100644 index 0000000..92f7647 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/controller/response/GuildIconsResponse.kt @@ -0,0 +1,5 @@ +package org.gitanimals.guild.controller.response + +data class GuildIconsResponse( + val icons: List, +) diff --git a/src/main/kotlin/org/gitanimals/guild/controller/response/GuildPagingResponse.kt b/src/main/kotlin/org/gitanimals/guild/controller/response/GuildPagingResponse.kt new file mode 100644 index 0000000..3fd285c --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/controller/response/GuildPagingResponse.kt @@ -0,0 +1,40 @@ +package org.gitanimals.guild.controller.response + +import org.gitanimals.guild.domain.Guild +import org.springframework.data.domain.Page + +data class GuildPagingResponse( + val guilds: List, + val pagination: Pagination, +) { + + data class Pagination( + val totalRecords: Int, + val currentPage: Int, + val totalPages: Int, + val nextPage: Int?, + val prevPage: Int?, + ) + + companion object { + + fun from(guilds: Page): GuildPagingResponse { + return GuildPagingResponse( + guilds = guilds.map { GuildResponse.from(it) }.toList(), + pagination = Pagination( + totalRecords = guilds.count(), + currentPage = guilds.number, + totalPages = guilds.totalPages, + nextPage = when (guilds.hasNext()) { + true -> guilds.number + 1 + false -> null + }, + prevPage = when (guilds.hasPrevious()) { + true -> guilds.number - 1 + false -> null + }, + ) + ) + } + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/controller/response/GuildResponse.kt b/src/main/kotlin/org/gitanimals/guild/controller/response/GuildResponse.kt new file mode 100644 index 0000000..c03a8c9 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/controller/response/GuildResponse.kt @@ -0,0 +1,91 @@ +package org.gitanimals.guild.controller.response + +import com.fasterxml.jackson.annotation.JsonFormat +import org.gitanimals.guild.domain.Guild +import java.time.Instant + +data class GuildResponse( + val id: String, + val title: String, + val body: String, + val guildIcon: String, + val leader: Leader, + val farmType: String, + val totalContributions: String, + val members: List, + val waitMembers: List, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "UTC" + ) + val createdAt: Instant, +) { + data class Leader( + val userId: String, + val name: String, + val contributions: String, + val personaId: String, + val personaType: String, + ) + + data class Member( + val id: String, + val userId: String, + val name: String, + val contributions: String, + val personaId: String, + val personaType: String, + ) + + data class WaitMember( + val id: String, + val userId: String, + val name: String, + val contributions: String, + val personaId: String, + val personaType: String, + ) + + companion object { + + fun from(guild: Guild): GuildResponse { + return GuildResponse( + id = guild.id.toString(), + title = guild.getTitle(), + body = guild.getBody(), + guildIcon = guild.getGuildIcon(), + leader = Leader( + userId = guild.getLeaderId().toString(), + name = guild.getLeaderName(), + contributions = guild.getContributions().toString(), + personaId = guild.getLeaderPersonaId().toString(), + personaType = guild.getLeaderPersonaType(), + ), + farmType = guild.getGuildFarmType().toString(), + totalContributions = guild.getTotalContributions().toString(), + members = guild.getMembers().map { + Member( + id = it.id.toString(), + userId = it.userId.toString(), + name = it.name, + contributions = it.getContributions().toString(), + personaId = it.personaId.toString(), + personaType = it.personaType, + ) + }, + waitMembers = guild.getWaitMembers().map { + WaitMember( + id = it.id.toString(), + userId = it.userId.toString(), + name = it.name, + contributions = it.getContributions().toString(), + personaId = it.personaId.toString(), + personaType = it.personaType, + ) + }, + createdAt = guild.createdAt, + ) + } + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/controller/response/GuildsResponse.kt b/src/main/kotlin/org/gitanimals/guild/controller/response/GuildsResponse.kt new file mode 100644 index 0000000..d494404 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/controller/response/GuildsResponse.kt @@ -0,0 +1,15 @@ +package org.gitanimals.guild.controller.response + +import org.gitanimals.guild.domain.Guild + +data class GuildsResponse( + val guilds: List, +) { + + companion object { + + fun from(guilds: List): GuildsResponse { + return GuildsResponse(guilds.map { GuildResponse.from(it) }) + } + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/core/AggregateRoot.kt b/src/main/kotlin/org/gitanimals/guild/core/AggregateRoot.kt new file mode 100644 index 0000000..8a2cb9f --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/core/AggregateRoot.kt @@ -0,0 +1,5 @@ +package org.gitanimals.guild.core + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class AggregateRoot diff --git a/src/main/kotlin/org/gitanimals/guild/core/IdGenerator.kt b/src/main/kotlin/org/gitanimals/guild/core/IdGenerator.kt new file mode 100644 index 0000000..b9389fd --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/core/IdGenerator.kt @@ -0,0 +1,10 @@ +package org.gitanimals.guild.core + +import com.github.f4b6a3.tsid.TsidFactory + +object IdGenerator { + + private val tsidFactory = TsidFactory.newInstance256() + + fun generate(): Long = tsidFactory.create().toLong() +} diff --git a/src/main/kotlin/org/gitanimals/guild/core/clock.kt b/src/main/kotlin/org/gitanimals/guild/core/clock.kt new file mode 100644 index 0000000..40f89c7 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/core/clock.kt @@ -0,0 +1,14 @@ +package org.gitanimals.guild.core + +import java.time.Clock +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime + +var clock: Clock = Clock.systemUTC() + +fun instant() = Instant.now(clock) + +fun Instant.toZonedDateTime() = ZonedDateTime.ofInstant(this, clock.zone) + +fun Instant.toKr() = ZonedDateTime.ofInstant(this, ZoneId.of("Asia/Seoul")) diff --git a/src/main/kotlin/org/gitanimals/guild/domain/AbstractTime.kt b/src/main/kotlin/org/gitanimals/guild/domain/AbstractTime.kt new file mode 100644 index 0000000..ff15c58 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/domain/AbstractTime.kt @@ -0,0 +1,32 @@ +package org.gitanimals.guild.domain + +import jakarta.persistence.Column +import jakarta.persistence.EntityListeners +import jakarta.persistence.MappedSuperclass +import jakarta.persistence.PrePersist +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.Instant + +@MappedSuperclass +@EntityListeners(AuditingEntityListener::class) +abstract class AbstractTime( + @CreatedDate + @Column(name = "created_at") + val createdAt: Instant = Instant.now(), + + @LastModifiedDate + @Column(name = "modified_at") + var modifiedAt: Instant? = null, +) { + + @PrePersist + fun prePersist() { + modifiedAt = when (modifiedAt == null) { + true -> createdAt + false -> return + } + } +} + diff --git a/src/main/kotlin/org/gitanimals/guild/domain/Guild.kt b/src/main/kotlin/org/gitanimals/guild/domain/Guild.kt new file mode 100644 index 0000000..bc907a1 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/domain/Guild.kt @@ -0,0 +1,238 @@ +package org.gitanimals.guild.domain + +import jakarta.persistence.* +import org.gitanimals.guild.core.AggregateRoot +import org.gitanimals.guild.core.IdGenerator +import org.gitanimals.guild.domain.request.ChangeGuildRequest +import org.hibernate.annotations.BatchSize + +@Entity +@AggregateRoot +@Table( + name = "guild", + indexes = [ + Index(name = "guild_idx_title", unique = true, columnList = "title") + ] +) +class Guild( + @Id + @Column(name = "id") + val id: Long, + + @Column(name = "guild_icon", nullable = false) + private var guildIcon: String, + + @Column(name = "title", unique = true, nullable = false, length = 50) + private var title: String, + + @Column(name = "body", columnDefinition = "TEXT", length = 500) + private var body: String, + + @Embedded + private val leader: Leader, + + @Enumerated(EnumType.STRING) + @Column(name = "farm_type", nullable = false, columnDefinition = "TEXT") + private var farmType: GuildFarmType, + + @Column(name = "auto_join", nullable = false) + private var autoJoin: Boolean, + + @OneToMany( + mappedBy = "guild", + orphanRemoval = true, + fetch = FetchType.LAZY, + cascade = [CascadeType.ALL], + ) + @BatchSize(size = 10) + private val members: MutableSet = mutableSetOf(), + + @OneToMany( + mappedBy = "guild", + orphanRemoval = true, + fetch = FetchType.LAZY, + cascade = [CascadeType.ALL], + ) + @BatchSize(size = 10) + private val waitMembers: MutableSet = mutableSetOf(), + + @Version + private var version: Long? = null, +) : AbstractTime() { + + fun getMembers(): Set = members.toSet() + + fun getWaitMembers(): Set = waitMembers.toSet() + + fun isAutoJoin(): Boolean = autoJoin + + fun join( + memberUserId: Long, + memberName: String, + memberPersonaId: Long, + memberContributions: Long, + memberPersonaType: String, + ) { + require(leader.userId != memberUserId) { + "Leader cannot join their own guild leaderId: \"${leader.userId}\", memberUserId: \"$memberUserId\"" + } + + if (autoJoin) { + val member = Member.create( + guild = this, + userId = memberUserId, + name = memberName, + personaId = memberPersonaId, + contributions = memberContributions, + personaType = memberPersonaType, + ) + members.add(member) + return + } + + val waitMember = WaitMember.create( + guild = this, + userId = memberUserId, + name = memberName, + personaId = memberPersonaId, + contributions = memberContributions, + personaType = memberPersonaType, + ) + waitMembers.add(waitMember) + } + + fun getLeaderId(): Long = leader.userId + + fun accept(acceptUserId: Long) { + val acceptUser = waitMembers.firstOrNull { it.userId == acceptUserId } ?: return + waitMembers.remove(acceptUser) + + members.add(acceptUser.toMember()) + } + + fun deny(denyUserId: Long) { + waitMembers.removeIf { it.userId == denyUserId } + } + + fun kickMember(kickUserId: Long) { + members.removeIf { it.userId == kickUserId } + } + + fun change(request: ChangeGuildRequest) { + this.title = request.title + this.body = request.body + this.farmType = request.farmType + this.guildIcon = request.guildIcon + this.autoJoin = request.autoJoin + } + + fun getTitle(): String = title + + fun getBody(): String = body + + fun getGuildIcon(): String = guildIcon + + fun getLeaderName(): String = leader.name + + fun getContributions(): Long = leader.contributions + + fun getGuildFarmType(): GuildFarmType = farmType + + fun getTotalContributions(): Long { + return leader.contributions + members.sumOf { it.getContributions() } + } + + fun updateContributions(username: String, contributions: Long) { + if (leader.name == username) { + leader.contributions = contributions + return + } + members.firstOrNull { it.name == username }?.setContributions(contributions) + } + + fun changePersonaIfDeleted( + userId: Long, + deletedPersonaId: Long, + changePersonaId: Long, + changePersonaType: String, + ) { + if (leader.userId == userId) { + if (leader.personaId == deletedPersonaId) { + leader.personaId = changePersonaId + leader.personaType = changePersonaType + } + return + } + + members.firstOrNull { + it.userId == userId && it.personaId == deletedPersonaId + }?.let { + it.personaId = changePersonaId + it.personaType = changePersonaType + } + + waitMembers.firstOrNull { + it.userId == userId && it.personaId == deletedPersonaId + }?.let { + it.personaId = changePersonaId + it.personaType = changePersonaType + } + } + + fun getLeaderPersonaId(): Long { + return leader.personaId + } + + fun getLeaderPersonaType(): String { + return leader.personaType + } + + fun changeMainPersona(userId: Long, personaId: Long, personaType: String) { + if (leader.userId == userId) { + leader.personaId = personaId + leader.personaType = personaType + return + } + + members.first { + it.userId == userId + }.run { + this.personaId = personaId + this.personaType = personaType + } + } + + fun leave(userId: Long) { + require(userId != leader.userId) { + "Leader cannot leave guild guildId: \"$id\", userId: \"$userId\"" + } + + members.removeIf { it.userId == userId } + } + + companion object { + + fun create( + guildIcon: String, + title: String, + body: String, + leader: Leader, + members: MutableSet = mutableSetOf(), + farmType: GuildFarmType, + autoJoin: Boolean, + ): Guild { + GuildIcons.requireExistImagePath(guildIcon) + + return Guild( + id = IdGenerator.generate(), + guildIcon = guildIcon, + title = title, + body = body, + leader = leader, + members = members, + farmType = farmType, + autoJoin = autoJoin, + ) + } + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/domain/GuildFarmType.kt b/src/main/kotlin/org/gitanimals/guild/domain/GuildFarmType.kt new file mode 100644 index 0000000..85c494a --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/domain/GuildFarmType.kt @@ -0,0 +1,7 @@ +package org.gitanimals.guild.domain + +enum class GuildFarmType { + + DUMMY, + ; +} diff --git a/src/main/kotlin/org/gitanimals/guild/domain/GuildIcons.kt b/src/main/kotlin/org/gitanimals/guild/domain/GuildIcons.kt new file mode 100644 index 0000000..bbbf75a --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/domain/GuildIcons.kt @@ -0,0 +1,29 @@ +package org.gitanimals.guild.domain + +enum class GuildIcons( + private val imageName: String, +) { + CAT("GUILD-1"), + CHICK("GUILD-2"), + FLAMINGO("GUILD-3"), + RABBIT("GUILD-4"), + DESSERT_FOX("GUILD-5"), + GHOST("GUILD-6"), + HAMSTER("GUILD-7"), + SLIME("GUILD-8"), + PIG("GUILD-9"), + PENGUIN("GUILD-10"), + ; + + fun getImagePath() = "$IMAGE_PATH_PREFIX$imageName" + + companion object { + private const val IMAGE_PATH_PREFIX = "https://static.gitanimals.org/guilds/icons/" + + fun requireExistImagePath(imagePath: String) { + require(entries.any { it.getImagePath() == imagePath }) { + "Cannot find matched image by imagePath \"$imagePath\"" + } + } + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/domain/GuildRepository.kt b/src/main/kotlin/org/gitanimals/guild/domain/GuildRepository.kt new file mode 100644 index 0000000..3d85930 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/domain/GuildRepository.kt @@ -0,0 +1,53 @@ +package org.gitanimals.guild.domain + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param + +interface GuildRepository : JpaRepository { + + fun existsByTitle(title: String): Boolean + + @Query( + """ + select g from Guild as g + where g.id = :id + and g.leader.userId = :leaderId + """ + ) + fun findGuildByIdAndLeaderId(@Param("id") id: Long, @Param("leaderId") leaderId: Long): Guild? + + @Query( + """ + select g from Guild as g + left join fetch g.members as m + where g.leader.userId = :userId or m.userId = :userId + """ + ) + fun findAllGuildByUserIdWithMembers(@Param("userId") userId: Long): List + + @Query( + """ + select g from Guild as g + left join fetch g.members as m + where g.leader.name = :username or m.name = :username + """ + ) + fun findAllGuildByUsernameWithMembers(@Param("username") username: String): List + + @Query("select g from Guild as g") + fun findAllWithLimit(pageable: Pageable): List + + @Query( + value = """ + SELECT g.* + FROM guild g + WHERE MATCH(g.title, g.body) AGAINST(:text IN BOOLEAN MODE) + """, + countQuery = "SELECT COUNT(*) FROM guild", + nativeQuery = true, + ) + fun search(@Param("text") text: String, pageable: Pageable): Page +} diff --git a/src/main/kotlin/org/gitanimals/guild/domain/GuildService.kt b/src/main/kotlin/org/gitanimals/guild/domain/GuildService.kt new file mode 100644 index 0000000..66aae0e --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/domain/GuildService.kt @@ -0,0 +1,188 @@ +package org.gitanimals.guild.domain + +import org.gitanimals.guild.domain.request.ChangeGuildRequest +import org.gitanimals.guild.domain.request.CreateLeaderRequest +import org.hibernate.Hibernate +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.repository.findByIdOrNull +import org.springframework.orm.ObjectOptimisticLockingFailureException +import org.springframework.retry.annotation.Retryable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GuildService( + private val guildRepository: GuildRepository, +) { + + @Transactional + fun createGuild( + guildIcon: String, + title: String, + body: String, + farmType: GuildFarmType, + autoJoin: Boolean, + createLeaderRequest: CreateLeaderRequest, + ) { + require(guildRepository.existsByTitle(title).not()) { + "Cannot create guild cause duplicated guild already exists." + } + + val leader = createLeaderRequest.toDomain() + + val newGuild = Guild.create( + guildIcon = guildIcon, + title = title, + body = body, + farmType = farmType, + leader = leader, + autoJoin = autoJoin, + ) + + guildRepository.save(newGuild) + } + + @Transactional + @Retryable(ObjectOptimisticLockingFailureException::class) + fun joinGuild( + guildId: Long, + memberUserId: Long, + memberName: String, + memberPersonaId: Long, + memberPersonaType: String, + memberContributions: Long, + ) { + val guild = getGuildById(guildId) + + guild.join( + memberUserId = memberUserId, + memberName = memberName, + memberPersonaId = memberPersonaId, + memberContributions = memberContributions, + memberPersonaType = memberPersonaType, + ) + } + + @Transactional + @Retryable(ObjectOptimisticLockingFailureException::class) + fun acceptJoin(acceptorId: Long, guildId: Long, acceptUserId: Long) { + val guild = guildRepository.findGuildByIdAndLeaderId(guildId, acceptorId) + ?: throw IllegalArgumentException("Cannot accept join cause your not a leader.") + + guild.accept(acceptUserId) + } + + @Transactional + @Retryable(ObjectOptimisticLockingFailureException::class) + fun denyJoin(denierId: Long, guildId: Long, denyUserId: Long) { + val guild = guildRepository.findGuildByIdAndLeaderId(guildId, denierId) + ?: throw IllegalArgumentException("Cannot deny join cause your not a leader.") + + guild.deny(denyUserId) + } + + @Transactional + @Retryable(ObjectOptimisticLockingFailureException::class) + fun kickMember(kickerId: Long, guildId: Long, kickUserId: Long) { + val guild = guildRepository.findGuildByIdAndLeaderId(guildId, kickerId) + ?: throw IllegalArgumentException("Cannot kick member cause your not a leader.") + + guild.kickMember(kickUserId) + } + + @Transactional + @Retryable(ObjectOptimisticLockingFailureException::class) + fun changeGuild(changeRequesterId: Long, guildId: Long, request: ChangeGuildRequest) { + val guild = guildRepository.findGuildByIdAndLeaderId(guildId, changeRequesterId) + ?: throw IllegalArgumentException("Cannot kick member cause your not a leader.") + + guild.change(request) + } + + @Transactional + @Retryable(ObjectOptimisticLockingFailureException::class) + fun changeMainPersona(guildId: Long, userId: Long, personaId: Long, personaType: String) { + val guild = getGuildById(guildId) + + guild.changeMainPersona(userId, personaId, personaType) + } + + @Transactional + @Retryable(ObjectOptimisticLockingFailureException::class) + fun leave(guildId: Long, userId: Long) { + val guild = getGuildById(guildId) + + guild.leave(userId) + } + + fun getGuildById(id: Long, vararg lazyLoading: (Guild) -> Unit): Guild { + val guild = guildRepository.findByIdOrNull(id) + ?: throw IllegalArgumentException("Cannot fint guild by id \"$id\"") + + lazyLoading.forEach { it.invoke(guild) } + return guild + } + + @Transactional + @Retryable(ObjectOptimisticLockingFailureException::class) + fun updateContribution(username: String, contributions: Long) { + val guilds = guildRepository.findAllGuildByUsernameWithMembers(username) + + guilds.forEach { it.updateContributions(username, contributions) } + } + + fun findAllGuildByUserId(userId: Long): List { + return guildRepository.findAllGuildByUserIdWithMembers(userId).onEach { + loadWaitMembers.invoke(it) + } + } + + fun search(text: String, pageNumber: Int, filter: SearchFilter): Page { + return guildRepository.search(text, Pageable.ofSize(PAGE_SIZE).withPage(pageNumber)) + .onEach { + loadMembers.invoke(it) + loadWaitMembers.invoke(it) + } + } + + fun findAllWithLimit(limit: Int): List { + return guildRepository.findAllWithLimit(Pageable.ofSize(limit)).onEach { + loadMembers.invoke(it) + loadWaitMembers.invoke(it) + } + } + + @Transactional + @Retryable(ObjectOptimisticLockingFailureException::class) + fun deletePersonaSync( + userId: Long, + deletedPersonaId: Long, + changePersonaId: Long, + changePersonaType: String, + ) { + val guilds = guildRepository.findAllGuildByUserIdWithMembers(userId) + + guilds.forEach { + it.changePersonaIfDeleted( + userId = userId, + deletedPersonaId = deletedPersonaId, + changePersonaId = changePersonaId, + changePersonaType = changePersonaType, + ) + } + } + + companion object { + const val PAGE_SIZE = 9 + + val loadMembers: (Guild) -> Unit = { + Hibernate.initialize(it.getMembers()) + } + + val loadWaitMembers: (Guild) -> Unit = { + Hibernate.initialize(it.getWaitMembers()) + } + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/domain/Leader.kt b/src/main/kotlin/org/gitanimals/guild/domain/Leader.kt new file mode 100644 index 0000000..d27d0ff --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/domain/Leader.kt @@ -0,0 +1,22 @@ +package org.gitanimals.guild.domain + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable + +@Embeddable +data class Leader( + @Column(name = "leader_id", nullable = false) + val userId: Long, + + @Column(name = "name", nullable = false, columnDefinition = "TEXT") + val name: String, + + @Column(name = "persona_id", nullable = false) + var personaId: Long, + + @Column(name = "persona_type", nullable = false) + var personaType: String, + + @Column(name = "contributions", nullable = false) + var contributions: Long, +) diff --git a/src/main/kotlin/org/gitanimals/guild/domain/Member.kt b/src/main/kotlin/org/gitanimals/guild/domain/Member.kt new file mode 100644 index 0000000..6aeec67 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/domain/Member.kt @@ -0,0 +1,71 @@ +package org.gitanimals.guild.domain + +import jakarta.persistence.* +import org.gitanimals.guild.core.IdGenerator + +@Entity +@Table(name = "member") +class Member( + @Id + @Column(name = "id") + val id: Long, + + @Column(name = "user_id", nullable = false) + val userId: Long, + + @Column(name = "user_name", nullable = false) + val name: String, + + @Column(name = "persona_id", nullable = false) + var personaId: Long, + + @Column(name = "persona_type", nullable = false) + var personaType: String, + + @Column(name = "contributions", nullable = false) + private var contributions: Long, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "guild_id") + val guild: Guild, +) : AbstractTime() { + + fun getContributions() = contributions + + fun setContributions(contributions: Long) { + this.contributions = contributions + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Member) return false + + return userId == other.userId + } + + override fun hashCode(): Int { + return userId.hashCode() + } + + companion object { + + fun create( + guild: Guild, + userId: Long, + name: String, + personaId: Long, + personaType: String, + contributions: Long, + ): Member { + return Member( + id = IdGenerator.generate(), + userId = userId, + name = name, + personaId = personaId, + personaType = personaType, + guild = guild, + contributions = contributions, + ) + } + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/domain/RandomGuildCache.kt b/src/main/kotlin/org/gitanimals/guild/domain/RandomGuildCache.kt new file mode 100644 index 0000000..d8661aa --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/domain/RandomGuildCache.kt @@ -0,0 +1,8 @@ +package org.gitanimals.guild.domain + +import org.springframework.data.domain.Page + +fun interface RandomGuildCache { + + fun get(key: Int, text: String, pageNumber: Int, filter: SearchFilter): Page +} diff --git a/src/main/kotlin/org/gitanimals/guild/domain/SearchFilter.kt b/src/main/kotlin/org/gitanimals/guild/domain/SearchFilter.kt new file mode 100644 index 0000000..6a8f4c5 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/domain/SearchFilter.kt @@ -0,0 +1,36 @@ +package org.gitanimals.guild.domain + +enum class SearchFilter { + + RANDOM { + override fun sort(guilds: List) = guilds + }, + + PEOPLE_ASC { + override fun sort(guilds: List): List { + return guilds.sortedBy { it.getMembers().size } + } + }, + + PEOPLE_DESC { + override fun sort(guilds: List): List { + return guilds.sortedByDescending { it.getMembers().size } + + } + }, + + CONTRIBUTION_ASC { + override fun sort(guilds: List): List { + return guilds.sortedBy { it.getTotalContributions() } + } + }, + + CONTRIBUTION_DESC { + override fun sort(guilds: List): List { + return guilds.sortedByDescending { it.getTotalContributions() } + } + }, + ; + + abstract fun sort(guilds: List): List +} diff --git a/src/main/kotlin/org/gitanimals/guild/domain/WaitMember.kt b/src/main/kotlin/org/gitanimals/guild/domain/WaitMember.kt new file mode 100644 index 0000000..a50ee1e --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/domain/WaitMember.kt @@ -0,0 +1,83 @@ +package org.gitanimals.guild.domain + +import jakarta.persistence.* +import org.gitanimals.guild.core.IdGenerator + +@Entity +@Table( + name = "wait_member", + indexes = [ + Index( + name = "wait_member_idx_id_user_id", + columnList = "id, user_id", + unique = true, + ) + ] +) +class WaitMember( + @Id + val id: Long, + + @Column(name = "user_id", nullable = false) + val userId: Long, + + @Column(name = "user_name", nullable = false) + val name: String, + + @Column(name = "persona_id", nullable = false) + var personaId: Long, + + @Column(name = "persona_type", nullable = false) + var personaType: String, + + @Column(name = "contributions", nullable = false) + private var contributions: Long, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "guild_id") + val guild: Guild, +) { + + fun getContributions(): Long = contributions + + fun toMember(): Member = Member.create( + userId = userId, + name = name, + personaId = personaId, + guild = guild, + contributions = contributions, + personaType = personaType, + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is WaitMember) return false + + return userId == other.userId + } + + override fun hashCode(): Int { + return userId.hashCode() + } + + companion object { + fun create( + guild: Guild, + userId: Long, + name: String, + personaId: Long, + personaType: String, + contributions: Long, + ): WaitMember { + return WaitMember( + id = IdGenerator.generate(), + userId = userId, + name = name, + personaId = personaId, + personaType = personaType, + guild = guild, + contributions = contributions, + ) + } + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/domain/request/ChangeGuildRequest.kt b/src/main/kotlin/org/gitanimals/guild/domain/request/ChangeGuildRequest.kt new file mode 100644 index 0000000..7a92bc3 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/domain/request/ChangeGuildRequest.kt @@ -0,0 +1,11 @@ +package org.gitanimals.guild.domain.request + +import org.gitanimals.guild.domain.GuildFarmType + +data class ChangeGuildRequest( + val title: String, + val body: String, + val farmType: GuildFarmType, + val guildIcon: String, + val autoJoin: Boolean, +) diff --git a/src/main/kotlin/org/gitanimals/guild/domain/request/CreateLeaderRequest.kt b/src/main/kotlin/org/gitanimals/guild/domain/request/CreateLeaderRequest.kt new file mode 100644 index 0000000..a2d4199 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/domain/request/CreateLeaderRequest.kt @@ -0,0 +1,22 @@ +package org.gitanimals.guild.domain.request + +import org.gitanimals.guild.domain.Leader + +data class CreateLeaderRequest( + val userId: Long, + val name: String, + val personaId: Long, + val contributions: Long, + val personaType: String, +) { + + fun toDomain(): Leader { + return Leader( + userId = userId, + name = name, + personaId = personaId, + contributions = contributions, + personaType = personaType, + ) + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/infra/HttpClientConfigurer.kt b/src/main/kotlin/org/gitanimals/guild/infra/HttpClientConfigurer.kt new file mode 100644 index 0000000..4e5daeb --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/infra/HttpClientConfigurer.kt @@ -0,0 +1,35 @@ +package org.gitanimals.guild.infra + +import org.gitanimals.guild.app.IdentityApi +import org.gitanimals.guild.app.RenderApi +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.client.RestClient +import org.springframework.web.client.support.RestClientAdapter +import org.springframework.web.service.invoker.HttpServiceProxyFactory + +@Configuration +class HttpClientConfigurer { + + @Bean + fun identityApiHttpClient(): IdentityApi { + val restClient = RestClient.create("https://api.gitanimals.org") + + val httpServiceProxyFactory = HttpServiceProxyFactory + .builderFor(RestClientAdapter.create(restClient)) + .build() + + return httpServiceProxyFactory.createClient(IdentityApi::class.java) + } + + @Bean + fun renderApiHttpClient(): RenderApi { + val restClient = RestClient.create("https://render.gitanimals.org") + + val httpServiceProxyFactory = HttpServiceProxyFactory + .builderFor(RestClientAdapter.create(restClient)) + .build() + + return httpServiceProxyFactory.createClient(RenderApi::class.java) + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/infra/InMemoryRandomGuildCache.kt b/src/main/kotlin/org/gitanimals/guild/infra/InMemoryRandomGuildCache.kt new file mode 100644 index 0000000..1c0ebba --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/infra/InMemoryRandomGuildCache.kt @@ -0,0 +1,75 @@ +package org.gitanimals.guild.infra + +import org.gitanimals.guild.domain.Guild +import org.gitanimals.guild.domain.GuildService +import org.gitanimals.guild.domain.GuildService.Companion.PAGE_SIZE +import org.gitanimals.guild.domain.RandomGuildCache +import org.gitanimals.guild.domain.SearchFilter +import org.springframework.context.event.ContextRefreshedEvent +import org.springframework.context.event.EventListener +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.Pageable +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +@Component +class InMemoryRandomGuildCache( + private val guildService: GuildService, +) : RandomGuildCache { + + private lateinit var cache: Map> + + override fun get(key: Int, text: String, pageNumber: Int, filter: SearchFilter): Page { + if (MAX_PAGE <= pageNumber) { + return guildService.search( + text = text, + pageNumber = pageNumber, + filter = filter, + ) + } + + val guilds = cache[key % MAX_KEY] + ?: throw IllegalStateException("Cannot find random guild data from key \"$key\"") + + val filteredGuilds = guilds.filter { + if (text.isBlank()) { + true + } else { + it.getTitle().contains(text) or it.getBody().contains(text) + } + } + + val response = mutableListOf() + + repeat(PAGE_SIZE) { + val idx = it + pageNumber * PAGE_SIZE + + if (filteredGuilds.size <= idx) { + return@repeat + } + + response.add(filteredGuilds[idx]) + } + + return PageImpl(filter.sort(response), Pageable.ofSize(pageNumber), guilds.size.toLong()) + } + + @Scheduled(cron = ONCE_0AM_TIME) + @EventListener(ContextRefreshedEvent::class) + fun updateRandom() { + val guilds = guildService.findAllWithLimit(LIMIT) + + val updateCache = mutableMapOf>() + repeat(MAX_KEY) { updateCache[it] = guilds.shuffled() } + + cache = updateCache + } + + companion object { + private const val MAX_KEY = 3 + private const val ONCE_0AM_TIME = "0 0 0/1 * * ?" + private const val LIMIT = PAGE_SIZE * 10 + private const val MAX_PAGE = LIMIT / PAGE_SIZE + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/saga/PersonaDeletedSagaHandler.kt b/src/main/kotlin/org/gitanimals/guild/saga/PersonaDeletedSagaHandler.kt new file mode 100644 index 0000000..4dcb16a --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/saga/PersonaDeletedSagaHandler.kt @@ -0,0 +1,36 @@ +package org.gitanimals.guild.saga + +import org.gitanimals.guild.app.RenderApi +import org.gitanimals.guild.domain.GuildService +import org.gitanimals.guild.saga.event.PersonaDeleted +import org.rooftop.netx.api.SagaStartEvent +import org.rooftop.netx.api.SagaStartListener +import org.rooftop.netx.api.SuccessWith +import org.rooftop.netx.meta.SagaHandler + +@SagaHandler +class PersonaDeletedSagaHandler( + private val guildService: GuildService, + private val renderApi: RenderApi, +) { + + @SagaStartListener( + event = PersonaDeleted::class, + noRollbackFor = [IllegalStateException::class], + successWith = SuccessWith.END, + ) + fun handlePersonaDeletedEvent(sagaStartEvent: SagaStartEvent) { + val personaDeleted = sagaStartEvent.decodeEvent(PersonaDeleted::class) + + val changePersona = + renderApi.getUserByName(personaDeleted.username).personas.maxByOrNull { it.level } + ?: throw IllegalStateException("Cannot find any persona by username \"${personaDeleted.username}\"") + + guildService.deletePersonaSync( + userId = personaDeleted.userId, + deletedPersonaId = personaDeleted.personaId, + changePersonaId = changePersona.id.toLong(), + changePersonaType = changePersona.type, + ) + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/saga/UpdateGuildContributionSagaHandler.kt b/src/main/kotlin/org/gitanimals/guild/saga/UpdateGuildContributionSagaHandler.kt new file mode 100644 index 0000000..78c2b07 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/saga/UpdateGuildContributionSagaHandler.kt @@ -0,0 +1,26 @@ +package org.gitanimals.guild.saga + +import org.gitanimals.guild.domain.GuildService +import org.gitanimals.guild.saga.event.UserContributionUpdated +import org.rooftop.netx.api.SagaCommitEvent +import org.rooftop.netx.api.SagaCommitListener +import org.rooftop.netx.meta.SagaHandler + +@SagaHandler +class UpdateGuildContributionSagaHandler( + private val guildService: GuildService, +) { + + @SagaCommitListener( + event = UserContributionUpdated::class, + noRollbackFor = [Throwable::class], + ) + fun updateGuildContributions(sagaCommitEvent: SagaCommitEvent) { + val userContributionUpdated = sagaCommitEvent.decodeEvent(UserContributionUpdated::class) + + guildService.updateContribution( + username = userContributionUpdated.username, + contributions = userContributionUpdated.contributions.toLong(), + ) + } +} diff --git a/src/main/kotlin/org/gitanimals/guild/saga/event/PersonaDeleted.kt b/src/main/kotlin/org/gitanimals/guild/saga/event/PersonaDeleted.kt new file mode 100644 index 0000000..0c5f665 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/saga/event/PersonaDeleted.kt @@ -0,0 +1,11 @@ +package org.gitanimals.guild.saga.event + +import org.gitanimals.render.core.clock +import java.time.Instant + +data class PersonaDeleted( + val userId: Long, + val username: String, + val personaId: Long, + val personaDeletedAt: Instant = clock.instant(), +) diff --git a/src/main/kotlin/org/gitanimals/guild/saga/event/UserContributionUpdated.kt b/src/main/kotlin/org/gitanimals/guild/saga/event/UserContributionUpdated.kt new file mode 100644 index 0000000..7258a46 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/guild/saga/event/UserContributionUpdated.kt @@ -0,0 +1,7 @@ +package org.gitanimals.guild.saga.event + +data class UserContributionUpdated( + val username: String, + val point: Long, + val contributions: Int, +) diff --git a/src/main/kotlin/org/gitanimals/render/controller/response/UserResponse.kt b/src/main/kotlin/org/gitanimals/render/controller/response/UserResponse.kt index fb61032..7af2d0c 100644 --- a/src/main/kotlin/org/gitanimals/render/controller/response/UserResponse.kt +++ b/src/main/kotlin/org/gitanimals/render/controller/response/UserResponse.kt @@ -5,6 +5,7 @@ import org.gitanimals.render.domain.User data class UserResponse( val id: String, val name: String, + val totalContributions: String, private val personas: List, ) { @@ -13,6 +14,7 @@ data class UserResponse( return UserResponse( user.id.toString(), user.name, + user.contributionCount().toString(), user.personas.map { PersonaResponse( it.id.toString(), diff --git a/src/main/kotlin/org/gitanimals/render/domain/AbstractTime.kt b/src/main/kotlin/org/gitanimals/render/domain/AbstractTime.kt index 85f6ec8..40c3be4 100644 --- a/src/main/kotlin/org/gitanimals/render/domain/AbstractTime.kt +++ b/src/main/kotlin/org/gitanimals/render/domain/AbstractTime.kt @@ -1,13 +1,16 @@ package org.gitanimals.render.domain import jakarta.persistence.Column +import jakarta.persistence.EntityListeners import jakarta.persistence.MappedSuperclass import jakarta.persistence.PrePersist import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener import java.time.Instant @MappedSuperclass +@EntityListeners(AuditingEntityListener::class) abstract class AbstractTime( @CreatedDate @Column(name = "created_at") diff --git a/src/main/kotlin/org/gitanimals/render/domain/User.kt b/src/main/kotlin/org/gitanimals/render/domain/User.kt index 8db5c0d..94e83af 100644 --- a/src/main/kotlin/org/gitanimals/render/domain/User.kt +++ b/src/main/kotlin/org/gitanimals/render/domain/User.kt @@ -4,6 +4,8 @@ import com.fasterxml.jackson.annotation.JsonIgnore import jakarta.persistence.* import org.gitanimals.render.core.AggregateRoot import org.gitanimals.render.core.IdGenerator +import org.gitanimals.render.domain.event.PersonaDeleted +import org.gitanimals.render.domain.listeners.DomainEventPublisher import org.gitanimals.render.domain.response.PersonaResponse import org.gitanimals.render.domain.value.Contribution import org.gitanimals.render.domain.value.Level @@ -68,11 +70,33 @@ class User( return PersonaResponse.from(persona) } + fun mergePersona(increasePersonaId: Long, deletePersonaId: Long): Persona { + require(increasePersonaId != deletePersonaId) { + "increasePersonaId \"$increasePersonaId\", deletePersonaId \"$deletePersonaId\" must be different" + } + + val increasePersona = personas.first { it.id == increasePersonaId } + val deletePersona = personas.first { it.id == deletePersonaId } + + increasePersona.level.value += max(deletePersona.level.value / 2, 1) + + deletePersona(deletePersona.id) + + return increasePersona + } + fun deletePersona(personaId: Long): PersonaResponse { val persona = this.personas.find { it.id == personaId } ?: throw IllegalArgumentException("Cannot find persona by id \"$personaId\"") this.personas.remove(persona) + DomainEventPublisher.publish( + PersonaDeleted( + userId = id, + username = name, + personaId = personaId, + ) + ) return PersonaResponse.from(persona) } @@ -178,6 +202,7 @@ class User( return this.append("") } + fun createFarmAnimation(): String { val field = getOrCreateDefaultFieldIfAbsent() @@ -192,7 +217,6 @@ class User( .closeSvg() } - fun contributionCount(): Long = contributions.totalCount() fun changeField(fieldType: FieldType) { @@ -247,21 +271,6 @@ class User( .append("") .toString() - fun mergePersona(increasePersonaId: Long, deletePersonaId: Long): Persona { - require(increasePersonaId != deletePersonaId) { - "increasePersonaId \"$increasePersonaId\", deletePersonaId \"$deletePersonaId\" must be different" - } - - val increasePersona = personas.first { it.id == increasePersonaId } - val deletePersona = personas.first { it.id == deletePersonaId } - - increasePersona.level.value += max(deletePersona.level.value / 2, 1) - - personas.remove(deletePersona) - - return increasePersona - } - companion object { private const val MAX_PERSONA_COUNT = 30L private const val MAX_INIT_PERSONA_COUNT = 10L diff --git a/src/main/kotlin/org/gitanimals/render/domain/event/PersonaDeleted.kt b/src/main/kotlin/org/gitanimals/render/domain/event/PersonaDeleted.kt new file mode 100644 index 0000000..4913b2e --- /dev/null +++ b/src/main/kotlin/org/gitanimals/render/domain/event/PersonaDeleted.kt @@ -0,0 +1,11 @@ +package org.gitanimals.render.domain.event + +import org.gitanimals.render.core.clock +import java.time.Instant + +data class PersonaDeleted( + val userId: Long, + val username: String, + val personaId: Long, + val personaDeletedAt: Instant = clock.instant(), +) diff --git a/src/main/kotlin/org/gitanimals/render/domain/listeners/DomainEventPublisher.kt b/src/main/kotlin/org/gitanimals/render/domain/listeners/DomainEventPublisher.kt new file mode 100644 index 0000000..77430d7 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/render/domain/listeners/DomainEventPublisher.kt @@ -0,0 +1,21 @@ +package org.gitanimals.render.domain.listeners + +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component + +object DomainEventPublisher { + + private lateinit var applicationEventPublisher: ApplicationEventPublisher + + fun publish(event: T) { + applicationEventPublisher.publishEvent(event) + } + + @Component + class EventPublisherInjector(applicationEventPublisher: ApplicationEventPublisher) { + + init { + DomainEventPublisher.applicationEventPublisher = applicationEventPublisher + } + } +} diff --git a/src/main/kotlin/org/gitanimals/render/infra/CustomExecutorConfigurer.kt b/src/main/kotlin/org/gitanimals/render/infra/CustomExecutorConfigurer.kt new file mode 100644 index 0000000..601264f --- /dev/null +++ b/src/main/kotlin/org/gitanimals/render/infra/CustomExecutorConfigurer.kt @@ -0,0 +1,28 @@ +package org.gitanimals.render.infra + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor +import java.util.concurrent.Executor +import kotlin.time.Duration.Companion.minutes + +@Configuration +class CustomExecutorConfigurer { + + @Bean(GRACEFUL_SHUTDOWN_EXECUTOR) + fun taskExecutor(): Executor { + val executor = ThreadPoolTaskExecutor() + executor.corePoolSize = 2 + executor.maxPoolSize = 20 + executor.threadNamePrefix = "$GRACEFUL_SHUTDOWN_EXECUTOR-" + executor.setWaitForTasksToCompleteOnShutdown(true) + executor.setAwaitTerminationSeconds(2.minutes.inWholeSeconds.toInt()) + executor.initialize() + + return executor + } + + companion object { + const val GRACEFUL_SHUTDOWN_EXECUTOR = "gracefulShutdownExecutor" + } +} diff --git a/src/main/kotlin/org/gitanimals/render/infra/PersonaDeletedEventHandler.kt b/src/main/kotlin/org/gitanimals/render/infra/PersonaDeletedEventHandler.kt new file mode 100644 index 0000000..cc96993 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/render/infra/PersonaDeletedEventHandler.kt @@ -0,0 +1,20 @@ +package org.gitanimals.render.infra + +import org.gitanimals.render.domain.event.PersonaDeleted +import org.gitanimals.render.infra.CustomExecutorConfigurer.Companion.GRACEFUL_SHUTDOWN_EXECUTOR +import org.rooftop.netx.api.SagaManager +import org.springframework.context.event.EventListener +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component + +@Component +class PersonaDeletedEventHandler( + private val sagaManager: SagaManager, +) { + + @Async(GRACEFUL_SHUTDOWN_EXECUTOR) + @EventListener + fun handlePersonaDeletedEvent(personaDeleted: PersonaDeleted) { + sagaManager.startSync(personaDeleted) + } +} diff --git a/src/main/kotlin/org/gitanimals/render/saga/VisitedSagaHandlers.kt b/src/main/kotlin/org/gitanimals/render/saga/VisitedSagaHandlers.kt index 4b274e1..a426f17 100644 --- a/src/main/kotlin/org/gitanimals/render/saga/VisitedSagaHandlers.kt +++ b/src/main/kotlin/org/gitanimals/render/saga/VisitedSagaHandlers.kt @@ -43,7 +43,7 @@ class VisitedSagaHandlers( val increaseContributionCount = userService.updateContributions(username, contribution) sagaJoinEvent.setNextEvent( - GavePoint(username = username, point = (increaseContributionCount * 100).toLong()) + GavePoint(username = username, point = (increaseContributionCount * 100).toLong(), contribution) ) } } diff --git a/src/main/kotlin/org/gitanimals/render/saga/event/GavePoint.kt b/src/main/kotlin/org/gitanimals/render/saga/event/GavePoint.kt index 807cda2..08e3747 100644 --- a/src/main/kotlin/org/gitanimals/render/saga/event/GavePoint.kt +++ b/src/main/kotlin/org/gitanimals/render/saga/event/GavePoint.kt @@ -3,4 +3,5 @@ package org.gitanimals.render.saga.event data class GavePoint( val username: String, val point: Long, + val contributions: Int, ) diff --git a/src/test/kotlin/org/gitanimals/guild/app/CreateGuildFacadeTest.kt b/src/test/kotlin/org/gitanimals/guild/app/CreateGuildFacadeTest.kt new file mode 100644 index 0000000..9f81c5a --- /dev/null +++ b/src/test/kotlin/org/gitanimals/guild/app/CreateGuildFacadeTest.kt @@ -0,0 +1,126 @@ +package org.gitanimals.guild.app + +import io.kotest.assertions.nondeterministic.eventually +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrowAny +import io.kotest.assertions.throwables.shouldThrowExactly +import io.kotest.core.annotation.DisplayName +import io.kotest.core.spec.style.DescribeSpec +import io.mockk.every +import org.gitanimals.guild.app.request.CreateGuildRequest +import org.gitanimals.guild.domain.GuildFarmType +import org.gitanimals.guild.domain.GuildIcons +import org.gitanimals.guild.domain.GuildRepository +import org.gitanimals.guild.domain.GuildService +import org.gitanimals.guild.supports.RedisContainer +import org.gitanimals.guild.supports.GuildSagaCapture +import org.gitanimals.guild.supports.MockApiConfiguration +import org.rooftop.netx.meta.EnableSaga +import org.springframework.boot.actuate.autoconfigure.wavefront.WavefrontProperties.Application +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.TestPropertySource +import kotlin.time.Duration.Companion.seconds + +@EnableSaga +@DataJpaTest +@ContextConfiguration( + classes = [ + Application::class, + RedisContainer::class, + GuildSagaCapture::class, + CreateGuildFacade::class, + MockApiConfiguration::class, + GuildService::class, + ] +) +@DisplayName("CreateGuildFacade 클래스의") +@EntityScan(basePackages = ["org.gitanimals.guild"]) +@TestPropertySource("classpath:application.properties") +@EnableJpaRepositories(basePackages = ["org.gitanimals.guild"]) +internal class CreateGuildFacadeTest( + private val createGuildFacade: CreateGuildFacade, + private val guildSagaCapture: GuildSagaCapture, + private val identityApi: IdentityApi, + private val guildRepository: GuildRepository, +) : DescribeSpec({ + + beforeEach { + guildSagaCapture.clear() + guildRepository.deleteAll() + } + + describe("createGuild 메소드는") { + context("token에 해당하는 유저가 길드를 생성할 수 있는 돈을 갖고 있다면,") { + it("길드를 생성한다.") { + shouldNotThrowAny { + createGuildFacade.createGuild(TOKEN, createGuildRequest) + } + + guildSagaCapture.countShouldBe( + start = 1, + commit = 1, + ) + } + } + + context("token에 해당하는 유저가 길드를 생성할 수 있는 돈을 갖고 있지 않다면,") { + val poolUserToken = "Bearer pool" + + it("IllegalArgumentException을 던진다,") { + every { identityApi.getUserByToken(poolUserToken) } returns poolIdentityUserResponse + + shouldThrowExactly { + createGuildFacade.createGuild(poolUserToken, createGuildRequest) + } + + eventually(5.seconds) { + guildSagaCapture.countShouldBe(start = 1, rollback = 1) + } + } + } + + context("포인트 차감 이후 에러가 발생하면,") { + it("유저에게 돈을 돌려준다.") { + // Create Duplicate data set for throw error + createGuildFacade.createGuild(TOKEN, createGuildRequest) + guildSagaCapture.clear() + + shouldThrowAny { + createGuildFacade.createGuild(TOKEN, createGuildRequest) + } + + eventually(5.seconds) { + guildSagaCapture.countShouldBe( + start = 1, + commit = 1, + rollback = 1, + ) + } + } + } + } +}) { + private companion object { + private const val TOKEN = "Bearer ..." + + private val createGuildRequest = CreateGuildRequest( + title = "Gitanimals", + body = "We are gitanimals", + guildIcon = GuildIcons.CAT.getImagePath(), + autoJoin = true, + farmType = GuildFarmType.DUMMY, + personaId = "3", + ) + + private val poolIdentityUserResponse = IdentityApi.UserResponse( + id = "1", + username = "devxb", + points = "29999", + profileImage = "https://gitanimals.org" + ) + + } +} diff --git a/src/test/kotlin/org/gitanimals/guild/domain/Fixture.kt b/src/test/kotlin/org/gitanimals/guild/domain/Fixture.kt new file mode 100644 index 0000000..bc10d85 --- /dev/null +++ b/src/test/kotlin/org/gitanimals/guild/domain/Fixture.kt @@ -0,0 +1,59 @@ +package org.gitanimals.guild.domain + +fun guild( + id: Long = 1L, + guildIcon: String = "default_icon.png", + title: String = "Default Guild Title", + body: String = "Default guild description.", + leader: Leader = leader(), + members: MutableSet = mutableSetOf(), + waitMembers: MutableSet = mutableSetOf(), + farmType: GuildFarmType = GuildFarmType.DUMMY, + autoJoin: Boolean = true, +): Guild { + return Guild( + id = id, + guildIcon = guildIcon, + title = title, + body = body, + leader = leader, + members = members, + waitMembers = waitMembers, + farmType = farmType, + autoJoin = autoJoin, + ) +} + +fun leader( + userId: Long = 1L, + name: String = "Default Leader", + personaId: Long = 1L, + contributions: Long = 0L, + personaType: String = "GOOSE", +): Leader { + return Leader( + userId = userId, + name = name, + personaId = personaId, + contributions = contributions, + personaType = personaType, + ) +} + +fun member( + guild: Guild, + userId: Long = 2L, + name: String = "DefaultName", + personaId: Long = 200L, + contributions: Long = 500L, + personaType: String = "GOOSE", +): Member { + return Member.create( + guild = guild, + userId = userId, + name = name, + personaId = personaId, + contributions = contributions, + personaType = personaType, + ) +} diff --git a/src/test/kotlin/org/gitanimals/guild/domain/GuildServiceTest.kt b/src/test/kotlin/org/gitanimals/guild/domain/GuildServiceTest.kt new file mode 100644 index 0000000..ccedf29 --- /dev/null +++ b/src/test/kotlin/org/gitanimals/guild/domain/GuildServiceTest.kt @@ -0,0 +1,204 @@ +package org.gitanimals.guild.domain + +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrowExactly +import io.kotest.core.annotation.DisplayName +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import org.gitanimals.guild.domain.GuildService.Companion.loadMembers +import org.gitanimals.guild.domain.GuildService.Companion.loadWaitMembers +import org.gitanimals.guild.domain.request.CreateLeaderRequest +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import org.springframework.test.context.ContextConfiguration + +@DataJpaTest +@DisplayName("GuildService 클래스의") +@ContextConfiguration(classes = [GuildService::class]) +@EntityScan(basePackages = ["org.gitanimals.guild"]) +@EnableJpaRepositories(basePackages = ["org.gitanimals.guild"]) +internal class GuildServiceTest( + private val guildService: GuildService, + private val guildRepository: GuildRepository, +) : DescribeSpec({ + + afterEach { + guildRepository.deleteAll() + } + + describe("createGuild 메소드는") { + context("guild 정보와 leader 정보를 입력받으면") { + val guildIcon = GuildIcons.CAT.getImagePath() + val title = "guildTitle" + val body = "guildBody" + val farmType = GuildFarmType.DUMMY + val leaderRequest = CreateLeaderRequest( + userId = 1L, + name = "devxb", + personaId = 2L, + contributions = 3L, + personaType = "GOOSE", + ) + + it("중복된 길드가 아니라면 길드를 생성한다.") { + + shouldNotThrowAny { + guildService.createGuild( + guildIcon = guildIcon, + title = title, + body = body, + farmType = farmType, + createLeaderRequest = leaderRequest, + autoJoin = true, + ) + } + } + + it("중복된 길드라면 IllegalArgumentException을 던진다.") { + guildService.createGuild( + guildIcon = guildIcon, + title = title, + body = body, + farmType = farmType, + createLeaderRequest = leaderRequest, + autoJoin = true, + ) + + shouldThrowExactly { + guildService.createGuild( + guildIcon = guildIcon, + title = title, + body = body, + farmType = farmType, + createLeaderRequest = leaderRequest, + autoJoin = true, + ) + } + } + } + } + + describe("joinGuild 메소드는") { + context("guild가 autoJoin true라면,") { + val guild = guildRepository.save(guild()) + val memberUserId = 2L + val memberName = "devxb" + val memberPersonaId = 2L + val memberContributions = 3L + + + it("유저를 바로 길드에 가입시킨다.") { + guildService.joinGuild( + guildId = guild.id, + memberUserId = memberUserId, + memberName = memberName, + memberPersonaId = memberPersonaId, + memberContributions = memberContributions, + memberPersonaType = "GOOSE", + ) + + guildService.getGuildById(guild.id, loadMembers).getMembers().size shouldBe 1 + } + } + + context("guild가 autoJoin false라면,") { + val guild = guildRepository.save(guild(autoJoin = false)) + val memberUserId = 2L + val memberName = "devxb" + val memberPersonaId = 2L + val memberContributions = 3L + + + it("유저를 wait 대기열에 포함시킨다.") { + guildService.joinGuild( + guildId = guild.id, + memberUserId = memberUserId, + memberName = memberName, + memberPersonaId = memberPersonaId, + memberContributions = memberContributions, + memberPersonaType = "GOOSE", + ) + + guildService.getGuildById(guild.id, loadWaitMembers) + .getWaitMembers().size shouldBe 1 + } + } + + context("가입을 요청한 유저와 리더의 아이디가 같다면,") { + val memberUserId = 1L + val guild = guildRepository.save(guild(leader = leader(userId = memberUserId))) + val memberName = "devxb" + val memberPersonaId = 2L + val memberContributions = 3L + + it("IllegalArgumentException을 던진다.") { + shouldThrowExactly { + guildService.joinGuild( + guildId = guild.id, + memberUserId = memberUserId, + memberName = memberName, + memberPersonaId = memberPersonaId, + memberContributions = memberContributions, + memberPersonaType = "GOOSE", + ) + } + } + } + } + + describe("acceptJoinGuild 메소드는") { + context("가입을 수락한 사람이 길드 리더라면,") { + val guild = guildRepository.save(guild(autoJoin = false)) + val memberUserId = 2L + val memberName = "devxb" + val memberPersonaId = 2L + val memberContributions = 3L + + guildService.joinGuild( + guildId = guild.id, + memberUserId = memberUserId, + memberName = memberName, + memberPersonaId = memberPersonaId, + memberContributions = memberContributions, + memberPersonaType = "GOOSE", + ) + + it("멤버를 가입시킨다.") { + guildService.acceptJoin( + acceptorId = 1L, + guildId = guild.id, + acceptUserId = memberUserId + ) + + val result = guildService.getGuildById(guild.id, loadWaitMembers, loadMembers) + + result.getWaitMembers().size shouldBe 0 + result.getMembers().size shouldBe 1 + } + } + } + + describe("kickMember 메소드는") { + context("추방을 요청한 사람이 길드 리더라면,") { + val memberId = 2L + val guild = guildRepository.save( + guild(autoJoin = false).apply { + member(guild = this, userId = memberId) + } + ) + + it("멤버를 추방시킨다.") { + guildService.kickMember( + kickerId = guild.getLeaderId(), + guildId = guild.id, + kickUserId = memberId + ) + + val result = guildService.getGuildById(guild.id, loadWaitMembers, loadMembers) + + result.getMembers().size shouldBe 0 + } + } + } +}) diff --git a/src/test/kotlin/org/gitanimals/guild/saga/PersonaDeletedSagaHandlerTest.kt b/src/test/kotlin/org/gitanimals/guild/saga/PersonaDeletedSagaHandlerTest.kt new file mode 100644 index 0000000..e246d26 --- /dev/null +++ b/src/test/kotlin/org/gitanimals/guild/saga/PersonaDeletedSagaHandlerTest.kt @@ -0,0 +1,126 @@ +package org.gitanimals.guild.saga + +import io.kotest.assertions.nondeterministic.eventually +import io.kotest.core.annotation.DisplayName +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import org.gitanimals.guild.app.RenderApi +import org.gitanimals.guild.domain.* +import org.gitanimals.guild.saga.event.PersonaDeleted +import org.gitanimals.guild.supports.MockApiConfiguration +import org.gitanimals.render.supports.RedisContainer +import org.rooftop.netx.api.SagaManager +import org.rooftop.netx.meta.EnableSaga +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import org.springframework.data.repository.findByIdOrNull +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.TestPropertySource +import kotlin.time.Duration.Companion.seconds + +@EnableSaga +@DataJpaTest +@ContextConfiguration( + classes = [ + RedisContainer::class, + GuildService::class, + MockApiConfiguration::class, + PersonaDeletedSagaHandler::class, + ] +) +@EntityScan(basePackages = ["org.gitanimals.guild"]) +@EnableJpaRepositories(basePackages = ["org.gitanimals.guild"]) +@DisplayName("PersonaDeletedSagaHandler 클래스의") +@TestPropertySource("classpath:application.properties") +internal class PersonaDeletedSagaHandlerTest( + private val renderApi: RenderApi, + private val sagaManager: SagaManager, + private val guildRepository: GuildRepository, +) : DescribeSpec({ + describe("handlePersonaDeletedEvent 메소드는") { + context("PersonaDeletedEvent를 받으면,") { + val leaderId = 999L + val leaderPersonaId = 100L + val leaderName = "devxb" + var guild = guild( + leader = leader( + userId = leaderId, + name = "devxb", + personaId = leaderPersonaId + ) + ) + + val memberId = 200L + val memberPersonaId = 200L + val memberName = "member" + guild.join( + memberUserId = memberId, + memberName = memberName, + memberPersonaId = memberPersonaId, + memberContributions = 100L, + memberPersonaType = "GOOSE", + ) + guild = guildRepository.save(guild) + + val changePersonaId = 101L + val leaderPersonaDeleted = PersonaDeleted( + userId = leaderId, + username = leaderName, + personaId = leaderPersonaId, + ) + + val memberPersonaDeleted = PersonaDeleted( + userId = memberId, + username = memberName, + personaId = memberPersonaId, + ) + + every { renderApi.getUserByName(leaderName) } returns RenderApi.UserResponse( + id = "1", + name = "devxb", + totalContributions = "1", + personas = listOf( + RenderApi.UserResponse.PersonaResponse( + changePersonaId.toString(), + "10", + "GOOSE", + ) + ) + ) + + every { renderApi.getUserByName(memberName) } returns RenderApi.UserResponse( + id = "1", + name = "member", + totalContributions = "1", + personas = listOf( + RenderApi.UserResponse.PersonaResponse( + changePersonaId.toString(), + "10", + "GOOSE", + ) + ) + ) + + it("리더의 삭제된 펫을 모두 찾아 새로운 펫으로 변경한다.") { + sagaManager.startSync(leaderPersonaDeleted) + + eventually(5.seconds) { + guildRepository.findByIdOrNull(guild.id) + ?.getLeaderPersonaId() shouldBe changePersonaId + } + } + + it("멤버의 삭제된 펫을 모두 찾아 새로운 펫으로 변경한다.") { + sagaManager.startSync(memberPersonaDeleted) + + eventually(5.seconds) { + guildRepository.findAllGuildByUserIdWithMembers(memberId)[0] + .getMembers() + .first { it.userId == memberId }.personaId shouldBe changePersonaId + } + } + } + } +}) diff --git a/src/test/kotlin/org/gitanimals/guild/supports/GuildSagaCapture.kt b/src/test/kotlin/org/gitanimals/guild/supports/GuildSagaCapture.kt new file mode 100644 index 0000000..9b4143b --- /dev/null +++ b/src/test/kotlin/org/gitanimals/guild/supports/GuildSagaCapture.kt @@ -0,0 +1,63 @@ +package org.gitanimals.guild.supports + +import io.kotest.matchers.equals.shouldBeEqual +import org.rooftop.netx.api.* +import org.rooftop.netx.meta.SagaHandler + +@SagaHandler +class GuildSagaCapture { + + val storage = mutableMapOf() + + fun clear() { + storage.clear() + } + + fun countShouldBe( + start: Int = 0, + join: Int = 0, + commit: Int = 0, + rollback: Int = 0, + ) { + startCountShouldBe(start) + joinCountShouldBe(join) + commitCountShouldBe(commit) + rollbackCountShouldBe(rollback) + } + + fun startCountShouldBe(count: Int) { + (storage["start"] ?: 0) shouldBeEqual count + } + + fun joinCountShouldBe(count: Int) { + (storage["join"] ?: 0) shouldBeEqual count + } + + fun commitCountShouldBe(count: Int) { + (storage["commit"] ?: 0) shouldBeEqual count + } + + fun rollbackCountShouldBe(count: Int) { + (storage["rollback"] ?: 0) shouldBeEqual count + } + + @SagaStartListener(successWith = SuccessWith.END) + fun captureStart(startEvent: SagaStartEvent) { + storage["start"] = (storage["start"] ?: 0) + 1 + } + + @SagaJoinListener(successWith = SuccessWith.END) + fun captureJoin(joinEvent: SagaJoinEvent) { + storage["join"] = (storage["join"] ?: 0) + 1 + } + + @SagaCommitListener + fun captureCommit(commitEvent: SagaCommitEvent) { + storage["commit"] = (storage["commit"] ?: 0) + 1 + } + + @SagaRollbackListener + fun captureRollback(rollbackEvent: SagaRollbackEvent) { + storage["rollback"] = (storage["rollback"] ?: 0) + 1 + } +} diff --git a/src/test/kotlin/org/gitanimals/guild/supports/MockApiConfiguration.kt b/src/test/kotlin/org/gitanimals/guild/supports/MockApiConfiguration.kt new file mode 100644 index 0000000..2814dcc --- /dev/null +++ b/src/test/kotlin/org/gitanimals/guild/supports/MockApiConfiguration.kt @@ -0,0 +1,55 @@ +package org.gitanimals.guild.supports + +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import org.gitanimals.guild.app.IdentityApi +import org.gitanimals.guild.app.RenderApi +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean + +@TestConfiguration +class MockApiConfiguration { + + @Bean + fun identityApi(): IdentityApi = mockk().apply { + val api = this + every { api.increasePoint(any(), any(), any(), any()) } just Runs + every { api.decreasePoint(any(), any(), any(), any()) } just Runs + every { api.getUserByToken(any()) } returns identityUserResponse + } + + @Bean + fun renderApi(): RenderApi = mockk().apply { + val api = this + every { api.getUserByName(any()) } returns renderUserResponse + } + + companion object { + val identityUserResponse = IdentityApi.UserResponse( + id = "1", + username = "devxb", + points = "30000", + profileImage = "https://gitanimals.org" + ) + + val renderUserResponse = RenderApi.UserResponse( + id = "2", + name = "devxb", + totalContributions = "9999", + personas = listOf( + RenderApi.UserResponse.PersonaResponse( + id = "3", + level = "99", + type = "GOOSE", + ), + RenderApi.UserResponse.PersonaResponse( + id = "4", + level = "98", + type = "GOOSE", + ), + ) + ) + } +} diff --git a/src/test/kotlin/org/gitanimals/guild/supports/RedisContainer.kt b/src/test/kotlin/org/gitanimals/guild/supports/RedisContainer.kt new file mode 100644 index 0000000..9c7686a --- /dev/null +++ b/src/test/kotlin/org/gitanimals/guild/supports/RedisContainer.kt @@ -0,0 +1,27 @@ +package org.gitanimals.guild.supports + + +import org.springframework.boot.test.context.TestConfiguration +import org.testcontainers.containers.GenericContainer +import org.testcontainers.utility.DockerImageName + +@TestConfiguration +internal class RedisContainer { + init { + val redis: GenericContainer<*> = GenericContainer(DockerImageName.parse("redis:7.2.3")) + .withExposedPorts(6379) + + runCatching { + redis.start() + }.onFailure { + if (it is com.github.dockerjava.api.exception.InternalServerErrorException) { + redis.start() + } + } + + System.setProperty( + "netx.port", + redis.getMappedPort(6379).toString() + ) + } +} diff --git a/src/test/kotlin/org/gitanimals/render/app/UserStatisticScheduleTest.kt b/src/test/kotlin/org/gitanimals/render/app/UserStatisticScheduleTest.kt index 086d351..819ce65 100644 --- a/src/test/kotlin/org/gitanimals/render/app/UserStatisticScheduleTest.kt +++ b/src/test/kotlin/org/gitanimals/render/app/UserStatisticScheduleTest.kt @@ -1,23 +1,33 @@ package org.gitanimals.render.app import io.kotest.assertions.nondeterministic.eventually +import io.kotest.core.annotation.DisplayName import io.kotest.core.spec.style.DescribeSpec -import org.gitanimals.Application +import org.gitanimals.render.domain.UserStatisticService import org.gitanimals.render.supports.RedisContainer import org.gitanimals.render.supports.SagaCapture -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.test.context.ActiveProfiles +import org.rooftop.netx.meta.EnableSaga +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.TestPropertySource import kotlin.time.Duration.Companion.seconds -@SpringBootTest( +@EnableSaga +@DataJpaTest +@ContextConfiguration( classes = [ - Application::class, RedisContainer::class, SagaCapture::class, + UserStatisticSchedule::class, + UserStatisticService::class, ] ) @TestPropertySource("classpath:application.properties") +@DisplayName("UserStatisticSchedule 클래스의") +@EntityScan(basePackages = ["org.gitanimals.render.domain"]) +@EnableJpaRepositories(basePackages = ["org.gitanimals.render.domain"]) internal class UserStatisticScheduleTest( private val userStatisticSchedule: UserStatisticSchedule, private val sagaCapture: SagaCapture, diff --git a/src/test/kotlin/org/gitanimals/render/controller/Api.kt b/src/test/kotlin/org/gitanimals/render/controller/Api.kt deleted file mode 100644 index 3f9cf2a..0000000 --- a/src/test/kotlin/org/gitanimals/render/controller/Api.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.gitanimals.render.controller - -import io.restassured.RestAssured -import io.restassured.http.ContentType -import io.restassured.response.ExtractableResponse -import io.restassured.response.Response - -fun users(username: String): ExtractableResponse = - RestAssured.given().log().all() - .contentType(ContentType.JSON) - .accept(ContentType.JSON) - .`when`().log().all() - .get("/users/$username") - .then().log().all() - .extract() diff --git a/src/test/kotlin/org/gitanimals/render/controller/filter/CorsFilterTest.kt b/src/test/kotlin/org/gitanimals/render/controller/filter/CorsFilterTest.kt deleted file mode 100644 index 8944ebe..0000000 --- a/src/test/kotlin/org/gitanimals/render/controller/filter/CorsFilterTest.kt +++ /dev/null @@ -1,63 +0,0 @@ -package org.gitanimals.render.controller.filter - -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.collections.shouldExistInOrder -import io.restassured.RestAssured -import io.restassured.http.Header -import org.gitanimals.render.controller.users -import org.gitanimals.render.domain.User -import org.gitanimals.render.domain.UserRepository -import org.gitanimals.render.supports.RedisContainer -import org.junit.jupiter.api.DisplayName -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.web.server.LocalServerPort -import org.springframework.test.context.ContextConfiguration - -@ContextConfiguration( - classes = [ - RedisContainer::class - ] -) -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@DisplayName("Cors 적용 테스트의") -internal class CorsFilterTest( - @LocalServerPort private val port: Int, - private val userRepository: UserRepository, -) : DescribeSpec({ - - beforeSpec { - RestAssured.port = port - } - - afterEach { userRepository.deleteAll() } - - describe("/users/{username} api는") { - context("호출되면, ") { - it("cors 허용 header들을 추가해서 반환한다.") { - val user = userRepository.saveAndFlush(user) - - val response = users(user.name) - - response.headers().shouldExistInOrder( - listOf( - { it == Header("Access-Control-Allow-Origin", "*") }, - { it == Header("Access-Control-Allow-Methods", "*") }, - { it == Header("Access-Control-Max-Age", "3600") }, - { - it == Header( - "Access-Control-Allow-Headers", - "Origin, X-Requested-With, Content-Type, Accept, Authorization, Api-Version" - ) - } - ) - ) - } - } - } - -}) { - - private companion object { - private val user = User.newUser("devxb", mutableMapOf(2024 to 1000)) - } -} diff --git a/src/test/kotlin/org/gitanimals/render/domain/UserFixture.kt b/src/test/kotlin/org/gitanimals/render/domain/UserFixture.kt index b177326..e3e719b 100644 --- a/src/test/kotlin/org/gitanimals/render/domain/UserFixture.kt +++ b/src/test/kotlin/org/gitanimals/render/domain/UserFixture.kt @@ -9,7 +9,6 @@ fun user( personas: MutableList = mutableListOf(), contributions: MutableList = mutableListOf(), visit: Long = 0L, - field: FieldType = FieldType.WHITE_FIELD, ): User { return User( id = id, @@ -17,7 +16,6 @@ fun user( personas = personas, contributions = contributions, visit = visit, - field = field, version = 0L, lastPersonaGivePoint = 0, ) diff --git a/src/test/kotlin/org/gitanimals/render/domain/UserServiceTest.kt b/src/test/kotlin/org/gitanimals/render/domain/UserServiceTest.kt index 15ff978..ffebd4d 100644 --- a/src/test/kotlin/org/gitanimals/render/domain/UserServiceTest.kt +++ b/src/test/kotlin/org/gitanimals/render/domain/UserServiceTest.kt @@ -3,20 +3,16 @@ package org.gitanimals.render.domain import io.kotest.core.annotation.DisplayName import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.nulls.shouldBeNull -import org.springframework.boot.autoconfigure.domain.EntityScan -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest -import org.springframework.data.jpa.repository.config.EnableJpaRepositories -import org.springframework.test.context.ContextConfiguration +import org.gitanimals.render.domain.listeners.DomainEventPublisher +import org.gitanimals.render.supports.IntegrationTest -@DataJpaTest -@DisplayName("UserService 클래스의") -@ContextConfiguration( +@IntegrationTest( classes = [ UserService::class, + DomainEventPublisher.EventPublisherInjector::class, ] ) -@EntityScan(basePackages = ["org.gitanimals.render.domain"]) -@EnableJpaRepositories(basePackages = ["org.gitanimals.render.domain"]) +@DisplayName("UserService 클래스의") internal class UserServiceTest( private val userService: UserService, private val userRepository: UserRepository, diff --git a/src/test/kotlin/org/gitanimals/render/domain/UserTest.kt b/src/test/kotlin/org/gitanimals/render/domain/UserTest.kt index a716253..2eb0273 100644 --- a/src/test/kotlin/org/gitanimals/render/domain/UserTest.kt +++ b/src/test/kotlin/org/gitanimals/render/domain/UserTest.kt @@ -6,12 +6,33 @@ import io.kotest.core.annotation.DisplayName import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.equals.shouldBeEqual import io.kotest.matchers.nulls.shouldNotBeNull +import org.gitanimals.render.domain.event.PersonaDeleted +import org.gitanimals.render.domain.listeners.DomainEventPublisher import org.gitanimals.render.domain.value.Contribution +import org.gitanimals.render.supports.DomainEventHolder +import org.springframework.test.context.ContextConfiguration import java.time.Instant import java.time.temporal.ChronoUnit +@ContextConfiguration( + classes = [ + DomainEventHolder::class, + DomainEventPublisher.EventPublisherInjector::class, + ] +) @DisplayName("User 클래스의") -internal class UserTest : DescribeSpec({ +internal class UserTest( + /** + * 빈 주입 순서로 인해서 주입을 받아야한다. + * 그러지 않으면 lazy initialize 에러가 발생한다. + */ + eventPublisherInjector: DomainEventPublisher.EventPublisherInjector, + private val domainEventHolder: DomainEventHolder, +) : DescribeSpec({ + + beforeEach { + domainEventHolder.deleteAll() + } describe("newUser 메소드는") { context("이름에 [대문자, -, 소문자, 숫자]로 이루어진 문장이 들어올 경우") { @@ -91,17 +112,6 @@ internal class UserTest : DescribeSpec({ user.personas.find { it.type == PersonaType.PENGUIN }.shouldNotBeNull() } } - - context("Bonus pet 목록에 등록되지 않은 pet의 이름이 주어질 경우,") { - val user = User.newUser("new-user", mutableMapOf()) - val persona = PersonaType.GOBLIN_BAG - - it("예외를 던진다.") { - shouldThrowWithMessage("Cannot select as a bonus persona.") { - user.giveNewPersonaByType(persona) - } - } - } } describe("mergePersona 메소드는") { @@ -116,6 +126,20 @@ internal class UserTest : DescribeSpec({ user.mergePersona(increasePersonaId, deletePersonaId) user.personas.size shouldBeEqual 1 + domainEventHolder.eventsShouldBe(PersonaDeleted::class, 1) + } + } + } + + describe("deletePersona 메소드는") { + context("personaId를 받으면,") { + val user = User.newUser("devxb", mapOf()) + val personaId = user.personas[0].id + + it("persona를 삭제하고 PersonaDeleted 이벤트를 발행한다.") { + user.deletePersona(personaId) + + domainEventHolder.eventsShouldBe(PersonaDeleted::class, 1) } } } diff --git a/src/test/kotlin/org/gitanimals/render/saga/UsedCouponSagaHandlerTest.kt b/src/test/kotlin/org/gitanimals/render/saga/UsedCouponSagaHandlerTest.kt index 5bcede6..2f946c3 100644 --- a/src/test/kotlin/org/gitanimals/render/saga/UsedCouponSagaHandlerTest.kt +++ b/src/test/kotlin/org/gitanimals/render/saga/UsedCouponSagaHandlerTest.kt @@ -8,20 +8,35 @@ import io.kotest.matchers.equals.shouldBeEqual import io.kotest.matchers.should import org.gitanimals.render.domain.PersonaType import org.gitanimals.render.domain.UserRepository +import org.gitanimals.render.domain.UserService import org.gitanimals.render.domain.user import org.gitanimals.render.saga.event.CouponUsed import org.gitanimals.render.supports.RedisContainer +import org.gitanimals.render.supports.SagaCapture import org.rooftop.netx.api.SagaManager -import org.springframework.boot.test.context.SpringBootTest +import org.rooftop.netx.meta.EnableSaga +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.data.jpa.repository.config.EnableJpaRepositories import org.springframework.data.repository.findByIdOrNull +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.TestPropertySource import kotlin.time.Duration.Companion.seconds -@SpringBootTest( +@EnableSaga +@DataJpaTest +@ContextConfiguration( classes = [ RedisContainer::class, + SagaCapture::class, + UserService::class, + UsedCouponSagaHandlers::class, ] ) @DisplayName("UsedCouponSagaHandler 클래스의") +@TestPropertySource("classpath:application.properties") +@EntityScan(basePackages = ["org.gitanimals.render.domain"]) +@EnableJpaRepositories(basePackages = ["org.gitanimals.render.domain"]) internal class UsedCouponSagaHandlerTest( private val sagaManager: SagaManager, private val userRepository: UserRepository, diff --git a/src/test/kotlin/org/gitanimals/render/supports/DomainEventHolder.kt b/src/test/kotlin/org/gitanimals/render/supports/DomainEventHolder.kt new file mode 100644 index 0000000..a2471af --- /dev/null +++ b/src/test/kotlin/org/gitanimals/render/supports/DomainEventHolder.kt @@ -0,0 +1,24 @@ +package org.gitanimals.render.supports + +import io.kotest.matchers.shouldBe +import org.gitanimals.render.domain.event.PersonaDeleted +import org.springframework.boot.test.context.TestComponent +import org.springframework.context.event.EventListener +import kotlin.reflect.KClass + +@TestComponent +class DomainEventHolder { + + private val events = mutableMapOf, Int>() + + @EventListener(PersonaDeleted::class) + fun handlePersonaDeleted(personaDeleted: PersonaDeleted) { + events[personaDeleted::class] = events.getOrDefault(personaDeleted::class, 0) + 1 + } + + fun eventsShouldBe(kClass: KClass<*>, count: Int) { + events[kClass] shouldBe count + } + + fun deleteAll() = events.clear() +} diff --git a/src/test/kotlin/org/gitanimals/render/supports/IntegrationTest.kt b/src/test/kotlin/org/gitanimals/render/supports/IntegrationTest.kt new file mode 100644 index 0000000..3ad318f --- /dev/null +++ b/src/test/kotlin/org/gitanimals/render/supports/IntegrationTest.kt @@ -0,0 +1,19 @@ +package org.gitanimals.render.supports + +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.core.annotation.AliasFor +import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.event.RecordApplicationEvents +import kotlin.reflect.KClass + +@DataJpaTest +@ContextConfiguration +@RecordApplicationEvents +@EntityScan(basePackages = ["org.gitanimals.render.domain"]) +@EnableJpaRepositories(basePackages = ["org.gitanimals.render.domain"]) +annotation class IntegrationTest( + @get:AliasFor(annotation = ContextConfiguration::class, value = "classes") + val classes: Array>, +)