test #426

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

View File

@@ -0,0 +1,55 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import org.springframework.stereotype.Component
import org.springframework.web.socket.WebSocketSession
import java.util.concurrent.ConcurrentHashMap
@Component
class UserCreatorChatWebSocketSessionRegistry {
private val sessionsByRoomMember = ConcurrentHashMap<RoomMemberKey, ConcurrentHashMap<String, WebSocketSession>>()
private val sessionIndexes = ConcurrentHashMap<String, RoomMemberKey>()
private val lockStripes = Array(LOCK_STRIPE_COUNT) { Any() }
fun register(roomId: Long, memberId: Long, session: WebSocketSession) {
val sessionId = session.id
synchronized(lockFor(sessionId)) {
removeLocked(sessionId)
val key = RoomMemberKey(roomId, memberId)
sessionsByRoomMember.computeIfAbsent(key) { ConcurrentHashMap() }[sessionId] = session
sessionIndexes[sessionId] = key
}
}
fun findSessions(roomId: Long, memberId: Long): List<WebSocketSession> {
return sessionsByRoomMember[RoomMemberKey(roomId, memberId)]?.values?.toList() ?: emptyList()
}
fun remove(sessionId: String) {
synchronized(lockFor(sessionId)) {
removeLocked(sessionId)
}
}
private fun removeLocked(sessionId: String) {
val key = sessionIndexes.remove(sessionId) ?: return
val sessions = sessionsByRoomMember[key] ?: return
sessions.remove(sessionId)
if (sessions.isEmpty()) {
sessionsByRoomMember.remove(key, sessions)
}
}
private fun lockFor(sessionId: String): Any {
return lockStripes[Math.floorMod(sessionId.hashCode(), lockStripes.size)]
}
private data class RoomMemberKey(
val roomId: Long,
val memberId: Long
)
companion object {
private const val LOCK_STRIPE_COUNT = 64
}
}

View File

@@ -0,0 +1,129 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.web.socket.WebSocketSession
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
class UserCreatorChatWebSocketSessionRegistryTest {
private val registry = UserCreatorChatWebSocketSessionRegistry()
@Test
@DisplayName("roomId/memberId/sessionId 기준으로 local WebSocket session을 등록하고 조회한다")
fun shouldRegisterAndFindSessionsByRoomAndMember() {
val session = session("session-1")
registry.register(roomId = 10L, memberId = 20L, session = session)
val sessions = registry.findSessions(roomId = 10L, memberId = 20L)
assertEquals(1, sessions.size, "Expected one registered local WebSocket session")
assertSame(session, sessions.single())
}
@Test
@DisplayName("sessionId로 등록된 local WebSocket session을 제거한다")
fun shouldRemoveSessionBySessionId() {
val session = session("session-1")
registry.register(roomId = 10L, memberId = 20L, session = session)
registry.remove("session-1")
assertFalse(
registry.findSessions(roomId = 10L, memberId = 20L).isNotEmpty(),
"Expected removed WebSocket session not to be returned"
)
}
@Test
@DisplayName("같은 session이 다른 room으로 전환되면 기존 room 등록을 제거한다")
fun shouldRemovePreviousRoomWhenSameSessionSwitchesRoom() {
val session = session("session-1")
registry.register(roomId = 10L, memberId = 20L, session = session)
registry.register(roomId = 11L, memberId = 20L, session = session)
assertFalse(
registry.findSessions(roomId = 10L, memberId = 20L).isNotEmpty(),
"Expected previous room mapping to be removed when same session switches rooms"
)
assertEquals(listOf(session), registry.findSessions(roomId = 11L, memberId = 20L))
}
@Test
@DisplayName("room/member에 등록된 여러 local session을 모두 조회한다")
fun shouldFindMultipleSessionsForSameRoomMember() {
val first = session("session-1")
val second = session("session-2")
registry.register(roomId = 10L, memberId = 20L, session = first)
registry.register(roomId = 10L, memberId = 20L, session = second)
val sessions = registry.findSessions(roomId = 10L, memberId = 20L)
assertEquals(setOf(first, second), sessions.toSet())
}
@Test
@DisplayName("같은 session의 동시 room 전환에서도 stale room 등록을 남기지 않는다")
fun shouldNotLeaveStaleRoomMappingWhenSameSessionSwitchesRoomConcurrently() {
val session = sessionWithSynchronizedFirstTwoIdReads("session-1")
val executor = Executors.newFixedThreadPool(2)
try {
val first = executor.submit { registry.register(roomId = 10L, memberId = 20L, session = session) }
val second = executor.submit { registry.register(roomId = 11L, memberId = 20L, session = session) }
first.get(3, TimeUnit.SECONDS)
second.get(3, TimeUnit.SECONDS)
} finally {
executor.shutdownNow()
}
val registeredRooms = listOf(10L, 11L).filter { roomId ->
registry.findSessions(roomId = roomId, memberId = 20L).isNotEmpty()
}
assertEquals(
1,
registeredRooms.size,
"Expected concurrent same-session room switch to leave exactly one active room mapping"
)
}
@Test
@DisplayName("sessionId별 lock map을 유지하지 않는다")
fun shouldNotKeepPerSessionLockMap() {
val hasSessionLockMap = UserCreatorChatWebSocketSessionRegistry::class.java.declaredFields
.any { field -> field.name == "sessionLocks" }
assertFalse(
hasSessionLockMap,
"Expected registry not to keep a per-session lock map that can grow with WebSocket traffic"
)
}
private fun sessionWithSynchronizedFirstTwoIdReads(id: String): WebSocketSession {
val session = Mockito.mock(WebSocketSession::class.java)
val readCount = AtomicInteger()
val firstTwoReads = CountDownLatch(2)
Mockito.`when`(session.id).thenAnswer {
if (readCount.incrementAndGet() <= 2) {
firstTwoReads.countDown()
firstTwoReads.await(1, TimeUnit.SECONDS)
}
id
}
return session
}
private fun session(id: String): WebSocketSession {
val session = Mockito.mock(WebSocketSession::class.java)
Mockito.`when`(session.id).thenReturn(id)
return session
}
}