@@ -41,14 +41,11 @@ class SSENotificationV2ServiceImplTest {
4141
4242 private SSENotificationV2ServiceImpl service ;
4343
44- // LocalDateTime 직렬화를 위해 JavaTimeModule 등록
4544 private final ObjectMapper objectMapper = new ObjectMapper ()
4645 .registerModule (new JavaTimeModule ());
4746
4847 @ BeforeEach
4948 void setUp () {
50- // SSENotificationV2ServiceImpl 생성자 직접 호출
51- // @PostConstruct(init)은 private이라 수동 호출 불가 → addMessageListener는 mock이라 아무것도 안 함
5249 service = new SSENotificationV2ServiceImpl (
5350 onlineStatusService , redisTemplate , redisMessageListenerContainer , objectMapper , 2 );
5451 }
@@ -65,22 +62,35 @@ class CreateConnection {
6562 void success_returnsEmitterAndSetsOnline () {
6663 UUID userId = UUID .randomUUID ();
6764
68- SseEmitter emitter = service .createConnection (userId );
65+ SseEmitter emitter = service .createConnection (userId , newConnectionId () );
6966
7067 assertThat (emitter ).isNotNull ();
7168 then (onlineStatusService ).should ().setUserOnline (userId );
7269 }
7370
7471 @ Test
75- @ DisplayName ("같은 userId로 재연결하면 기존 연결과 다른 emitter를 반환한다 " )
76- void reconnect_returnsDifferentEmitter () {
72+ @ DisplayName ("같은 userId로 두 번째 탭을 열어도 첫 번째 emitter가 살아있다 " )
73+ void multiTab_bothEmittersAlive () {
7774 UUID userId = UUID .randomUUID ();
7875
79- SseEmitter first = service .createConnection (userId );
80- SseEmitter second = service .createConnection (userId );
76+ SseEmitter emitterA = service .createConnection (userId , newConnectionId () );
77+ SseEmitter emitterB = service .createConnection (userId , newConnectionId () );
8178
82- assertThat (first ).isNotSameAs (second );
83- then (onlineStatusService ).should (times (2 )).setUserOnline (userId );
79+ assertThat (emitterA ).isNotNull ();
80+ assertThat (emitterB ).isNotNull ();
81+ assertThat (emitterA ).isNotSameAs (emitterB );
82+ }
83+
84+ @ Test
85+ @ DisplayName ("같은 userId로 두 탭이 연결돼도 setUserOnline은 최초 1회만 호출된다" )
86+ void multiTab_setOnlineCalledOnce () {
87+ UUID userId = UUID .randomUUID ();
88+
89+ service .createConnection (userId , newConnectionId ());
90+ service .createConnection (userId , newConnectionId ());
91+
92+ // 첫 번째 연결 시에만 온라인 전환
93+ then (onlineStatusService ).should (times (1 )).setUserOnline (userId );
8494 }
8595
8696 @ Test
@@ -89,8 +99,8 @@ void differentUsers_independentEmitters() {
8999 UUID userA = UUID .randomUUID ();
90100 UUID userB = UUID .randomUUID ();
91101
92- SseEmitter emitterA = service .createConnection (userA );
93- SseEmitter emitterB = service .createConnection (userB );
102+ SseEmitter emitterA = service .createConnection (userA , newConnectionId () );
103+ SseEmitter emitterB = service .createConnection (userB , newConnectionId () );
94104
95105 assertThat (emitterA ).isNotSameAs (emitterB );
96106 then (onlineStatusService ).should ().setUserOnline (userA );
@@ -109,7 +119,7 @@ class DisconnectUser {
109119 @ DisplayName ("연결된 사용자를 끊으면 오프라인 상태로 설정한다" )
110120 void connected_setsOffline () {
111121 UUID userId = UUID .randomUUID ();
112- service .createConnection (userId );
122+ service .createConnection (userId , newConnectionId () );
113123
114124 service .disconnectUser (userId );
115125
@@ -126,19 +136,77 @@ void notConnected_setsOfflineSafely() {
126136 then (onlineStatusService ).should ().setUserOffline (userId );
127137 }
128138
139+ @ Test
140+ @ DisplayName ("멀티탭 상태에서 disconnectUser 호출 시 모든 탭이 끊기고 오프라인 전환된다" )
141+ void multiTab_disconnectUser_allTabsClosed () {
142+ UUID userId = UUID .randomUUID ();
143+ service .createConnection (userId , newConnectionId ());
144+ service .createConnection (userId , newConnectionId ());
145+
146+ service .disconnectUser (userId );
147+
148+ then (onlineStatusService ).should ().setUserOffline (userId );
149+ }
150+
129151 @ Test
130152 @ DisplayName ("disconnect 후 재연결하면 새로운 emitter가 반환된다" )
131153 void disconnectThenReconnect_returnsNewEmitter () {
132154 UUID userId = UUID .randomUUID ();
133- SseEmitter first = service .createConnection (userId );
155+ SseEmitter first = service .createConnection (userId , newConnectionId () );
134156 service .disconnectUser (userId );
135157
136- SseEmitter second = service .createConnection (userId );
158+ SseEmitter second = service .createConnection (userId , newConnectionId () );
137159
138160 assertThat (first ).isNotSameAs (second );
139161 }
140162 }
141163
164+ // ──────────────────────────────────────────────
165+ // disconnectConnection
166+ // ──────────────────────────────────────────────
167+ @ Nested
168+ @ DisplayName ("disconnectConnection" )
169+ class DisconnectConnection {
170+
171+ @ Test
172+ @ DisplayName ("탭 하나만 끊으면 나머지 탭은 유지되고 온라인 상태가 유지된다" )
173+ void oneTab_remainsOnline () {
174+ UUID userId = UUID .randomUUID ();
175+ String connIdA = newConnectionId ();
176+ String connIdB = newConnectionId ();
177+ service .createConnection (userId , connIdA );
178+ service .createConnection (userId , connIdB );
179+
180+ service .disconnectConnection (userId , connIdA );
181+
182+ // 아직 탭B가 남아있으므로 오프라인 전환 없음
183+ then (onlineStatusService ).should (never ()).setUserOffline (userId );
184+ }
185+
186+ @ Test
187+ @ DisplayName ("마지막 탭을 disconnectConnection으로 끊으면 오프라인으로 전환된다" )
188+ void lastTab_setsOffline () {
189+ UUID userId = UUID .randomUUID ();
190+ String connId = newConnectionId ();
191+ service .createConnection (userId , connId );
192+
193+ service .disconnectConnection (userId , connId );
194+
195+ then (onlineStatusService ).should ().setUserOffline (userId );
196+ }
197+
198+ @ Test
199+ @ DisplayName ("존재하지 않는 connectionId로 끊어도 예외가 발생하지 않는다" )
200+ void unknownConnectionId_noException () {
201+ UUID userId = UUID .randomUUID ();
202+ service .createConnection (userId , newConnectionId ());
203+
204+ service .disconnectConnection (userId , "non-existent-id" );
205+
206+ // 예외 없이 통과하면 성공
207+ }
208+ }
209+
142210 // ──────────────────────────────────────────────
143211 // sendNotification
144212 // ──────────────────────────────────────────────
@@ -197,18 +265,26 @@ class OnMessage {
197265 @ DisplayName ("연결된 사용자의 Redis 메시지는 예외 없이 처리된다" )
198266 void connectedUser_processedWithoutException () throws Exception {
199267 UUID userId = UUID .randomUUID ();
200- service .createConnection (userId );
268+ service .createConnection (userId , newConnectionId () );
201269
202270 service .onMessage (buildRedisMessage (userId , NotificationType .SCHEDULE ), null );
271+ }
203272
204- // emitter.send() 실패해도 바깥으로 예외 전파 없음 — 이 자체가 검증
273+ @ Test
274+ @ DisplayName ("멀티탭 사용자에게 메시지가 오면 모든 탭에 전송 시도한다" )
275+ void multiTab_messageDeliveredToAllTabs () throws Exception {
276+ UUID userId = UUID .randomUUID ();
277+ service .createConnection (userId , newConnectionId ());
278+ service .createConnection (userId , newConnectionId ());
279+
280+ // 예외 없이 두 탭 모두에 전송 시도되면 성공
281+ service .onMessage (buildRedisMessage (userId , NotificationType .SCHEDULE ), null );
205282 }
206283
207284 @ Test
208285 @ DisplayName ("연결되지 않은 사용자의 메시지는 무시하고 오프라인 처리도 하지 않는다" )
209286 void notConnectedUser_ignored () throws Exception {
210287 UUID userId = UUID .randomUUID ();
211- // createConnection 미호출 → userLocks에 없음
212288
213289 service .onMessage (buildRedisMessage (userId , NotificationType .PAYMENT ), null );
214290
@@ -222,8 +298,6 @@ void malformedJson_swallowsException() {
222298 given (redisMessage .getBody ()).willReturn ("invalid-json" .getBytes ());
223299
224300 service .onMessage (redisMessage , null );
225-
226- // 예외 없이 통과하면 성공
227301 }
228302 }
229303
@@ -245,12 +319,10 @@ void noConnections_noop() {
245319 @ Test
246320 @ DisplayName ("연결이 있어도 예외 없이 완료된다" )
247321 void withConnections_completesWithoutException () {
248- service .createConnection (UUID .randomUUID ());
249- service .createConnection (UUID .randomUUID ());
322+ service .createConnection (UUID .randomUUID (), newConnectionId () );
323+ service .createConnection (UUID .randomUUID (), newConnectionId () );
250324
251325 service .cleanupInactiveConnections ();
252-
253- // ping 전송은 테스트 환경에서 실패할 수 있으나 예외 전파 없어야 함
254326 }
255327 }
256328
@@ -268,20 +340,24 @@ void noConnections_shutdownCleanly() {
268340 }
269341
270342 @ Test
271- @ DisplayName ("연결이 있는 상태에서 shutdown하면 모든 연결을 정리한다" )
272- void withConnections_cleansAllUp () {
273- service .createConnection (UUID .randomUUID ());
274- service .createConnection (UUID .randomUUID ());
343+ @ DisplayName ("멀티탭 연결이 있는 상태에서 shutdown하면 모든 연결을 정리한다" )
344+ void withMultiTabConnections_cleansAllUp () {
345+ UUID userId = UUID .randomUUID ();
346+ service .createConnection (userId , newConnectionId ());
347+ service .createConnection (userId , newConnectionId ());
275348
276349 service .shutdown ();
277- // 종료 후 추가 연결 시도 없이 예외 없으면 성공
278350 }
279351 }
280352
281353 // ──────────────────────────────────────────────
282354 // helpers
283355 // ──────────────────────────────────────────────
284356
357+ private String newConnectionId () {
358+ return UUID .randomUUID ().toString ();
359+ }
360+
285361 private Message buildRedisMessage (UUID userId , NotificationType type ) throws Exception {
286362 NotificationMessage payload = new NotificationMessage (userId , "제목" , "내용" , type , "relatedId" );
287363 byte [] body = objectMapper .writeValueAsBytes (payload );
0 commit comments