test #426

Merged
klaus merged 415 commits from test into main 2026-06-27 00:35:30 +00:00
3 changed files with 248 additions and 0 deletions
Showing only changes of commit 216850c07a - Show all commits

View File

@@ -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
)

View File

@@ -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()
}
}

View File

@@ -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()))
}
}