diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRedisIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRedisIntegrationTest.kt new file mode 100644 index 00000000..c42d65b8 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRedisIntegrationTest.kt @@ -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())) + } +}