test(user-creator-chat): WebSocket Redis 통합 검증을 추가한다

This commit is contained in:
2026-06-18 19:08:59 +09:00
parent f44ea58ca2
commit 282bc078e5

View File

@@ -0,0 +1,133 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource
import org.springframework.web.socket.TextMessage
import org.springframework.web.socket.WebSocketSession
import java.time.Instant
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@SpringBootTest
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@TestPropertySource(properties = ["user-creator-chat.websocket.server-id=redis-test-server"])
class UserCreatorChatRedisIntegrationTest {
@Autowired
private lateinit var stringRedisTemplate: StringRedisTemplate
@Autowired
private lateinit var objectMapper: ObjectMapper
@Autowired
private lateinit var presenceService: UserCreatorChatPresenceService
@Autowired
private lateinit var sessionRegistry: UserCreatorChatWebSocketSessionRegistry
@Autowired
private lateinit var roomMessageBroker: UserCreatorChatRoomMessageBroker
@AfterEach
fun tearDown() {
sessionRegistry.remove("redis-integration-session")
sessionRegistry.remove("redis-integration-other-session")
stringRedisTemplate.connectionFactory?.connection?.use { connection ->
connection.flushDb()
}
}
@Test
@DisplayName("embedded Redis에 join presence key와 index를 저장하고 TTL을 설정한다")
fun shouldStorePresenceKeysWithTtlOnJoin() {
presenceService.markJoined(roomId = 10L, memberId = 20L, sessionId = "session-1")
val presenceKey = UserCreatorChatPresenceService.presenceKey(10L, 20L, "session-1")
val memberSessionsKey = UserCreatorChatPresenceService.memberSessionsKey(10L, 20L)
val roomKey = UserCreatorChatPresenceService.roomKey(10L)
assertPresenceJson(stringRedisTemplate.opsForValue().get(presenceKey))
assertTrue(stringRedisTemplate.opsForSet().isMember(memberSessionsKey, "session-1") == true)
assertTrue(stringRedisTemplate.opsForSet().isMember(roomKey, "20") == true)
assertTrue(stringRedisTemplate.getExpire(presenceKey) > 0)
}
@Test
@DisplayName("embedded Redis에서 마지막 session leave 시 presence key와 index를 정리한다")
fun shouldRemovePresenceKeysWhenLastSessionLeaves() {
presenceService.markJoined(roomId = 10L, memberId = 20L, sessionId = "session-1")
presenceService.markLeft(roomId = 10L, memberId = 20L, sessionId = "session-1")
val presenceKey = UserCreatorChatPresenceService.presenceKey(10L, 20L, "session-1")
val memberSessionsKey = UserCreatorChatPresenceService.memberSessionsKey(10L, 20L)
val roomKey = UserCreatorChatPresenceService.roomKey(10L)
assertFalse(stringRedisTemplate.hasKey(presenceKey) == true)
assertFalse(stringRedisTemplate.hasKey(memberSessionsKey) == true)
assertFalse(stringRedisTemplate.opsForSet().isMember(roomKey, "20") == true)
}
@Test
@DisplayName("stale session id만 남으면 presence 없음으로 판단하고 stale id를 제거한다")
fun shouldPruneStaleSessionIdWhenCheckingPresence() {
val memberSessionsKey = UserCreatorChatPresenceService.memberSessionsKey(10L, 20L)
stringRedisTemplate.opsForSet().add(memberSessionsKey, "stale-session")
val hasPresence = presenceService.hasPresence(roomId = 10L, memberId = 20L)
assertFalse(hasPresence)
assertFalse(stringRedisTemplate.opsForSet().isMember(memberSessionsKey, "stale-session") == true)
}
@Test
@DisplayName("publish는 embedded Redis pub/sub listener를 거쳐 대상 local session에 payload를 전달한다")
fun shouldPublishThroughRedisAndDeliverToLocalTargetSession() {
val latch = CountDownLatch(1)
val targetSession = session("redis-integration-session", latch)
val otherSession = session("redis-integration-other-session", CountDownLatch(1))
sessionRegistry.register(roomId = 10L, memberId = 20L, session = targetSession)
sessionRegistry.register(roomId = 10L, memberId = 21L, session = otherSession)
roomMessageBroker.publish(roomId = 10L, memberId = 20L, payload = "{\"type\":\"MESSAGE\"}")
assertTrue(latch.await(3, TimeUnit.SECONDS), "Expected Redis pub/sub payload to reach target session")
val textCaptor = ArgumentCaptor.forClass(TextMessage::class.java)
Mockito.verify(targetSession).sendMessage(textCaptor.capture())
assertEquals("{\"type\":\"MESSAGE\"}", textCaptor.value.payload)
Mockito.verify(otherSession, Mockito.never()).sendMessage(Mockito.any(TextMessage::class.java))
}
private fun session(id: String, latch: CountDownLatch): WebSocketSession {
val session = Mockito.mock(WebSocketSession::class.java)
Mockito.`when`(session.id).thenReturn(id)
Mockito.`when`(session.isOpen).thenReturn(true)
Mockito.doAnswer {
latch.countDown()
null
}.`when`(session).sendMessage(Mockito.any(TextMessage::class.java))
return session
}
private fun assertPresenceJson(json: String?) {
assertNotNull(json)
val presence = objectMapper.readTree(json)
assertEquals("redis-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()))
}
}