test #426
@@ -0,0 +1,104 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class UserCreatorChatPresenceService(
|
||||||
|
private val stringRedisTemplate: StringRedisTemplate,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
@Qualifier("userCreatorChatWebSocketServerId") private val serverId: String
|
||||||
|
) {
|
||||||
|
fun markJoined(roomId: Long, memberId: Long, sessionId: String) {
|
||||||
|
stringRedisTemplate.opsForValue().set(
|
||||||
|
presenceKey(roomId, memberId, sessionId),
|
||||||
|
presenceJson(roomId, memberId, sessionId),
|
||||||
|
PRESENCE_TTL
|
||||||
|
)
|
||||||
|
stringRedisTemplate.opsForSet().add(memberSessionsKey(roomId, memberId), sessionId)
|
||||||
|
stringRedisTemplate.expire(memberSessionsKey(roomId, memberId), PRESENCE_TTL)
|
||||||
|
stringRedisTemplate.opsForSet().add(roomKey(roomId), memberId.toString())
|
||||||
|
stringRedisTemplate.expire(roomKey(roomId), PRESENCE_TTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh(roomId: Long, memberId: Long, sessionId: String) {
|
||||||
|
stringRedisTemplate.opsForValue().set(
|
||||||
|
presenceKey(roomId, memberId, sessionId),
|
||||||
|
presenceJson(roomId, memberId, sessionId),
|
||||||
|
PRESENCE_TTL
|
||||||
|
)
|
||||||
|
stringRedisTemplate.expire(memberSessionsKey(roomId, memberId), PRESENCE_TTL)
|
||||||
|
stringRedisTemplate.expire(roomKey(roomId), PRESENCE_TTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markLeft(roomId: Long, memberId: Long, sessionId: String) {
|
||||||
|
stringRedisTemplate.delete(presenceKey(roomId, memberId, sessionId))
|
||||||
|
stringRedisTemplate.opsForSet().remove(memberSessionsKey(roomId, memberId), sessionId)
|
||||||
|
|
||||||
|
removeMemberPresenceIfNoLiveSession(roomId, memberId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasPresence(roomId: Long, memberId: Long): Boolean {
|
||||||
|
return findLiveSessionIds(roomId, memberId).isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeMemberPresenceIfNoLiveSession(roomId: Long, memberId: Long) {
|
||||||
|
if (findLiveSessionIds(roomId, memberId).isNotEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stringRedisTemplate.delete(memberSessionsKey(roomId, memberId))
|
||||||
|
stringRedisTemplate.opsForSet().remove(roomKey(roomId), memberId.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findLiveSessionIds(roomId: Long, memberId: Long): Set<String> {
|
||||||
|
val sessionIds = stringRedisTemplate.opsForSet().members(memberSessionsKey(roomId, memberId)) ?: emptySet()
|
||||||
|
return sessionIds.filterTo(mutableSetOf()) { sessionId ->
|
||||||
|
val isLive = stringRedisTemplate.hasKey(presenceKey(roomId, memberId, sessionId)) == true
|
||||||
|
if (!isLive) {
|
||||||
|
stringRedisTemplate.opsForSet().remove(memberSessionsKey(roomId, memberId), sessionId)
|
||||||
|
}
|
||||||
|
isLive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun presenceJson(roomId: Long, memberId: Long, sessionId: String): String {
|
||||||
|
return objectMapper.writeValueAsString(
|
||||||
|
UserCreatorChatRedisPresence(
|
||||||
|
serverId = serverId,
|
||||||
|
memberId = memberId,
|
||||||
|
roomId = roomId,
|
||||||
|
sessionId = sessionId,
|
||||||
|
lastSeenAt = Instant.now()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val PRESENCE_TTL = Duration.ofSeconds(90)
|
||||||
|
|
||||||
|
fun presenceKey(roomId: Long, memberId: Long, sessionId: String): String {
|
||||||
|
return "v2:user-creator-chat:ws:presence:$roomId:$memberId:$sessionId"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun memberSessionsKey(roomId: Long, memberId: Long): String {
|
||||||
|
return "v2:user-creator-chat:ws:room:$roomId:member:$memberId:sessions"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun roomKey(roomId: Long): String {
|
||||||
|
return "v2:user-creator-chat:ws:room:$roomId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class UserCreatorChatRedisPresence(
|
||||||
|
val serverId: String,
|
||||||
|
val memberId: Long,
|
||||||
|
val roomId: Long,
|
||||||
|
val sessionId: String,
|
||||||
|
val lastSeenAt: Instant
|
||||||
|
)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
class UserCreatorChatWebSocketServerIdConfig {
|
||||||
|
@Bean
|
||||||
|
fun userCreatorChatWebSocketServerId(
|
||||||
|
@Value("\${user-creator-chat.websocket.server-id:}") configuredServerId: String
|
||||||
|
): String {
|
||||||
|
return configuredServerId.takeIf { it.isNotBlank() } ?: UUID.randomUUID().toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.ArgumentCaptor
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.data.redis.core.SetOperations
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate
|
||||||
|
import org.springframework.data.redis.core.ValueOperations
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
class UserCreatorChatPresenceServiceTest {
|
||||||
|
private val stringRedisTemplate = Mockito.mock(StringRedisTemplate::class.java)
|
||||||
|
private val valueOperations = Mockito.mock(ValueOperations::class.java) as ValueOperations<String, String>
|
||||||
|
private val setOperations = Mockito.mock(SetOperations::class.java) as SetOperations<String, String>
|
||||||
|
private val objectMapper = ObjectMapper()
|
||||||
|
.findAndRegisterModules()
|
||||||
|
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||||
|
private val service = UserCreatorChatPresenceService(stringRedisTemplate, objectMapper, "test-server")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("join 시 session presence와 member session index에 TTL을 설정한다")
|
||||||
|
fun shouldMarkJoinedWithTtl() {
|
||||||
|
givenRedisOperations()
|
||||||
|
|
||||||
|
service.markJoined(roomId = 10L, memberId = 20L, sessionId = "session-1")
|
||||||
|
|
||||||
|
val presenceJsonCaptor = ArgumentCaptor.forClass(String::class.java)
|
||||||
|
Mockito.verify(valueOperations).set(
|
||||||
|
Mockito.eq("v2:user-creator-chat:ws:presence:10:20:session-1"),
|
||||||
|
presenceJsonCaptor.capture(),
|
||||||
|
Mockito.eq(Duration.ofSeconds(90))
|
||||||
|
)
|
||||||
|
assertPresenceJson(presenceJsonCaptor.value)
|
||||||
|
Mockito.verify(setOperations).add("v2:user-creator-chat:ws:room:10:member:20:sessions", "session-1")
|
||||||
|
Mockito.verify(stringRedisTemplate).expire(
|
||||||
|
"v2:user-creator-chat:ws:room:10:member:20:sessions",
|
||||||
|
Duration.ofSeconds(90)
|
||||||
|
)
|
||||||
|
Mockito.verify(setOperations).add("v2:user-creator-chat:ws:room:10", "20")
|
||||||
|
Mockito.verify(stringRedisTemplate).expire("v2:user-creator-chat:ws:room:10", Duration.ofSeconds(90))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("refresh 시 기존 session presence와 index TTL을 갱신한다")
|
||||||
|
fun shouldRefreshPresenceTtl() {
|
||||||
|
givenRedisOperations()
|
||||||
|
|
||||||
|
service.refresh(roomId = 10L, memberId = 20L, sessionId = "session-1")
|
||||||
|
|
||||||
|
val presenceJsonCaptor = ArgumentCaptor.forClass(String::class.java)
|
||||||
|
Mockito.verify(valueOperations).set(
|
||||||
|
Mockito.eq("v2:user-creator-chat:ws:presence:10:20:session-1"),
|
||||||
|
presenceJsonCaptor.capture(),
|
||||||
|
Mockito.eq(Duration.ofSeconds(90))
|
||||||
|
)
|
||||||
|
assertPresenceJson(presenceJsonCaptor.value)
|
||||||
|
Mockito.verify(stringRedisTemplate).expire(
|
||||||
|
"v2:user-creator-chat:ws:room:10:member:20:sessions",
|
||||||
|
Duration.ofSeconds(90)
|
||||||
|
)
|
||||||
|
Mockito.verify(stringRedisTemplate).expire("v2:user-creator-chat:ws:room:10", Duration.ofSeconds(90))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("leave 시 session presence를 삭제하고 마지막 session이면 member presence를 제거한다")
|
||||||
|
fun shouldMarkLeftAndRemoveMemberPresenceWhenLastSessionLeaves() {
|
||||||
|
givenRedisOperations()
|
||||||
|
Mockito.`when`(setOperations.size("v2:user-creator-chat:ws:room:10:member:20:sessions")).thenReturn(0L)
|
||||||
|
|
||||||
|
service.markLeft(roomId = 10L, memberId = 20L, sessionId = "session-1")
|
||||||
|
|
||||||
|
Mockito.verify(stringRedisTemplate).delete("v2:user-creator-chat:ws:presence:10:20:session-1")
|
||||||
|
Mockito.verify(setOperations).remove("v2:user-creator-chat:ws:room:10:member:20:sessions", "session-1")
|
||||||
|
Mockito.verify(stringRedisTemplate).delete("v2:user-creator-chat:ws:room:10:member:20:sessions")
|
||||||
|
Mockito.verify(setOperations).remove("v2:user-creator-chat:ws:room:10", "20")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("leave 후 남은 session id가 stale이면 member presence를 제거한다")
|
||||||
|
fun shouldRemoveMemberPresenceWhenRemainingSessionIdsAreStale() {
|
||||||
|
givenRedisOperations()
|
||||||
|
Mockito.`when`(setOperations.size("v2:user-creator-chat:ws:room:10:member:20:sessions")).thenReturn(1L)
|
||||||
|
Mockito.`when`(setOperations.members("v2:user-creator-chat:ws:room:10:member:20:sessions"))
|
||||||
|
.thenReturn(setOf("session-2"))
|
||||||
|
Mockito.`when`(stringRedisTemplate.hasKey("v2:user-creator-chat:ws:presence:10:20:session-2"))
|
||||||
|
.thenReturn(false)
|
||||||
|
|
||||||
|
service.markLeft(roomId = 10L, memberId = 20L, sessionId = "session-1")
|
||||||
|
|
||||||
|
Mockito.verify(setOperations).remove("v2:user-creator-chat:ws:room:10:member:20:sessions", "session-2")
|
||||||
|
Mockito.verify(stringRedisTemplate).delete("v2:user-creator-chat:ws:room:10:member:20:sessions")
|
||||||
|
Mockito.verify(setOperations).remove("v2:user-creator-chat:ws:room:10", "20")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("member session index에 live session key가 있으면 room presence가 있다고 판단한다")
|
||||||
|
fun shouldReturnPresenceFromMemberSessionIndex() {
|
||||||
|
givenRedisOperations()
|
||||||
|
Mockito.`when`(setOperations.members("v2:user-creator-chat:ws:room:10:member:20:sessions"))
|
||||||
|
.thenReturn(setOf("session-1"))
|
||||||
|
Mockito.`when`(stringRedisTemplate.hasKey("v2:user-creator-chat:ws:presence:10:20:session-1"))
|
||||||
|
.thenReturn(true)
|
||||||
|
|
||||||
|
val hasPresence = service.hasPresence(roomId = 10L, memberId = 20L)
|
||||||
|
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(true, hasPresence, "Expected member presence to be true")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun givenRedisOperations() {
|
||||||
|
Mockito.`when`(stringRedisTemplate.opsForValue()).thenReturn(valueOperations)
|
||||||
|
Mockito.`when`(stringRedisTemplate.opsForSet()).thenReturn(setOperations)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertPresenceJson(json: String) {
|
||||||
|
val presence = objectMapper.readTree(json)
|
||||||
|
assertEquals("test-server", presence["serverId"].asText())
|
||||||
|
assertEquals(20L, presence["memberId"].asLong())
|
||||||
|
assertEquals(10L, presence["roomId"].asLong())
|
||||||
|
assertEquals("session-1", presence["sessionId"].asText())
|
||||||
|
assertNotNull(Instant.parse(presence["lastSeenAt"].asText()))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user