participants = roomOpt.get().getParticipantIds()
- .stream()
- .map(userRepository::findById)
- .filter(Optional::isPresent)
- .map(Optional::get)
- .map(UserResponse::from)
- .toList();
-
- JoinRoomSuccessResponse response = JoinRoomSuccessResponse.builder()
- .roomId(roomId)
- .participants(participants)
- .messages(messageLoadResult.getMessages())
- .hasMore(messageLoadResult.isHasMore())
- .activeStreams(Collections.emptyList())
- .build();
-
- client.sendEvent(JOIN_ROOM_SUCCESS, response);
-
- // ์
์ฅ ๋ฉ์์ง ๋ธ๋ก๋์บ์คํธ
- socketIOServer.getRoomOperations(roomId)
- .sendEvent(MESSAGE, messageResponseMapper.mapToMessageResponse(joinMessage, null));
-
- // ์ฐธ๊ฐ์ ๋ชฉ๋ก ์
๋ฐ์ดํธ ๋ธ๋ก๋์บ์คํธ
- socketIOServer.getRoomOperations(roomId)
- .sendEvent(PARTICIPANTS_UPDATE, participants);
-
- log.info("User {} joined room {} successfully. Message count: {}, hasMore: {}",
- userName, roomId, messageLoadResult.getMessages().size(), messageLoadResult.isHasMore());
-
+ log.debug("User joined Socket.IO room successfully. userId={}, roomId={}", userId, roomId);
} catch (Exception e) {
log.error("Error handling joinRoom", e);
client.sendEvent(JOIN_ROOM_ERROR, Map.of(
- "message", e.getMessage() != null ? e.getMessage() : "์ฑํ
๋ฐฉ ์
์ฅ์ ์คํจํ์ต๋๋ค."
+ "message", e.getMessage() != null ? e.getMessage() : "Failed to join room."
));
+ } finally {
+ if (joinKey != null) {
+ joinsInProgress.remove(joinKey);
+ }
}
}
-
- private SocketUser getUser(SocketIOClient client) {
- return client.get("user");
+
+ private void sendJoinSuccess(SocketIOClient client, String roomId) {
+ client.sendEvent(JOIN_ROOM_SUCCESS, JoinRoomSuccessResponse.builder()
+ .roomId(roomId)
+ .activeStreams(Collections.emptyList())
+ .build());
}
private String getUserId(SocketIOClient client) {
- SocketUser user = getUser(client);
+ SocketUser user = client.get("user");
return user != null ? user.id() : null;
}
-
- private String getUserName(SocketIOClient client) {
- SocketUser user = getUser(client);
- return user != null ? user.name() : null;
- }
}
diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandler.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandler.java
index 0d9bab6c..360afc7f 100644
--- a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandler.java
+++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomLeaveHandler.java
@@ -54,11 +54,6 @@ public void handleLeaveRoom(SocketIOClient client, String roomId) {
return;
}
- if (!userRooms.isInRoom(userId, roomId)) {
- log.debug("User {} is not in room {}", userId, roomId);
- return;
- }
-
User user = userRepository.findById(userId).orElse(null);
Room room = roomRepository.findById(roomId).orElse(null);
@@ -67,7 +62,10 @@ public void handleLeaveRoom(SocketIOClient client, String roomId) {
return;
}
- roomRepository.removeParticipant(roomId, userId);
+ if (roomRepository.removeParticipant(roomId, userId) == 0) {
+ log.debug("User {} is not in room {}", userId, roomId);
+ return;
+ }
client.leaveRoom(roomId);
userRooms.remove(userId, roomId);
diff --git a/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomListSubscriptionHandler.java b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomListSubscriptionHandler.java
new file mode 100644
index 00000000..5fcd40aa
--- /dev/null
+++ b/apps/backend/src/main/java/com/ktb/chatapp/websocket/socketio/handler/RoomListSubscriptionHandler.java
@@ -0,0 +1,32 @@
+package com.ktb.chatapp.websocket.socketio.handler;
+
+import com.corundumstudio.socketio.SocketIOClient;
+import com.corundumstudio.socketio.annotation.OnEvent;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+import static com.ktb.chatapp.websocket.socketio.SocketIOEvents.JOIN_ROOM_LIST;
+import static com.ktb.chatapp.websocket.socketio.SocketIOEvents.LEAVE_ROOM_LIST;
+
+@Slf4j
+@Component
+@ConditionalOnProperty(name = "socketio.enabled", havingValue = "true", matchIfMissing = true)
+public class RoomListSubscriptionHandler {
+
+ private static final String ROOM_LIST = "room-list";
+
+ @OnEvent(JOIN_ROOM_LIST)
+ public void joinRoomList(SocketIOClient client) {
+ if (client.get("user") == null) {
+ log.debug("Ignoring unauthenticated room-list subscription: socketId={}", client.getSessionId());
+ return;
+ }
+ client.joinRoom(ROOM_LIST);
+ }
+
+ @OnEvent(LEAVE_ROOM_LIST)
+ public void leaveRoomList(SocketIOClient client) {
+ client.leaveRoom(ROOM_LIST);
+ }
+}
diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties
index da6d0e5f..ff6ccfd9 100644
--- a/apps/backend/src/main/resources/application.properties
+++ b/apps/backend/src/main/resources/application.properties
@@ -1,16 +1,21 @@
# Server Configuration
server.port=${PORT:5001}
-server.tomcat.threads.max=10
-server.tomcat.threads.min-spare=1
-server.tomcat.accept-count=10
-server.tomcat.max-connections=50
+# Tomcat Configuration
+server.tomcat.threads.max=${TOMCAT_THREADS_MAX:100}
+server.tomcat.threads.min-spare=${TOMCAT_THREADS_MIN_SPARE:10}
+server.tomcat.accept-count=${TOMCAT_ACCEPT_COUNT:500}
+server.tomcat.max-connections=${TOMCAT_MAX_CONNECTIONS:1000}
# Database Configuration
# Spring Boot 4: connection settings moved from spring.data.mongodb.* to spring.mongodb.*
spring.mongodb.uri=${MONGO_URI}
spring.data.mongodb.auto-index-creation=true
+app.mongodb.pool.max-size=${MONGO_MAX_POOL_SIZE:100}
+app.mongodb.pool.min-size=${MONGO_MIN_POOL_SIZE:0}
+app.mongodb.pool.max-wait-time-ms=${MONGO_MAX_WAIT_TIME_MS:120000}
+
# Redis Configuration
spring.data.redis.host=${REDIS_HOST:localhost}
spring.data.redis.port=${REDIS_PORT:6379}
@@ -19,7 +24,14 @@ spring.data.redis.password=${REDIS_PASSWORD:}
# File Upload Configuration
# Storage type: local or gridfs (default: local)
file.storage.type=${FILE_STORAGE_TYPE:local}
+file.storage.require-offload=${FILE_STORAGE_REQUIRE_OFFLOAD:false}
file.upload-dir=./uploads
+app.s3.bucket=${S3_BUCKET:}
+app.s3.region=${AWS_REGION:ap-northeast-2}
+app.s3.endpoint=${S3_ENDPOINT:}
+app.s3.path-style-access=${S3_PATH_STYLE:false}
+app.s3.key-prefix=${S3_KEY_PREFIX:}
+app.s3.presign-ttl=${S3_PRESIGN_TTL:10m}
# Multipart upload limits
spring.servlet.multipart.max-file-size=5MB
@@ -41,6 +53,14 @@ spring.ai.openai.chat.options.temperature=${OPENAI_TEMPERATURE:0.7}
socketio.server.host=0.0.0.0
socketio.server.port=${WS_PORT:5002}
socketio.server.origin=${SOCKETIO_SERVER_ORIGIN:*}
+socketio.server.accept-backlog=${SOCKETIO_ACCEPT_BACKLOG:1024}
+socketio.server.tcp-no-delay=${SOCKETIO_TCP_NO_DELAY:true}
+socketio.presence-refresh-interval=${SOCKETIO_PRESENCE_REFRESH_INTERVAL:5m}
+socketio.store.type=${SOCKETIO_STORE_TYPE:redis}
+rate-limit.store.type=${RATE_LIMIT_STORE_TYPE:redis}
+session.store.type=${SESSION_STORE_TYPE:redis}
+room-activity.flush-delay=${ROOM_ACTIVITY_FLUSH_DELAY:1s}
+room-activity.debounce-window=${ROOM_ACTIVITY_DEBOUNCE_WINDOW:1s}
# CORS Configuration
app.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:*}
@@ -48,6 +68,8 @@ app.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:*}
# Logging Configuration
logging.level.org.springframework=INFO
logging.level.com.ktb.chatapp=INFO
+logging.level.com.ktb.chatapp.websocket.socketio=${SOCKETIO_LOG_LEVEL:WARN}
+logging.level.com.corundumstudio.socketio=${SOCKETIO_LIBRARY_LOG_LEVEL:WARN}
logging.level.org.mongodb=INFO
# Content Moderation
@@ -74,3 +96,10 @@ management.endpoint.prometheus.access=read_only
management.prometheus.metrics.export.enabled=true
management.metrics.tags.application=${spring.application.name:ktb-chat-backend}
management.metrics.tags.host=${HOSTNAME:unknown}
+
+# CloudFront signed URL
+app.cloudfront.enabled=${CLOUDFRONT_ENABLED:false}
+app.cloudfront.base-url=${CLOUDFRONT_BASE_URL:}
+app.cloudfront.key-pair-id=${CLOUDFRONT_KEY_PAIR_ID:}
+app.cloudfront.private-key-path=${CLOUDFRONT_PRIVATE_KEY_PATH:}
+app.cloudfront.url-ttl=${CLOUDFRONT_URL_TTL:5m}
diff --git a/apps/backend/src/main/resources/static/api/docs/socketio/asyncapi.yaml b/apps/backend/src/main/resources/static/api/docs/socketio/asyncapi.yaml
index ae1ecaae..1957ecbe 100644
--- a/apps/backend/src/main/resources/static/api/docs/socketio/asyncapi.yaml
+++ b/apps/backend/src/main/resources/static/api/docs/socketio/asyncapi.yaml
@@ -155,14 +155,14 @@ channels:
address: roomCreated
messages:
roomCreatedResponse:
- $ref: '#/components/messages/RoomResponse'
+ $ref: '#/components/messages/RoomListItemResponse'
description: ์ฑํ
๋ฐฉ ์์ฑ ์๋ฆผ
roomUpdated:
address: roomUpdated
messages:
roomUpdatedResponse:
- $ref: '#/components/messages/RoomResponse'
+ $ref: '#/components/messages/RoomListItemResponse'
description: ์ฑํ
๋ฐฉ ์ ๋ณด ์
๋ฐ์ดํธ
roomActivity:
@@ -551,13 +551,13 @@ components:
items:
$ref: '#/components/schemas/UserResponse'
- RoomResponse:
- name: RoomResponse
- title: ์ฑํ
๋ฐฉ ์๋ต
- summary: ์ฑํ
๋ฐฉ ์ ๋ณด ํ์ด๋ก๋
+ RoomListItemResponse:
+ name: RoomListItemResponse
+ title: ์ฑํ
๋ฐฉ ๋ชฉ๋ก ํญ๋ชฉ ์๋ต
+ summary: ์ฑํ
๋ฐฉ ๋ชฉ๋ก์ฉ ๊ฒฝ๋ ํ์ด๋ก๋
contentType: application/json
payload:
- $ref: '#/components/schemas/RoomResponse'
+ $ref: '#/components/schemas/RoomListItemResponse'
RoomActivityResponse:
name: RoomActivity
@@ -1047,7 +1047,7 @@ components:
- messages
- hasMore
- RoomResponse:
+ RoomListItemResponse:
type: object
properties:
_id:
@@ -1059,27 +1059,23 @@ components:
hasPassword:
type: boolean
description: ๋น๋ฐ๋ฒํธ ์ค์ ์ฌ๋ถ
- creator:
- $ref: '#/components/schemas/UserResponse'
- participants:
- type: array
- items:
- $ref: '#/components/schemas/UserResponse'
- description: ์ฐธ๊ฐ์ ๋ชฉ๋ก
- participantsCount:
+ participantCount:
type: integer
description: ์ฐธ๊ฐ์ ์
+ recentMessageCount:
+ type: integer
+ description: ์ต๊ทผ 30๋ถ๊ฐ ๋ฉ์์ง ์
createdAt:
type: string
format: date-time
description: ์์ฑ ๋ ์ง
- isCreator:
- type: boolean
- description: ํ์ฌ ์ฌ์ฉ์๊ฐ ์์ฑ์์ธ์ง ์ฌ๋ถ
required:
- _id
- name
- hasPassword
+ - participantCount
+ - recentMessageCount
+ - createdAt
ErrorPayload:
type: object
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/config/SecurityConfigTest.java b/apps/backend/src/test/java/com/ktb/chatapp/config/SecurityConfigTest.java
index 995eb78d..18eb7cde 100644
--- a/apps/backend/src/test/java/com/ktb/chatapp/config/SecurityConfigTest.java
+++ b/apps/backend/src/test/java/com/ktb/chatapp/config/SecurityConfigTest.java
@@ -9,10 +9,13 @@
import org.springframework.context.annotation.Import;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.cors.CorsConfigurationSource;
+import static org.junit.jupiter.api.Assertions.assertSame;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -23,6 +26,9 @@ class SecurityConfigTest {
@Autowired
private MockMvc mockMvc;
+ @Autowired
+ private CorsConfigurationSource corsConfigurationSource;
+
@MockitoBean
private JwtDecoder jwtDecoder;
@@ -52,6 +58,16 @@ void otherApiEndpointsStillRequireAuthentication() throws Exception {
.andExpect(status().isUnauthorized());
}
+ @Test
+ void corsConfigurationIsReusedAcrossRequests() {
+ MockHttpServletRequest firstRequest = new MockHttpServletRequest("GET", "/api/probe");
+ MockHttpServletRequest secondRequest = new MockHttpServletRequest("GET", "/api/probe");
+
+ assertSame(
+ corsConfigurationSource.getCorsConfiguration(firstRequest),
+ corsConfigurationSource.getCorsConfiguration(secondRequest));
+ }
+
@RestController
static class ProbeController {
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/config/SocketIOConfigTest.java b/apps/backend/src/test/java/com/ktb/chatapp/config/SocketIOConfigTest.java
index 0270240a..0a4b7c62 100644
--- a/apps/backend/src/test/java/com/ktb/chatapp/config/SocketIOConfigTest.java
+++ b/apps/backend/src/test/java/com/ktb/chatapp/config/SocketIOConfigTest.java
@@ -9,6 +9,7 @@
import org.springframework.test.context.TestPropertySource;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@TestPropertySource(properties = "socketio.enabled=false")
@@ -21,4 +22,19 @@ void shouldNotLoadSocketIOBeansWhenDisabled(ApplicationContext context) {
assertThrows(NoSuchBeanDefinitionException.class,
() -> context.getBean(SocketIOServer.class));
}
+
+ @Test
+ void createsSpikeReadySocketTransportSettings() {
+ var socketConfig = SocketIOConfig.createSocketConfig(1024, true);
+
+ assertThat(socketConfig.getAcceptBackLog()).isEqualTo(1024);
+ assertThat(socketConfig.isTcpNoDelay()).isTrue();
+ assertThat(socketConfig.isReuseAddress()).isTrue();
+ }
+
+ @Test
+ void rejectsInvalidAcceptBacklog() {
+ assertThrows(IllegalArgumentException.class,
+ () -> SocketIOConfig.createSocketConfig(0, true));
+ }
}
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/config/UploadsResourceAccessTest.java b/apps/backend/src/test/java/com/ktb/chatapp/config/UploadsResourceAccessTest.java
index 4390d557..36d29146 100644
--- a/apps/backend/src/test/java/com/ktb/chatapp/config/UploadsResourceAccessTest.java
+++ b/apps/backend/src/test/java/com/ktb/chatapp/config/UploadsResourceAccessTest.java
@@ -18,6 +18,7 @@
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
@@ -54,6 +55,7 @@ class UploadsResourceAccessTest {
void profileImageIsServedToAnonymousRequests() throws Exception {
mockMvc.perform(get("/api/files/profiles/avatar.png"))
.andExpect(status().isOk())
+ .andExpect(header().string("Cache-Control", "max-age=31536000, public, immutable"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("fake-png-bytes")));
}
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/controller/AuthControllerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/controller/AuthControllerTest.java
index 49d4e4a3..633cadce 100644
--- a/apps/backend/src/test/java/com/ktb/chatapp/controller/AuthControllerTest.java
+++ b/apps/backend/src/test/java/com/ktb/chatapp/controller/AuthControllerTest.java
@@ -4,6 +4,7 @@
import com.ktb.chatapp.config.MongoTestContainer;
import com.ktb.chatapp.dto.LoginRequest;
import com.ktb.chatapp.dto.RegisterRequest;
+import com.ktb.chatapp.repository.UserRepository;
import com.ktb.chatapp.service.SessionCreationResult;
import com.ktb.chatapp.service.SessionMetadata;
import com.ktb.chatapp.service.SessionService;
@@ -16,9 +17,13 @@
import org.springframework.security.test.context.support.WithAnonymousUser;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -40,6 +45,9 @@ public class AuthControllerTest {
@MockitoBean
private SessionService sessionService;
+ @MockitoSpyBean
+ private UserRepository userRepository;
+
@Test
@WithAnonymousUser
public void testRegisterUser() throws Exception {
@@ -106,7 +114,8 @@ public void testAuthenticateUser() throws Exception {
.content(objectMapper.writeValueAsString(registerRequest)))
.andExpect(status().isCreated());
- LoginRequest loginRequest = new LoginRequest(email, "password");
+ clearInvocations(userRepository);
+ LoginRequest loginRequest = new LoginRequest(email.toUpperCase(), "password");
mockMvc.perform(post("/api/auth/login")
.with(csrf())
@@ -114,5 +123,7 @@ public void testAuthenticateUser() throws Exception {
.content(objectMapper.writeValueAsString(loginRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").exists());
+
+ verify(userRepository, times(1)).findByEmail(email);
}
}
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/controller/FileControllerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/controller/FileControllerTest.java
index 971ee717..f8eed2c8 100644
--- a/apps/backend/src/test/java/com/ktb/chatapp/controller/FileControllerTest.java
+++ b/apps/backend/src/test/java/com/ktb/chatapp/controller/FileControllerTest.java
@@ -1,19 +1,26 @@
package com.ktb.chatapp.controller;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.ktb.chatapp.model.User;
+import com.ktb.chatapp.exception.DirectUploadNotSupportedException;
+import com.ktb.chatapp.exception.FileAccessException;
import com.ktb.chatapp.repository.UserRepository;
import com.ktb.chatapp.security.SessionAwareJwtAuthenticationConverter;
import com.ktb.chatapp.service.FileAccess;
import com.ktb.chatapp.service.FileAccessService;
+import com.ktb.chatapp.service.ChatFileDirectUploadService;
import com.ktb.chatapp.service.FileService;
import com.ktb.chatapp.service.PreviewNotSupportedException;
import com.ktb.chatapp.service.RateLimitService;
@@ -21,6 +28,8 @@
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.util.Optional;
+import java.util.List;
+import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -29,6 +38,7 @@
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@@ -36,9 +46,7 @@
* {@code /api/files} ์ฝ๊ธฐ ๊ฒฝ๋ก์ HTTP ํ๋ฉด์ ๊ณ ์ ํ๋ค. {@link FileAccessService}๊ฐ ์กฐ๋ฆฝํ
* {@link FileAccess}๋ฅผ ์ปจํธ๋กค๋ฌ๊ฐ ์ด๋ค ์ํ์ฝ๋ยทํค๋๋ก ๋ฒ์ญํ๋์ง๊ฐ ๊ฒ์ฆ ๋์์ด๋ค.
*
- * ๋ ๊ณ์ฝ์ด ํนํ ์กฐ์ฉํ ๊นจ์ง๋ค. โ {@code handleFileError}๋ ์์ธ ๋ฉ์์ง ๋ฌธ์์ด๋ก 403/404๋ฅผ
- * ๋ถ๊ธฐํ๋ฏ๋ก ๋์ง๋ ๋ฉ์์ง๊ฐ ๋ฐ๋๋ฉด ์ํ์ฝ๋๊ฐ ๋ฐ๋๋ค. โก download๋ {@code attachment} + ์บ์ ๊ธ์ง,
- * view๋ {@code inline} + ์ฅ๊ธฐ ์บ์๋ก ํค๋๊ฐ ์๋ก ๋ฌ๋ผ์ผ ํ๋ค.
+ *
ํ์ผ ์ฝ๊ธฐ ์์ธ์ ํ์
๋ณ ์ํ ์ฝ๋์ download/view์ ์๋ก ๋ค๋ฅธ ์๋ต ํค๋๋ฅผ ๊ฒ์ฆํ๋ค.
*/
@WebMvcTest(controllers = FileController.class)
@AutoConfigureMockMvc(addFilters = false)
@@ -63,6 +71,9 @@ class FileControllerTest {
@MockitoBean
private FileAccessService fileAccessService;
+ @MockitoBean
+ private ChatFileDirectUploadService chatFileDirectUploadService;
+
@MockitoBean
private UserRepository userRepository;
@@ -110,7 +121,21 @@ void viewFile_stream_setsInlineHeader() throws Exception {
"Content-Disposition",
"inline; filename=\"" + ORIGINAL_NAME + "\"; filename*=UTF-8''"
+ ENCODED_ORIGINAL_NAME))
- .andExpect(header().doesNotExist("Cache-Control"));
+ .andExpect(header().string("Cache-Control", "private, max-age=300"));
+ }
+
+ @Test
+ @DisplayName("์ธ์ฆ details์ userId๋ฅผ ์ฌ์ฉํด ์ฌ์ฉ์ ์ฌ์กฐํ๋ฅผ ์๋ตํ๋ค")
+ void viewFile_authenticatedUserId_skipsUserLookup() throws Exception {
+ UsernamePasswordAuthenticationToken authentication =
+ new UsernamePasswordAuthenticationToken(EMAIL, "", List.of());
+ authentication.setDetails(Map.of("userId", USER_ID));
+ when(fileAccessService.forView(FILE_NAME, USER_ID)).thenReturn(stream());
+
+ mockMvc.perform(get("/api/files/view/{filename}", FILE_NAME).principal(authentication))
+ .andExpect(status().isOk());
+
+ verify(userRepository, never()).findByEmail(EMAIL);
}
@Test
@@ -134,10 +159,10 @@ void viewFile_redirect_returnsFoundWithLocation() throws Exception {
}
@Test
- @DisplayName("๊ถํ ์์ธ ๋ฉ์์ง โ 403")
- void downloadFile_unauthorizedMessage_returnsForbidden() throws Exception {
+ @DisplayName("๋น์ฐธ๊ฐ์ ์์ธ โ 403")
+ void downloadFile_nonParticipant_returnsForbidden() throws Exception {
when(fileAccessService.forDownload(FILE_NAME, USER_ID))
- .thenThrow(new RuntimeException("ํ์ผ์ ์ ๊ทผํ ๊ถํ์ด ์์ต๋๋ค"));
+ .thenThrow(new FileAccessException(FileAccessException.Reason.NOT_PARTICIPANT));
mockMvc.perform(get("/api/files/download/{filename}", FILE_NAME).principal(PRINCIPAL))
.andExpect(status().isForbidden())
@@ -146,10 +171,10 @@ void downloadFile_unauthorizedMessage_returnsForbidden() throws Exception {
}
@Test
- @DisplayName("๋ฏธ๋ฐ๊ฒฌ ์์ธ ๋ฉ์์ง โ 404")
- void downloadFile_notFoundMessage_returnsNotFound() throws Exception {
+ @DisplayName("ํ์ผ ๋ฏธ๋ฐ๊ฒฌ ์์ธ โ 404")
+ void downloadFile_fileNotFound_returnsNotFound() throws Exception {
when(fileAccessService.forDownload(FILE_NAME, USER_ID))
- .thenThrow(new RuntimeException("ํ์ผ์ ์ฐพ์ ์ ์์ต๋๋ค: " + FILE_NAME));
+ .thenThrow(new FileAccessException(FileAccessException.Reason.FILE_NOT_FOUND));
mockMvc.perform(get("/api/files/download/{filename}", FILE_NAME).principal(PRINCIPAL))
.andExpect(status().isNotFound())
@@ -158,10 +183,10 @@ void downloadFile_notFoundMessage_returnsNotFound() throws Exception {
}
@Test
- @DisplayName("view์ ๊ถํ ์์ธ๋ ๊ฐ์ 403 ๊ณ์ฝ์ ๋ฐ๋ฅธ๋ค")
- void viewFile_unauthorizedMessage_returnsForbidden() throws Exception {
+ @DisplayName("view์ ๋น์ฐธ๊ฐ์ ์์ธ๋ ๊ฐ์ 403 ๊ณ์ฝ์ ๋ฐ๋ฅธ๋ค")
+ void viewFile_nonParticipant_returnsForbidden() throws Exception {
when(fileAccessService.forView(FILE_NAME, USER_ID))
- .thenThrow(new RuntimeException("ํ์ผ์ ์ ๊ทผํ ๊ถํ์ด ์์ต๋๋ค"));
+ .thenThrow(new FileAccessException(FileAccessException.Reason.NOT_PARTICIPANT));
mockMvc.perform(get("/api/files/view/{filename}", FILE_NAME).principal(PRINCIPAL))
.andExpect(status().isForbidden())
@@ -191,6 +216,51 @@ void deleteFile_unauthorizedMessage_returnsForbidden() throws Exception {
.andExpect(jsonPath("$.message").value("ํ์ผ์ ์ญ์ ํ ๊ถํ์ด ์์ต๋๋ค."));
}
+ @Test
+ @DisplayName("presign ์ ๊ทยทํธํ ๊ฒฝ๋ก๊ฐ ๊ฐ์ ์๋ต์ ๋ฐํํ๋ค")
+ void presignAliases_returnSameUploadContract() throws Exception {
+ when(chatFileDirectUploadService.prepare(
+ eq("profile.jpg"), eq("image/jpeg"), eq(1024L), eq(USER_ID)))
+ .thenReturn(new ChatFileDirectUploadService.PreparedUpload(
+ "https://s3.example.test/signed-upload",
+ "chat/user-1/safe.jpg",
+ "safe.jpg"));
+
+ String requestBody = """
+ {"originalname":"profile.jpg","mimetype":"image/jpeg","size":1024}
+ """;
+
+ for (String path : new String[]{"/api/files/presign", "/api/files/upload/presign"}) {
+ mockMvc.perform(post(path)
+ .principal(PRINCIPAL)
+ .contentType("application/json")
+ .content(requestBody))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.uploadUrl").value("https://s3.example.test/signed-upload"))
+ .andExpect(jsonPath("$.key").value("chat/user-1/safe.jpg"));
+ }
+ }
+
+ @Test
+ @DisplayName("์ง์ ์
๋ก๋ ๋ฏธ์ง์ ์คํ ๋ฆฌ์ง๋ 409๋ก ํด๋ฐฑ ๊ฐ๋ฅ ์ฌ๋ถ๋ฅผ ์๋ฆฐ๋ค")
+ void presign_notSupported_returnsConflict() throws Exception {
+ when(chatFileDirectUploadService.prepare(
+ eq("profile.jpg"), eq("image/jpeg"), eq(1024L), eq(USER_ID)))
+ .thenThrow(new DirectUploadNotSupportedException());
+
+ mockMvc.perform(post("/api/files/presign")
+ .principal(PRINCIPAL)
+ .contentType("application/json")
+ .content("""
+ {"originalname":"profile.jpg","mimetype":"image/jpeg","size":1024}
+ """))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.success").value(false))
+ .andExpect(jsonPath("$.message")
+ .value("ํ์ฌ ์คํ ๋ฆฌ์ง๋ ์ง์ ์
๋ก๋๋ฅผ ์ง์ํ์ง ์์ต๋๋ค."));
+ }
+
private FileAccess stream() {
return new FileAccess.Stream(STORED_BYTES, ORIGINAL_NAME, "image/png", 11L);
}
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/repository/MessageRepositoryIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/repository/MessageRepositoryIntegrationTest.java
new file mode 100644
index 00000000..e62af628
--- /dev/null
+++ b/apps/backend/src/test/java/com/ktb/chatapp/repository/MessageRepositoryIntegrationTest.java
@@ -0,0 +1,81 @@
+package com.ktb.chatapp.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.ktb.chatapp.config.MongoTestContainer;
+import com.ktb.chatapp.config.RedisTestContainer;
+import com.ktb.chatapp.model.Message;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Set;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.TestPropertySource;
+
+@SpringBootTest
+@Import({MongoTestContainer.class, RedisTestContainer.class})
+@TestPropertySource(properties = {
+ "spring.data.mongodb.auto-index-creation=true",
+ "socketio.enabled=false"
+})
+@DisplayName("MessageRepository ํตํฉ ํ
์คํธ")
+class MessageRepositoryIntegrationTest {
+
+ @Autowired private MessageRepository messageRepository;
+
+ @AfterEach
+ void tearDown() {
+ messageRepository.deleteAll();
+ }
+
+ @Test
+ @DisplayName("์ฌ๋ฌ ์ฑํ
๋ฐฉ์ ์ต๊ทผ ๋ฉ์์ง ์๋ฅผ ํ ๋ฒ์ ์ง๊ณ๋ก ๋ฐํํ๋ค")
+ void countRecentMessagesByRoomIds_GroupsRecentMessagesByRequestedRoom() {
+ LocalDateTime now = LocalDateTime.now();
+ saveMessage("room-1", now.minusMinutes(10));
+ saveMessage("room-1", now.minusMinutes(20));
+ saveMessage("room-1", now.minusMinutes(40));
+ saveMessage("room-2", now.minusMinutes(5));
+ saveMessage("room-3", now.minusMinutes(5));
+
+ List result =
+ messageRepository.countRecentMessagesByRoomIds(
+ Set.of("room-1", "room-2"),
+ now.minusMinutes(30));
+
+ assertThat(result)
+ .extracting(RecentMessageCount::roomKey, RecentMessageCount::count)
+ .containsExactlyInAnyOrder(
+ org.assertj.core.groups.Tuple.tuple("room-1", 2L),
+ org.assertj.core.groups.Tuple.tuple("room-2", 1L));
+ }
+
+ @Test
+ @DisplayName("ํ์ผ ์ธ๊ฐ๋ ๋ฉ์์ง์ id์ room๋ง projectionํ๋ค")
+ void findRoomOnlyByFileId_returnsAuthorizationProjection() {
+ Message saved = messageRepository.save(Message.builder()
+ .roomId("room-file")
+ .fileId("file-1")
+ .content("projection์์ ์ ์ธ๋ ํฐ ๋ฉ์์ง ๋ณธ๋ฌธ")
+ .build());
+
+ Message projection = messageRepository.findRoomOnlyByFileId("file-1").orElseThrow();
+
+ assertThat(projection.getId()).isEqualTo(saved.getId());
+ assertThat(projection.getRoomId()).isEqualTo("room-file");
+ assertThat(projection.getContent()).isNull();
+ }
+
+ private void saveMessage(String roomId, LocalDateTime timestamp) {
+ Message saved = messageRepository.save(Message.builder()
+ .roomId(roomId)
+ .content("๋ฉ์์ง")
+ .build());
+ saved.setTimestamp(timestamp);
+ messageRepository.save(saved);
+ }
+}
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/repository/RoomRepositoryIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/repository/RoomRepositoryIntegrationTest.java
new file mode 100644
index 00000000..3c3a8d35
--- /dev/null
+++ b/apps/backend/src/test/java/com/ktb/chatapp/repository/RoomRepositoryIntegrationTest.java
@@ -0,0 +1,91 @@
+package com.ktb.chatapp.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.ktb.chatapp.config.MongoTestContainer;
+import com.ktb.chatapp.model.Room;
+import java.util.Set;
+import java.util.stream.IntStream;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.test.context.TestPropertySource;
+
+@SpringBootTest
+@Import(MongoTestContainer.class)
+@TestPropertySource(properties = {
+ "spring.data.mongodb.auto-index-creation=true",
+ "socketio.enabled=false"
+})
+class RoomRepositoryIntegrationTest {
+
+ @Autowired
+ private RoomRepository roomRepository;
+
+ @Autowired
+ private MongoTemplate mongoTemplate;
+
+ @AfterEach
+ void tearDown() {
+ roomRepository.deleteAll();
+ }
+
+ @Test
+ void participantLookupUsesAnExplicitMultikeyIndex() {
+ Room room = roomRepository.save(Room.builder()
+ .name("indexed-room")
+ .participantIds(Set.of("user-1", "user-2"))
+ .build());
+
+ assertThat(roomRepository.findByParticipantIdsContaining("user-2"))
+ .extracting(Room::getName)
+ .containsExactly("indexed-room");
+
+ assertThat(mongoTemplate.indexOps(Room.class).getIndexInfo())
+ .anySatisfy(index -> {
+ if ("participant_ids_idx".equals(index.getName())) {
+ assertThat(index.getIndexFields())
+ .anySatisfy(field -> assertThat(field.getKey()).isEqualTo("participantIds"));
+ } else {
+ throw new AssertionError("not the participant index");
+ }
+ });
+
+ assertThat(roomRepository.findAccessById(room.getId(), "user-2"))
+ .contains(new RoomAccessResult(true));
+ assertThat(roomRepository.findAccessById(room.getId(), "outsider"))
+ .contains(new RoomAccessResult(false));
+ assertThat(roomRepository.findAccessById("missing-room", "user-2")).isEmpty();
+ }
+
+ @Test
+ void concurrentParticipantAddsDoNotLoseMembers() {
+ Room room = roomRepository.save(Room.builder()
+ .name("concurrent-room")
+ .participantIds(Set.of("creator"))
+ .build());
+ int participantCount = 40;
+
+ try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
+ CompletableFuture>[] additions = IntStream.range(0, participantCount)
+ .mapToObj(index -> CompletableFuture.runAsync(
+ () -> roomRepository.addParticipant(room.getId(), "user-" + index),
+ executor))
+ .toArray(CompletableFuture[]::new);
+
+ CompletableFuture.allOf(additions).join();
+ }
+
+ Room updated = roomRepository.findById(room.getId()).orElseThrow();
+ assertThat(updated.getParticipantIds()).hasSize(participantCount + 1);
+ assertThat(updated.getParticipantIds()).contains("creator", "user-0", "user-39");
+ assertThat(roomRepository.existsByIdAndParticipantIdsContaining(room.getId(), "user-17"))
+ .isTrue();
+ }
+}
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/FileAccessServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/FileAccessServiceTest.java
index e3ecd5ed..2ae26199 100644
--- a/apps/backend/src/test/java/com/ktb/chatapp/service/FileAccessServiceTest.java
+++ b/apps/backend/src/test/java/com/ktb/chatapp/service/FileAccessServiceTest.java
@@ -3,21 +3,25 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
import com.ktb.chatapp.model.File;
import com.ktb.chatapp.model.Message;
-import com.ktb.chatapp.model.Room;
+import com.ktb.chatapp.exception.FileAccessException;
import com.ktb.chatapp.repository.FileRepository;
import com.ktb.chatapp.repository.MessageRepository;
+import com.ktb.chatapp.repository.RoomAccessResult;
import com.ktb.chatapp.repository.RoomRepository;
import com.ktb.chatapp.storage.StoragePort;
import com.ktb.chatapp.storage.StoredObject;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Optional;
-import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -57,6 +61,8 @@ class FileAccessServiceTest {
@Mock
private RoomRepository roomRepository;
+ private final SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry();
+
@Test
@DisplayName("์ฐธ๊ฐ์ + ์คํ๋ก๋ฉ ์ง์ ์คํ ๋ฆฌ์ง โ Redirect๋ก ์คํ๋ก๋ฉ๋๋ค")
void forDownload_participantWithOffloadSupport_returnsRedirect() {
@@ -69,6 +75,14 @@ void forDownload_participantWithOffloadSupport_returnsRedirect() {
assertThat(((FileAccess.Redirect) access).location()).isEqualTo(OFFLOADED_URL);
assertThat(storage.offloadedKey).isEqualTo(KEY);
assertThat(storage.openCalls).isZero();
+ verify(messageRepository).findRoomOnlyByFileId(FILE_ID);
+ verify(messageRepository, never()).findByFileId(FILE_ID);
+ assertThat(meterRegistry.get("file.access.authorization.duration")
+ .tags("operation", "download", "outcome", "allowed", "reason", "participant")
+ .timer().count()).isEqualTo(1L);
+ assertThat(meterRegistry.get("file.access.delivery.duration")
+ .tags("operation", "download", "outcome", "success", "delivery", "redirect")
+ .timer().count()).isEqualTo(1L);
}
@Test
@@ -95,11 +109,16 @@ void forDownload_nonParticipantWithOffloadSupport_rejectsBeforeIssuingUrl() {
FileAccessService service = serviceWith(storage, "image/png");
assertThatThrownBy(() -> service.forDownload(FILE_NAME, OUTSIDER))
- .isInstanceOf(RuntimeException.class)
- .hasMessage("ํ์ผ์ ์ ๊ทผํ ๊ถํ์ด ์์ต๋๋ค");
+ .isInstanceOfSatisfying(FileAccessException.class, exception ->
+ assertThat(exception.getReason())
+ .isEqualTo(FileAccessException.Reason.NOT_PARTICIPANT));
assertThat(storage.offloadCalls).isZero();
assertThat(storage.openCalls).isZero();
+ assertThat(meterRegistry.get("file.access.requests")
+ .tags("operation", "download", "outcome", "denied", "reason", "not_participant")
+ .counter()
+ .count()).isEqualTo(1.0);
}
@Test
@@ -109,8 +128,9 @@ void forDownload_nonParticipantWithoutOffloadSupport_rejectsWithSameMessage() {
FileAccessService service = serviceWith(storage, "image/png");
assertThatThrownBy(() -> service.forDownload(FILE_NAME, OUTSIDER))
- .isInstanceOf(RuntimeException.class)
- .hasMessage("ํ์ผ์ ์ ๊ทผํ ๊ถํ์ด ์์ต๋๋ค");
+ .isInstanceOfSatisfying(FileAccessException.class, exception ->
+ assertThat(exception.getReason())
+ .isEqualTo(FileAccessException.Reason.NOT_PARTICIPANT));
assertThat(storage.openCalls).isZero();
}
@@ -180,7 +200,9 @@ void forView_nonParticipant_failsAuthorizationBeforePreviewCheck() {
FileAccessService service = serviceWith(storage, "application/zip");
assertThatThrownBy(() -> service.forView(FILE_NAME, OUTSIDER))
- .hasMessage("ํ์ผ์ ์ ๊ทผํ ๊ถํ์ด ์์ต๋๋ค");
+ .isInstanceOfSatisfying(FileAccessException.class, exception ->
+ assertThat(exception.getReason())
+ .isEqualTo(FileAccessException.Reason.NOT_PARTICIPANT));
}
@Test
@@ -188,10 +210,13 @@ void forView_nonParticipant_failsAuthorizationBeforePreviewCheck() {
void forDownload_missingFileEntity_throwsNotFound() {
when(fileRepository.findByFilename(FILE_NAME)).thenReturn(Optional.empty());
FileAccessService service = new FileAccessService(
- new DirectStorage(), fileRepository, messageRepository, roomRepository);
+ new DirectStorage(), fileRepository, messageRepository, roomRepository,
+ Optional.empty(), meterRegistry, "test-instance", "local", false);
assertThatThrownBy(() -> service.forDownload(FILE_NAME, PARTICIPANT))
- .hasMessage("ํ์ผ์ ์ฐพ์ ์ ์์ต๋๋ค: " + FILE_NAME);
+ .isInstanceOfSatisfying(FileAccessException.class, exception ->
+ assertThat(exception.getReason())
+ .isEqualTo(FileAccessException.Reason.FILE_NOT_FOUND));
}
@Test
@@ -205,13 +230,27 @@ void forDownload_missingStoredObject_throwsNotFound() {
.hasMessage("ํ์ผ์ ์ฐพ์ ์ ์์ต๋๋ค: " + FILE_NAME);
}
+ @Test
+ @DisplayName("์คํ๋ก๋ฉ ํ์ ํ๊ฒฝ์์ local storage๋ ์์ ์ ์ ๊ฑฐ๋ถ๋๋ค")
+ void constructor_requiredOffloadWithLocalStorage_rejectsConfiguration() {
+ assertThatThrownBy(() -> new FileAccessService(
+ new DirectStorage(), fileRepository, messageRepository, roomRepository,
+ Optional.empty(), meterRegistry, "test-instance", "local", true))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("FILE_STORAGE_TYPE=s3");
+ }
+
private FileAccessService serviceWith(StoragePort storagePort, String mimetype) {
when(fileRepository.findByFilename(FILE_NAME)).thenReturn(Optional.of(fileEntity(mimetype)));
- when(messageRepository.findByFileId(FILE_ID)).thenReturn(Optional.of(
+ when(messageRepository.findRoomOnlyByFileId(FILE_ID)).thenReturn(Optional.of(
Message.builder().id("message-id").roomId(ROOM_ID).fileId(FILE_ID).build()));
- when(roomRepository.findById(ROOM_ID)).thenReturn(Optional.of(
- Room.builder().id(ROOM_ID).participantIds(Set.of(PARTICIPANT)).build()));
- return new FileAccessService(storagePort, fileRepository, messageRepository, roomRepository);
+ lenient().when(roomRepository.findAccessById(ROOM_ID, PARTICIPANT))
+ .thenReturn(Optional.of(new RoomAccessResult(true)));
+ lenient().when(roomRepository.findAccessById(ROOM_ID, OUTSIDER))
+ .thenReturn(Optional.of(new RoomAccessResult(false)));
+ return new FileAccessService(
+ storagePort, fileRepository, messageRepository, roomRepository,
+ Optional.empty(), meterRegistry, "test-instance", "local", false);
}
private File fileEntity(String mimetype) {
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitRedisStoreIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitRedisStoreIntegrationTest.java
new file mode 100644
index 00000000..6d34e97d
--- /dev/null
+++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitRedisStoreIntegrationTest.java
@@ -0,0 +1,78 @@
+package com.ktb.chatapp.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.ktb.chatapp.config.MongoTestContainer;
+import com.ktb.chatapp.config.RedisTestContainer;
+import com.ktb.chatapp.service.ratelimit.RateLimitRedisStore;
+import java.time.Duration;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.IntStream;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.test.context.TestPropertySource;
+
+@SpringBootTest
+@Import({MongoTestContainer.class, RedisTestContainer.class})
+@TestPropertySource(properties = {
+ "socketio.enabled=false",
+ "rate-limit.store.type=redis"
+})
+class RateLimitRedisStoreIntegrationTest {
+
+ @Autowired
+ private StringRedisTemplate redisTemplate;
+
+ @Autowired
+ private RedisConnectionFactory connectionFactory;
+
+ @BeforeEach
+ void clearRedis() {
+ try (var connection = connectionFactory.getConnection()) {
+ connection.serverCommands().flushDb();
+ }
+ }
+
+ @Test
+ void rejectsRequestsAfterTheSharedLimit() {
+ var service = new RateLimitService(new RateLimitRedisStore(redisTemplate));
+
+ assertThat(service.checkRateLimit("client", 2, Duration.ofMinutes(1)).allowed()).isTrue();
+ assertThat(service.checkRateLimit("client", 2, Duration.ofMinutes(1)).allowed()).isTrue();
+ assertThat(service.checkRateLimit("client", 2, Duration.ofMinutes(1)).allowed()).isFalse();
+ }
+
+ @Test
+ void concurrentInstancesNeverAllowMoreThanTheSharedLimit() throws Exception {
+ int maxRequests = 25;
+ var firstInstance = new RateLimitService(new RateLimitRedisStore(redisTemplate));
+ var secondInstance = new RateLimitService(new RateLimitRedisStore(redisTemplate));
+ var executor = Executors.newFixedThreadPool(20);
+ try {
+ var futures = IntStream.range(0, 100)
+ .mapToObj(index -> executor.submit(() -> {
+ var instance = index % 2 == 0 ? firstInstance : secondInstance;
+ return instance.checkRateLimit(
+ "shared-client", maxRequests, Duration.ofMinutes(1));
+ }))
+ .toList();
+
+ long allowed = 0;
+ for (var future : futures) {
+ if (future.get(10, TimeUnit.SECONDS).allowed()) {
+ allowed++;
+ }
+ }
+
+ assertThat(allowed).isEqualTo(maxRequests);
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+}
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceTest.java
index 6e6c2ac4..b2113cbb 100644
--- a/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceTest.java
+++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceTest.java
@@ -4,6 +4,9 @@
import com.ktb.chatapp.repository.RateLimitRepository;
import java.time.Duration;
import java.time.Instant;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.IntStream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -131,4 +134,25 @@ void checkRateLimit_IndependentLimitsPerClient() {
assertThat(result2.allowed()).isTrue();
assertThat(result2.remaining()).isEqualTo(1);
}
+
+ @Test
+ @DisplayName("๋์ ์์ฒญ๋ ์ธ์คํด์ค ๊ณตํต ํ๋๋ฅผ ์ด๊ณผํด ํ์ฉํ์ง ์๋๋ค")
+ void checkRateLimit_ConcurrentRequestsRespectGlobalLimit() throws Exception {
+ int maxRequests = 5;
+ var executor = Executors.newFixedThreadPool(10);
+ try {
+ var futures = IntStream.range(0, 20)
+ .mapToObj(ignored -> executor.submit(() -> rateLimitService.checkRateLimit(
+ "concurrent-client", maxRequests, Duration.ofSeconds(60))))
+ .toList();
+
+ long allowed = 0;
+ for (var future : futures) {
+ if (future.get(10, TimeUnit.SECONDS).allowed()) allowed++;
+ }
+ assertThat(allowed).isEqualTo(maxRequests);
+ } finally {
+ executor.shutdownNow();
+ }
+ }
}
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceUnitTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceUnitTest.java
index e4ac1b4d..d314ac4e 100644
--- a/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceUnitTest.java
+++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RateLimitServiceUnitTest.java
@@ -1,178 +1,69 @@
package com.ktb.chatapp.service;
-import com.ktb.chatapp.model.RateLimit;
+import com.ktb.chatapp.service.ratelimit.RateLimitConsumption;
import com.ktb.chatapp.service.ratelimit.RateLimitStore;
import java.time.Duration;
import java.time.Instant;
-import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
-import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
-@DisplayName("RateLimitService ๋จ์ ํ
์คํธ")
class RateLimitServiceUnitTest {
- private static final String HOST_NAME = "test-host";
- private static final String CLIENT_ID = "client-1";
- private static final String STORE_CLIENT_ID = HOST_NAME + ":" + CLIENT_ID;
-
- @Mock
- private RateLimitStore rateLimitStore;
-
- private RateLimitService rateLimitService;
+ @Mock private RateLimitStore rateLimitStore;
+ private RateLimitService service;
@BeforeEach
void setUp() {
- rateLimitService = new RateLimitService(rateLimitStore);
- ReflectionTestUtils.setField(rateLimitService, "hostName", HOST_NAME);
+ service = new RateLimitService(rateLimitStore);
}
@Test
- @DisplayName("์ต์ด ์์ฒญ์ host-prefixed clientId๋ก ์ ์ฅ๋๊ณ ๋จ์ ํ์๋ฅผ ๋ฐํํ๋ค")
- void checkRateLimit_FirstRequest_SavesHostPrefixedClientId() {
- when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenReturn(Optional.empty());
- when(rateLimitStore.save(any(RateLimit.class))).thenAnswer(invocation -> invocation.getArgument(0));
- ArgumentCaptor rateLimitCaptor = ArgumentCaptor.forClass(RateLimit.class);
+ void usesGlobalClientIdAndReturnsRemainingCount() {
+ when(rateLimitStore.consume(eq("client-1"), eq(3), eq(Duration.ofSeconds(30)), any()))
+ .thenReturn(new RateLimitConsumption(1, Instant.now().plusSeconds(30), true));
- RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, Duration.ofSeconds(30));
+ RateLimitCheckResult result = service.checkRateLimit("client-1", 3, Duration.ofSeconds(30));
assertThat(result.allowed()).isTrue();
- assertThat(result.limit()).isEqualTo(3);
assertThat(result.remaining()).isEqualTo(2);
- assertThat(result.windowSeconds()).isEqualTo(30);
- assertThat(result.retryAfterSeconds()).isBetween(1L, 30L);
- verify(rateLimitStore).save(rateLimitCaptor.capture());
- assertThat(rateLimitCaptor.getValue().getClientId()).isEqualTo(STORE_CLIENT_ID);
- assertThat(rateLimitCaptor.getValue().getCount()).isEqualTo(1);
- assertThat(rateLimitCaptor.getValue().getExpiresAt()).isAfter(Instant.now());
}
@Test
- @DisplayName("๊ธฐ์กด ์นด์ดํธ๊ฐ ํ๋ ๋ฏธ๋ง์ด๋ฉด ์ฆ๊ฐ์์ผ ์ ์ฅํ๋ค")
- void checkRateLimit_ExistingBelowLimit_IncrementsCount() {
- RateLimit existing = RateLimit.builder()
- .clientId(STORE_CLIENT_ID)
- .count(1)
- .expiresAt(Instant.now().plusSeconds(20))
- .build();
- when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenReturn(Optional.of(existing));
- when(rateLimitStore.save(existing)).thenReturn(existing);
-
- RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, Duration.ofSeconds(30));
+ void rejectsWhenAtomicStoreRejects() {
+ when(rateLimitStore.consume(eq("client-1"), eq(3), eq(Duration.ofSeconds(30)), any()))
+ .thenReturn(new RateLimitConsumption(3, Instant.now().plusSeconds(10), false));
- assertThat(result.allowed()).isTrue();
- assertThat(result.remaining()).isEqualTo(1);
- assertThat(existing.getCount()).isEqualTo(2);
- verify(rateLimitStore).save(existing);
- }
-
- @Test
- @DisplayName("๊ธฐ์กด ์นด์ดํธ๊ฐ ํ๋์ ๋๋ฌํ๋ฉด retry-after์ reset epoch๋ฅผ ๋ฐํํ๊ณ ์ ์ฅํ์ง ์๋๋ค")
- void checkRateLimit_LimitReached_ReturnsRetryAfterWithoutSaving() {
- Instant expiresAt = Instant.now().plusSeconds(10);
- RateLimit existing = RateLimit.builder()
- .clientId(STORE_CLIENT_ID)
- .count(3)
- .expiresAt(expiresAt)
- .build();
- when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenReturn(Optional.of(existing));
-
- RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, Duration.ofSeconds(30));
+ RateLimitCheckResult result = service.checkRateLimit("client-1", 3, Duration.ofSeconds(30));
assertThat(result.allowed()).isFalse();
- assertThat(result.limit()).isEqualTo(3);
assertThat(result.remaining()).isZero();
- assertThat(result.retryAfterSeconds()).isBetween(1L, 10L);
- assertThat(result.resetEpochSeconds()).isEqualTo(expiresAt.getEpochSecond());
- verify(rateLimitStore, never()).save(any(RateLimit.class));
- }
-
- @Test
- @DisplayName("0์ด window๋ ์ต์ 1์ด window๋ก ์ ๊ทํ๋๋ค")
- void checkRateLimit_ZeroWindow_NormalizesToOneSecond() {
- when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenReturn(Optional.empty());
- when(rateLimitStore.save(any(RateLimit.class))).thenAnswer(invocation -> invocation.getArgument(0));
-
- RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, Duration.ZERO);
-
- assertThat(result.allowed()).isTrue();
- assertThat(result.windowSeconds()).isEqualTo(1);
- assertThat(result.retryAfterSeconds()).isPositive();
- }
-
- @Test
- @DisplayName("null window๋ ์ต์ 1์ด window๋ก ์ ๊ทํ๋๋ค")
- void checkRateLimit_NullWindow_NormalizesToOneSecond() {
- when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenReturn(Optional.empty());
- when(rateLimitStore.save(any(RateLimit.class))).thenAnswer(invocation -> invocation.getArgument(0));
-
- RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, null);
-
- assertThat(result.allowed()).isTrue();
- assertThat(result.windowSeconds()).isEqualTo(1);
- assertThat(result.retryAfterSeconds()).isPositive();
}
@Test
- @DisplayName("๋ง๋ฃ๋ ์ ์ฅ์ ๋ฌธ์๋ ์ window๋ก ๋ฆฌ์
๋๋ค")
- void checkRateLimit_ExpiredStoredRateLimit_StartsNewWindow() {
- RateLimit expired = RateLimit.builder()
- .clientId(STORE_CLIENT_ID)
- .count(3)
- .expiresAt(Instant.now().minusSeconds(10))
- .build();
- when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenReturn(Optional.of(expired));
- when(rateLimitStore.save(expired)).thenReturn(expired);
-
- RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, Duration.ofSeconds(30));
+ void normalizesZeroWindowToOneSecond() {
+ when(rateLimitStore.consume(eq("client-1"), eq(3), eq(Duration.ofSeconds(1)), any()))
+ .thenReturn(new RateLimitConsumption(1, Instant.now().plusSeconds(1), true));
- assertThat(result.allowed()).isTrue();
- assertThat(result.remaining()).isEqualTo(2);
- assertThat(result.retryAfterSeconds()).isBetween(1L, 30L);
- assertThat(result.resetEpochSeconds()).isGreaterThan(Instant.now().getEpochSecond());
- assertThat(expired.getCount()).isEqualTo(1);
- assertThat(expired.getExpiresAt()).isAfter(Instant.now());
- verify(rateLimitStore).save(expired);
+ assertThat(service.checkRateLimit("client-1", 3, Duration.ZERO).windowSeconds()).isEqualTo(1);
}
@Test
- @DisplayName("์ ์ฅ์ ์คํจ ์ ์์ฒญ์ ํ์ฉํ๊ณ ์ ์ฒด ํ๋๋ฅผ ๋จ๊ธด๋ค")
- void checkRateLimit_StoreFailure_FailsOpenDeterministically() {
- when(rateLimitStore.findByClientId(STORE_CLIENT_ID)).thenThrow(new IllegalStateException("store down"));
+ void failsOpenWhenStoreIsUnavailable() {
+ when(rateLimitStore.consume(eq("client-1"), eq(3), eq(Duration.ofSeconds(30)), any()))
+ .thenThrow(new IllegalStateException("store down"));
- RateLimitCheckResult result = rateLimitService.checkRateLimit(CLIENT_ID, 3, Duration.ofSeconds(30));
+ RateLimitCheckResult result = service.checkRateLimit("client-1", 3, Duration.ofSeconds(30));
assertThat(result.allowed()).isTrue();
- assertThat(result.limit()).isEqualTo(3);
assertThat(result.remaining()).isEqualTo(3);
- assertThat(result.windowSeconds()).isEqualTo(30);
- assertThat(result.retryAfterSeconds()).isEqualTo(30);
- }
-
- @Test
- @DisplayName("null clientId๋ host prefix๊ฐ ์ ์ฉ๋ ์ ์ฅ์ key๋ก ์ฒ๋ฆฌ๋๋ค")
- void checkRateLimit_NullClientId_UsesHostPrefixedNullKey() {
- String storeClientId = HOST_NAME + ":null";
- when(rateLimitStore.findByClientId(storeClientId)).thenReturn(Optional.empty());
- when(rateLimitStore.save(any(RateLimit.class))).thenAnswer(invocation -> invocation.getArgument(0));
- ArgumentCaptor rateLimitCaptor = ArgumentCaptor.forClass(RateLimit.class);
-
- RateLimitCheckResult result = rateLimitService.checkRateLimit(null, 3, Duration.ofSeconds(30));
-
- assertThat(result.allowed()).isTrue();
- verify(rateLimitStore).save(rateLimitCaptor.capture());
- assertThat(rateLimitCaptor.getValue().getClientId()).isEqualTo(storeClientId);
}
}
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RecentMessageCounterTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RecentMessageCounterTest.java
new file mode 100644
index 00000000..2968d334
--- /dev/null
+++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RecentMessageCounterTest.java
@@ -0,0 +1,78 @@
+package com.ktb.chatapp.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.ktb.chatapp.repository.MessageRepository;
+import com.ktb.chatapp.repository.RecentMessageCount;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("RecentMessageCounter ๋จ์ ํ
์คํธ")
+class RecentMessageCounterTest {
+
+ @Mock private MessageRepository messageRepository;
+
+ private RecentMessageCounter recentMessageCounter;
+
+ @BeforeEach
+ void setUp() {
+ recentMessageCounter = new RecentMessageCounter(messageRepository);
+ }
+
+ @Test
+ @DisplayName("์ฌ๋ฌ ์ฑํ
๋ฐฉ์ ์ต๊ทผ ๋ฉ์์ง ์๋ฅผ ํ ๋ฒ์ ์กฐํํด Map์ผ๋ก ๋ณํํ๋ค")
+ void countRecentMessagesByRoomIds_LoadsCountsOnceAndMapsByRoomId() {
+ Set roomIds = Set.of("room-1", "room-2");
+ when(messageRepository.countRecentMessagesByRoomIds(
+ eq(roomIds), any(LocalDateTime.class)))
+ .thenReturn(List.of(
+ new RecentMessageCount("room-1", 3),
+ new RecentMessageCount("room-2", 5)));
+
+ Map result =
+ recentMessageCounter.countRecentMessagesByRoomIds(roomIds);
+
+ assertThat(result).containsExactlyInAnyOrderEntriesOf(
+ Map.of("room-1", 3, "room-2", 5));
+ verify(messageRepository).countRecentMessagesByRoomIds(
+ eq(roomIds), any(LocalDateTime.class));
+ }
+
+ @Test
+ @DisplayName("์ฑํ
๋ฐฉ ID๊ฐ ์์ผ๋ฉด ๋ฉ์์ง๋ฅผ ์กฐํํ์ง ์๋๋ค")
+ void countRecentMessagesByRoomIds_EmptyRoomIdsSkipsRepository() {
+ Map result =
+ recentMessageCounter.countRecentMessagesByRoomIds(Set.of());
+
+ assertThat(result).isEmpty();
+ verifyNoInteractions(messageRepository);
+ }
+
+ @Test
+ @DisplayName("๋จ๊ฑด ์ต๊ทผ ๋ฉ์์ง ์กฐํ ๋์์ ์ ์งํ๋ค")
+ void countRecentMessages_KeepsSingleRoomQuery() {
+ when(messageRepository.countRecentMessagesByRoomId(
+ eq("room-1"), any(LocalDateTime.class)))
+ .thenReturn(7L);
+
+ int result = recentMessageCounter.countRecentMessages("room-1");
+
+ assertThat(result).isEqualTo(7);
+ verify(messageRepository).countRecentMessagesByRoomId(
+ eq("room-1"), any(LocalDateTime.class));
+ }
+}
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityDebouncerIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityDebouncerIntegrationTest.java
new file mode 100644
index 00000000..ae5e479f
--- /dev/null
+++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityDebouncerIntegrationTest.java
@@ -0,0 +1,40 @@
+package com.ktb.chatapp.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.ktb.chatapp.config.MongoTestContainer;
+import com.ktb.chatapp.config.RedisTestContainer;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.TestPropertySource;
+
+@SpringBootTest
+@Import({MongoTestContainer.class, RedisTestContainer.class})
+@TestPropertySource(properties = {
+ "socketio.enabled=false",
+ "socketio.store.type=redis"
+})
+class RoomActivityDebouncerIntegrationTest {
+
+ @Autowired
+ private RoomActivityDebouncer debouncer;
+
+ @Autowired
+ private RedissonClient redissonClient;
+
+ @BeforeEach
+ void clearKeys() {
+ redissonClient.getKeys().deleteByPattern("room_activity:debounce:*");
+ }
+
+ @Test
+ void onlyOneInstanceCanAggregateTheSameRoomDuringTheWindow() {
+ assertThat(debouncer.tryAcquire("room-1")).isTrue();
+ assertThat(debouncer.tryAcquire("room-1")).isFalse();
+ assertThat(debouncer.tryAcquire("room-2")).isTrue();
+ }
+}
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityNotifierTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityNotifierTest.java
index 97d8c7ef..520484f3 100644
--- a/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityNotifierTest.java
+++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomActivityNotifierTest.java
@@ -1,6 +1,8 @@
package com.ktb.chatapp.service;
-import com.ktb.chatapp.event.RoomActivityEvent;
+import com.ktb.chatapp.event.RoomActivitiesEvent;
+import java.util.Map;
+import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@@ -8,7 +10,7 @@
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
-import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -21,52 +23,77 @@ class RoomActivityNotifierTest {
@Mock private RecentMessageCounter recentMessageCounter;
@Mock private ApplicationEventPublisher eventPublisher;
+ @Mock private RoomActivityDebouncer roomActivityDebouncer;
private RoomActivityNotifier notifier() {
- return new RoomActivityNotifier(recentMessageCounter, eventPublisher);
+ return new RoomActivityNotifier(recentMessageCounter, eventPublisher, roomActivityDebouncer);
}
@Test
- void notifyMessageStored_firstMessageOfRoom_publishesRecentMessageCount() {
- when(recentMessageCounter.countRecentMessages("room-1")).thenReturn(7);
+ void flushPublishesRecentMessageCount() {
+ when(recentMessageCounter.countRecentMessagesByRoomIds(Set.of("room-1")))
+ .thenReturn(Map.of("room-1", 7));
+ when(roomActivityDebouncer.tryAcquire("room-1")).thenReturn(true);
+ RoomActivityNotifier notifier = notifier();
- notifier().notifyMessageStored("room-1");
+ notifier.notifyMessageStored("room-1");
+ notifier.flushPendingRoomActivities();
- ArgumentCaptor eventCaptor =
- ArgumentCaptor.forClass(RoomActivityEvent.class);
- verify(eventPublisher).publishEvent(eventCaptor.capture());
- assertEquals("room-1", eventCaptor.getValue().getRoomId());
- assertEquals(7, eventCaptor.getValue().getRecentMessageCount());
+ ArgumentCaptor event = ArgumentCaptor.forClass(RoomActivitiesEvent.class);
+ verify(eventPublisher).publishEvent(event.capture());
+ assertThat(event.getValue().getRecentMessageCounts()).containsEntry("room-1", 7);
}
@Test
- void notifyMessageStored_everyMessage_publishes() {
- when(recentMessageCounter.countRecentMessages("room-1")).thenReturn(1);
+ void flushCoalescesRepeatedNotificationsForSameRoom() {
+ when(recentMessageCounter.countRecentMessagesByRoomIds(Set.of("room-1")))
+ .thenReturn(Map.of("room-1", 3));
+ when(roomActivityDebouncer.tryAcquire("room-1")).thenReturn(true);
RoomActivityNotifier notifier = notifier();
notifier.notifyMessageStored("room-1");
notifier.notifyMessageStored("room-1");
notifier.notifyMessageStored("room-1");
+ notifier.flushPendingRoomActivities();
+
+ verify(recentMessageCounter).countRecentMessagesByRoomIds(Set.of("room-1"));
+ verify(eventPublisher).publishEvent(any(RoomActivitiesEvent.class));
+ }
+
+ @Test
+ void nullRoomIdDoesNothing() {
+ RoomActivityNotifier notifier = notifier();
+ notifier.notifyMessageStored(null);
+ notifier.flushPendingRoomActivities();
- verify(eventPublisher, times(3)).publishEvent(any(RoomActivityEvent.class));
- verify(recentMessageCounter, times(3)).countRecentMessages("room-1");
+ verifyNoInteractions(recentMessageCounter, eventPublisher);
}
@Test
- void notifyMessageStored_nullRoomId_doesNothing() {
- notifier().notifyMessageStored(null);
+ void failedBatchIsRetriedOnNextFlush() {
+ when(recentMessageCounter.countRecentMessagesByRoomIds(Set.of("room-1")))
+ .thenThrow(new RuntimeException("mongo down"))
+ .thenReturn(Map.of("room-1", 1));
+ when(roomActivityDebouncer.tryAcquire("room-1")).thenReturn(true);
+ RoomActivityNotifier notifier = notifier();
+
+ notifier.notifyMessageStored("room-1");
+ notifier.flushPendingRoomActivities();
+ verify(eventPublisher, never()).publishEvent(any(RoomActivitiesEvent.class));
- verifyNoInteractions(recentMessageCounter);
- verify(eventPublisher, never()).publishEvent(any(RoomActivityEvent.class));
+ notifier.flushPendingRoomActivities();
+ verify(recentMessageCounter, times(2)).countRecentMessagesByRoomIds(Set.of("room-1"));
+ verify(eventPublisher).publishEvent(any(RoomActivitiesEvent.class));
}
@Test
- void notifyMessageStored_counterFails_swallowsException() {
- when(recentMessageCounter.countRecentMessages("room-1"))
- .thenThrow(new RuntimeException("mongo down"));
+ void distributedDebounceSkipsDuplicateAggregation() {
+ when(roomActivityDebouncer.tryAcquire("room-1")).thenReturn(false);
+ RoomActivityNotifier notifier = notifier();
- notifier().notifyMessageStored("room-1");
+ notifier.notifyMessageStored("room-1");
+ notifier.flushPendingRoomActivities();
- verify(eventPublisher, never()).publishEvent(any(RoomActivityEvent.class));
+ verifyNoInteractions(recentMessageCounter, eventPublisher);
}
}
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java
new file mode 100644
index 00000000..5e3027fa
--- /dev/null
+++ b/apps/backend/src/test/java/com/ktb/chatapp/service/RoomServiceTest.java
@@ -0,0 +1,173 @@
+package com.ktb.chatapp.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anySet;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.ktb.chatapp.dto.RoomListItemResponse;
+import com.ktb.chatapp.dto.RoomsResponse;
+import com.ktb.chatapp.model.Room;
+import com.ktb.chatapp.model.User;
+import com.ktb.chatapp.repository.RoomRepository;
+import com.ktb.chatapp.repository.UserRepository;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("RoomService ๋จ์ ํ
์คํธ")
+class RoomServiceTest {
+
+ @Mock private RoomRepository roomRepository;
+ @Mock private UserRepository userRepository;
+ @Mock private RecentMessageCounter recentMessageCounter;
+ @Mock private PasswordEncoder passwordEncoder;
+ @Mock private ApplicationEventPublisher eventPublisher;
+
+ private RoomService roomService;
+
+ @BeforeEach
+ void setUp() {
+ roomService = new RoomService(
+ roomRepository,
+ userRepository,
+ recentMessageCounter,
+ passwordEncoder,
+ eventPublisher);
+ }
+
+ @Test
+ @DisplayName("์ฑํ
๋ฐฉ์ ์ต์ ์์ผ๋ก 20๊ฐ์ฉ ์กฐํํ๊ณ ํ์ฌ ํ์ด์ง ๋ฒ์๋ง ์ง๊ณํ๋ค")
+ void getAllRooms_LoadsOnlyRequestedPageInDescendingOrder() {
+ LocalDateTime createdAt = LocalDateTime.of(2026, 8, 10, 10, 0);
+ Room room2 = room("room-2", Set.of("user-1", "user-2"), createdAt);
+ Room room1 = room("room-1", Set.of("user-1"), createdAt);
+ PageRequest requestedPage = PageRequest.of(1, RoomService.ROOM_LIST_PAGE_SIZE);
+
+ when(roomRepository.findAll(any(Pageable.class))).thenReturn(
+ new PageImpl<>(List.of(room2, room1), requestedPage, 42));
+ when(recentMessageCounter.countRecentMessagesByRoomIds(anySet()))
+ .thenReturn(Map.of("room-2", 5));
+
+ RoomsResponse response = roomService.getAllRooms(1);
+
+ assertThat(response.isSuccess()).isTrue();
+ assertThat(response.getData()).extracting(RoomListItemResponse::getId)
+ .containsExactly("room-2", "room-1");
+ assertThat(response.getData()).extracting(RoomListItemResponse::getParticipantCount)
+ .containsExactly(2, 1);
+ assertThat(response.getData()).extracting(RoomListItemResponse::getRecentMessageCount)
+ .containsExactly(5, 0);
+ assertThat(response.getMetadata().getTotal()).isEqualTo(42);
+ assertThat(response.getMetadata().getPage()).isEqualTo(1);
+ assertThat(response.getMetadata().getPageSize()).isEqualTo(20);
+ assertThat(response.getMetadata().getTotalPages()).isEqualTo(3);
+ assertThat(response.getMetadata().isHasMore()).isTrue();
+ assertThat(response.getMetadata().getCurrentCount()).isEqualTo(2);
+ assertThat(response.getMetadata().getSort().getField()).isEqualTo("createdAt");
+ assertThat(response.getMetadata().getSort().getOrder()).isEqualTo("DESC");
+
+ ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class);
+ verify(roomRepository).findAll(pageableCaptor.capture());
+ Pageable pageable = pageableCaptor.getValue();
+ assertThat(pageable.getPageNumber()).isEqualTo(1);
+ assertThat(pageable.getPageSize()).isEqualTo(20);
+ assertThat(pageable.getSort().getOrderFor("createdAt").getDirection())
+ .isEqualTo(Sort.Direction.DESC);
+ assertThat(pageable.getSort().getOrderFor("id").getDirection())
+ .isEqualTo(Sort.Direction.DESC);
+ verify(recentMessageCounter).countRecentMessagesByRoomIds(Set.of("room-1", "room-2"));
+ verifyNoInteractions(userRepository);
+ }
+
+ @Test
+ @DisplayName("๋ฒ์๋ฅผ ๋ฒ์ด๋ ๋น ํ์ด์ง๋ ์ถ๊ฐ ์กฐํ ์์ด ๋ฉํ๋ฐ์ดํฐ์ ํจ๊ป ๋ฐํํ๋ค")
+ void getAllRooms_OutOfRangePageSkipsRelatedQueries() {
+ PageRequest requestedPage = PageRequest.of(3, RoomService.ROOM_LIST_PAGE_SIZE);
+ when(roomRepository.findAll(any(Pageable.class)))
+ .thenReturn(new PageImpl<>(List.of(), requestedPage, 2));
+
+ RoomsResponse response = roomService.getAllRooms(3);
+
+ assertThat(response.isSuccess()).isTrue();
+ assertThat(response.getData()).isEmpty();
+ assertThat(response.getMetadata().getTotal()).isEqualTo(2);
+ assertThat(response.getMetadata().getPage()).isEqualTo(3);
+ assertThat(response.getMetadata().getCurrentCount()).isZero();
+ assertThat(response.getMetadata().isHasMore()).isFalse();
+ verifyNoInteractions(userRepository, recentMessageCounter);
+ }
+
+ @Test
+ @DisplayName("๋ง์ง๋ง ํ์ด์ง๋ ๋ค์ ํ์ด์ง๊ฐ ์๋ค๊ณ ํ์ํ๋ค")
+ void getAllRooms_LastPageHasNoMorePages() {
+ Room room = room(
+ "room-21",
+ Set.of("user-1"),
+ LocalDateTime.of(2026, 8, 10, 10, 0));
+ PageRequest requestedPage = PageRequest.of(1, RoomService.ROOM_LIST_PAGE_SIZE);
+ when(roomRepository.findAll(any(Pageable.class)))
+ .thenReturn(new PageImpl<>(List.of(room), requestedPage, 21));
+ when(recentMessageCounter.countRecentMessagesByRoomIds(anySet())).thenReturn(Map.of());
+
+ RoomsResponse response = roomService.getAllRooms(1);
+
+ assertThat(response.getMetadata().getTotalPages()).isEqualTo(2);
+ assertThat(response.getMetadata().isHasMore()).isFalse();
+ verify(userRepository, never()).findAllById(anySet());
+ }
+
+ @Test
+ @DisplayName("์ฑํ
๋ฐฉ ์
์ฅ์ ์ฐธ์ฌ์๋ฅผ ์์์ ์ผ๋ก ์ถ๊ฐํ๊ณ ์ ์ฒด ๋ฌธ์๋ฅผ ์ ์ฅํ์ง ์๋๋ค")
+ void joinRoom_AddsParticipantAtomically() {
+ User user = User.builder()
+ .id("user-2")
+ .email("user-2@example.com")
+ .build();
+ Room beforeJoin = room(
+ "room-1",
+ Set.of("user-1"),
+ LocalDateTime.of(2026, 8, 10, 10, 0));
+ when(roomRepository.findById("room-1"))
+ .thenReturn(Optional.of(beforeJoin));
+ when(userRepository.findByEmail("user-2@example.com"))
+ .thenReturn(Optional.of(user));
+ when(roomRepository.addParticipant("room-1", "user-2")).thenReturn(1L);
+ Room joinedRoom = roomService.joinRoom("room-1", null, "user-2@example.com");
+
+ assertThat(joinedRoom.getId()).isEqualTo("room-1");
+ verify(roomRepository).addParticipant("room-1", "user-2");
+ verify(roomRepository).findById("room-1");
+ verify(recentMessageCounter, never()).countRecentMessages(any());
+ verify(roomRepository, never()).save(any(Room.class));
+ }
+
+ private Room room(String id, Set participantIds, LocalDateTime createdAt) {
+ return Room.builder()
+ .id(id)
+ .name("์ฑํ
๋ฐฉ " + id)
+ .creator("user-1")
+ .participantIds(participantIds)
+ .createdAt(createdAt)
+ .build();
+ }
+}
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionRedisStoreIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionRedisStoreIntegrationTest.java
new file mode 100644
index 00000000..43176496
--- /dev/null
+++ b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionRedisStoreIntegrationTest.java
@@ -0,0 +1,74 @@
+package com.ktb.chatapp.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.ktb.chatapp.config.MongoTestContainer;
+import com.ktb.chatapp.config.RedisTestContainer;
+import com.ktb.chatapp.service.session.SessionRedisStore;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.test.context.TestPropertySource;
+import tools.jackson.databind.ObjectMapper;
+
+@SpringBootTest
+@Import({MongoTestContainer.class, RedisTestContainer.class})
+@TestPropertySource(properties = {
+ "socketio.enabled=false",
+ "session.store.type=redis"
+})
+class SessionRedisStoreIntegrationTest {
+
+ @Autowired
+ private StringRedisTemplate redisTemplate;
+
+ @Autowired
+ private RedisConnectionFactory connectionFactory;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @BeforeEach
+ void clearRedis() {
+ try (var connection = connectionFactory.getConnection()) {
+ connection.serverCommands().flushDb();
+ }
+ }
+
+ @Test
+ void sessionCreatedOnOneInstanceIsValidatedOnAnother() {
+ var firstInstance = service();
+ var secondInstance = service();
+
+ var created = firstInstance.createSession(
+ "shared-user", new SessionMetadata("agent", "127.0.0.1", "device"));
+
+ var validation = secondInstance.validateSession("shared-user", created.getSessionId());
+
+ assertThat(validation.isValid()).isTrue();
+ assertThat(validation.getSession().getMetadata().deviceInfo()).isEqualTo("device");
+ }
+
+ @Test
+ void replacingSessionPreventsStaleInstanceFromDeletingTheNewSession() {
+ var firstInstance = service();
+ var secondInstance = service();
+ var oldSession = firstInstance.createSession("shared-user", null);
+ var newSession = secondInstance.createSession("shared-user", null);
+
+ firstInstance.removeSession("shared-user", oldSession.getSessionId());
+
+ assertThat(secondInstance.validateSession("shared-user", oldSession.getSessionId()).isValid())
+ .isFalse();
+ assertThat(secondInstance.validateSession("shared-user", newSession.getSessionId()).isValid())
+ .isTrue();
+ }
+
+ private SessionService service() {
+ return new SessionService(new SessionRedisStore(redisTemplate, objectMapper));
+ }
+}
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceTest.java
index 1dea3427..a430a2bf 100644
--- a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceTest.java
+++ b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceTest.java
@@ -1,12 +1,21 @@
package com.ktb.chatapp.service;
import com.ktb.chatapp.config.MongoTestContainer;
+import com.ktb.chatapp.model.Session;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.data.mongodb.core.query.Criteria;
+import org.springframework.data.mongodb.core.query.Query;
import org.springframework.test.context.TestPropertySource;
import static org.assertj.core.api.Assertions.assertThat;
@@ -28,6 +37,9 @@ class SessionServiceTest {
@Autowired
private SessionService sessionService;
+ @Autowired
+ private MongoTemplate mongoTemplate;
+
private static final String TEST_USER_ID = "test-user-123";
private static final String TEST_USER_ID_2 = "test-user-456";
private static final String TEST_IP = "127.0.0.1";
@@ -70,8 +82,8 @@ void createSession_Success() {
}
@Test
- @DisplayName("์ธ์
์์ฑ ์ ๊ธฐ์กด ์ธ์
์ ๊ฑฐ")
- void createSession_RemovesExistingSession() {
+ @DisplayName("์ธ์
์์ฑ ์ ๊ธฐ์กด ์ธ์
๊ต์ฒด")
+ void createSession_ReplacesExistingSession() {
// Given - ์ฒซ ๋ฒ์งธ ์ธ์
์์ฑ
SessionMetadata metadata = createTestMetadata();
SessionCreationResult firstSession = sessionService.createSession(TEST_USER_ID, metadata);
@@ -167,8 +179,8 @@ void validateSession_NonExistentUser_Failure() {
}
@Test
- @DisplayName("์ธ์
๊ฒ์ฆ - lastActivity ์
๋ฐ์ดํธ")
- void validateSession_UpdatesLastActivity() throws InterruptedException {
+ @DisplayName("์ต๊ทผ ํ๋ํ ์ธ์
๊ฒ์ฆ์ lastActivity๋ฅผ ๋ค์ ์ฐ์ง ์์")
+ void validateSession_DoesNotUpdateRecentLastActivity() throws InterruptedException {
// Given
SessionMetadata metadata = createTestMetadata();
SessionCreationResult created = sessionService.createSession(TEST_USER_ID, metadata);
@@ -181,7 +193,7 @@ void validateSession_UpdatesLastActivity() throws InterruptedException {
// Then
assertTrue(result.isValid());
- assertThat(result.getSession().getLastActivity()).isGreaterThan(initialLastActivity);
+ assertThat(result.getSession().getLastActivity()).isEqualTo(initialLastActivity);
}
// ============ ์ธ์
ํ๋ ์
๋ฐ์ดํธ ํ
์คํธ ============
@@ -300,6 +312,42 @@ void getActiveSession_NoSession_ReturnsNull() {
// ============ ๋์์ฑ ๋ฐ ๋ฉํฐ ์ฌ์ฉ์ ํ
์คํธ ============
+ @Test
+ @DisplayName("๋์ผ ์ฌ์ฉ์์ ๋์ ์ธ์
์์ฑ์ ํ ๋ฌธ์๋ง ์ ์งํ๋ค")
+ void createSession_ConcurrentSameUser_KeepsSingleDocument() throws Exception {
+ int attempts = 20;
+ ExecutorService executor = Executors.newFixedThreadPool(attempts);
+ List> tasks = java.util.stream.IntStream.range(0, attempts)
+ .mapToObj(index -> (Callable) () ->
+ sessionService.createSession(TEST_USER_ID, createTestMetadata()))
+ .toList();
+
+ try {
+ List> futures = executor.invokeAll(tasks);
+ List issuedSessionIds = futures.stream()
+ .map(future -> {
+ try {
+ return future.get().getSessionId();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ })
+ .toList();
+
+ long storedSessions = mongoTemplate.count(
+ Query.query(Criteria.where("userId").is(TEST_USER_ID)),
+ Session.class
+ );
+ SessionData activeSession = sessionService.getActiveSession(TEST_USER_ID);
+
+ assertEquals(1L, storedSessions);
+ assertNotNull(activeSession);
+ assertTrue(issuedSessionIds.contains(activeSession.getSessionId()));
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
@Test
@DisplayName("์ฌ๋ฌ ์ฌ์ฉ์์ ๋
๋ฆฝ์ ์ธ ์ธ์
๊ด๋ฆฌ")
void multipleSessions_IndependentUsers() {
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java
index 55901111..bdf587d4 100644
--- a/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java
+++ b/apps/backend/src/test/java/com/ktb/chatapp/service/SessionServiceUnitTest.java
@@ -16,6 +16,8 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -35,17 +37,17 @@ class SessionServiceUnitTest {
private SessionService sessionService;
@Test
- @DisplayName("์ธ์
์์ฑ์ ๊ธฐ์กด ์ฌ์ฉ์ ์ธ์
์ ์ ๊ฑฐํ ๋ค ์ ์ธ์
์ ์ ์ฅํ๋ค")
- void createSession_RemovesExistingSessionsBeforeSave() {
+ @DisplayName("์ธ์
์์ฑ์ ์ฌ์ฉ์ ๊ธฐ์ค์ผ๋ก ์์์ ๊ต์ฒดํ๋ค")
+ void createSession_AtomicallyReplacesSessionByUserId() {
ArgumentCaptor sessionCaptor = ArgumentCaptor.forClass(Session.class);
- when(sessionStore.save(any(Session.class))).thenAnswer(invocation -> invocation.getArgument(0));
+ when(sessionStore.replaceByUserId(any(Session.class))).thenAnswer(invocation -> invocation.getArgument(0));
SessionCreationResult result = sessionService.createSession(
USER_ID,
new SessionMetadata("agent", "127.0.0.1", "device"));
- verify(sessionStore).deleteAll(USER_ID);
- verify(sessionStore).save(sessionCaptor.capture());
+ verify(sessionStore, never()).deleteAll(USER_ID);
+ verify(sessionStore).replaceByUserId(sessionCaptor.capture());
Session savedSession = sessionCaptor.getValue();
assertThat(result.getSessionId()).isEqualTo(savedSession.getSessionId());
assertThat(result.getExpiresIn()).isEqualTo(SessionService.SESSION_TTL_SEC);
@@ -56,7 +58,7 @@ void createSession_RemovesExistingSessionsBeforeSave() {
@Test
@DisplayName("์ธ์
์์ฑ ์ค ์ ์ฅ์ ์คํจ๋ RuntimeException์ผ๋ก ๋ํ๋๋ค")
void createSession_StoreFailure_ThrowsRuntimeException() {
- doThrow(new IllegalStateException("store down")).when(sessionStore).deleteAll(USER_ID);
+ doThrow(new IllegalStateException("store down")).when(sessionStore).replaceByUserId(any(Session.class));
RuntimeException exception = assertThrows(
RuntimeException.class,
@@ -65,6 +67,7 @@ void createSession_StoreFailure_ThrowsRuntimeException() {
assertThat(exception).hasMessage("์ธ์
์์ฑ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.");
assertThat(exception).hasRootCauseInstanceOf(IllegalStateException.class);
verify(sessionStore, never()).save(any(Session.class));
+ verify(sessionStore, never()).deleteAll(USER_ID);
}
@Test
@@ -123,6 +126,42 @@ void validateSession_StoreFailure_ReturnsValidationError() {
assertThat(result.getError()).isEqualTo("VALIDATION_ERROR");
}
+ @Test
+ @DisplayName("์ต๊ทผ ํ๋ํ ์ ํจ ์ธ์
์ ๊ฒ์ฆ ์ ๋ค์ ์ ์ฅํ์ง ์๋๋ค")
+ void validateSession_RecentlyActive_DoesNotWriteSession() {
+ Session session = Session.builder()
+ .userId(USER_ID)
+ .sessionId(SESSION_ID)
+ .lastActivity(Instant.now().toEpochMilli())
+ .expiresAt(Instant.now().plusSeconds(SessionService.SESSION_TTL_SEC))
+ .build();
+ when(sessionStore.findByUserId(USER_ID)).thenReturn(Optional.of(session));
+
+ SessionValidationResult result = sessionService.validateSession(USER_ID, SESSION_ID);
+
+ assertThat(result.isValid()).isTrue();
+ verify(sessionStore, never()).save(any(Session.class));
+ }
+
+ @Test
+ @DisplayName("30์ด ์ด์ ์ง๋ ์ ํจ ์ธ์
์ ๊ฒ์ฆ ์ ํ๋ ์๊ฐ์ ๊ฐฑ์ ํ๋ค")
+ void validateSession_StaleActivity_UpdatesSession() {
+ Session session = Session.builder()
+ .userId(USER_ID)
+ .sessionId(SESSION_ID)
+ .lastActivity(Instant.now().minusSeconds(31).toEpochMilli())
+ .expiresAt(Instant.now().plusSeconds(SessionService.SESSION_TTL_SEC))
+ .build();
+ when(sessionStore.findByUserId(USER_ID)).thenReturn(Optional.of(session));
+ when(sessionStore.touch(eq(USER_ID), eq(SESSION_ID), anyLong(), any(Instant.class)))
+ .thenReturn(Optional.of(session));
+
+ SessionValidationResult result = sessionService.validateSession(USER_ID, SESSION_ID);
+
+ assertThat(result.isValid()).isTrue();
+ verify(sessionStore).touch(eq(USER_ID), eq(SESSION_ID), anyLong(), any(Instant.class));
+ }
+
@Test
@DisplayName("ํ์ฑ ์ธ์
์กฐํ ์ค ์ ์ฅ์ ์คํจ๋ null๋ก ๋ฐํ๋๋ค")
void getActiveSession_StoreFailure_ReturnsNull() {
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/service/UserServiceTest.java b/apps/backend/src/test/java/com/ktb/chatapp/service/UserServiceTest.java
index 46595baa..d38c78bf 100644
--- a/apps/backend/src/test/java/com/ktb/chatapp/service/UserServiceTest.java
+++ b/apps/backend/src/test/java/com/ktb/chatapp/service/UserServiceTest.java
@@ -4,10 +4,14 @@
import com.ktb.chatapp.model.User;
import com.ktb.chatapp.repository.UserRepository;
import com.ktb.chatapp.storage.LocalStorage;
+import com.ktb.chatapp.storage.StoragePort;
+import com.ktb.chatapp.storage.StoredObjectMetadata;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.net.URI;
+import java.time.Duration;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
@@ -18,12 +22,18 @@
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.data.mongodb.core.FindAndModifyOptions;
+import org.springframework.data.mongodb.core.MongoOperations;
+import org.springframework.data.mongodb.core.query.Query;
+import org.springframework.data.mongodb.core.query.Update;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.mock;
@ExtendWith(MockitoExtension.class)
@DisplayName("UserService ๋จ์ ํ
์คํธ")
@@ -37,6 +47,9 @@ class UserServiceTest {
@Mock
private FileService fileService;
+ @Mock
+ private MongoOperations mongoOperations;
+
private UserService userService;
@TempDir
@@ -48,7 +61,8 @@ class UserServiceTest {
*/
@BeforeEach
void setUp() {
- userService = new UserService(userRepository, fileService, new LocalStorage(uploadDir.toString()));
+ userService = new UserService(
+ userRepository, fileService, new LocalStorage(uploadDir.toString()), mongoOperations);
ReflectionTestUtils.setField(userService, "maxProfileImageSize", 5242880L);
}
@@ -69,18 +83,76 @@ void uploadProfileImage_DeletesOldProfileImageFile() throws IOException {
.email(EMAIL)
.profileImage("profiles/old.jpg")
.build();
- when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(user));
when(fileService.storeFile(any(), eq("profiles"))).thenReturn("profiles/new.jpg");
+ when(mongoOperations.findAndModify(
+ any(Query.class), any(Update.class), any(FindAndModifyOptions.class), eq(User.class)))
+ .thenReturn(user);
MockMultipartFile file = new MockMultipartFile(
"file", "new.jpg", "image/jpeg", "new-image-bytes".getBytes());
ProfileImageResponse response = userService.uploadProfileImage(EMAIL, file);
assertThat(Files.exists(oldFile)).isFalse();
- assertThat(user.getProfileImage()).isEqualTo("profiles/new.jpg");
assertThat(response.getImageUrl()).isEqualTo("/api/files/profiles/new.jpg");
}
+ @Test
+ @DisplayName("ํ๋กํ ์ด๋ฏธ์ง DB ์ ์ฅ ์คํจ ์ ๊ธฐ์กด ํ์ผ์ ๋ณด์กดํ๊ณ ์ ํ์ผ์ ์ ๋ฆฌํ๋ค")
+ void uploadProfileImage_WhenDatabaseSaveFails_PreservesOldFileAndDeletesNewFile() throws IOException {
+ Path oldFile = createOldProfileImageFile("old-save-failure.jpg");
+ Path newFile = createOldProfileImageFile("new-save-failure.jpg");
+ User user = User.builder()
+ .id("user-1")
+ .email(EMAIL)
+ .profileImage("profiles/old-save-failure.jpg")
+ .build();
+ when(fileService.storeFile(any(), eq("profiles")))
+ .thenReturn("profiles/new-save-failure.jpg");
+ when(mongoOperations.findAndModify(
+ any(Query.class), any(Update.class), any(FindAndModifyOptions.class), eq(User.class)))
+ .thenThrow(new RuntimeException("mongo unavailable"));
+ MockMultipartFile file = new MockMultipartFile(
+ "file", "new.jpg", "image/jpeg", "new-image-bytes".getBytes());
+
+ assertThatThrownBy(() -> userService.uploadProfileImage(EMAIL, file))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessage("mongo unavailable");
+
+ assertThat(oldFile).exists();
+ assertThat(newFile).doesNotExist();
+ }
+
+ @Test
+ @DisplayName("ํ๋กํ ์ง์ ์
๋ก๋๋ ํ์ผ ๋ณธ๋ฌธ์ API๋ก ๋ฐ์ง ์๊ณ presign๊ณผ metadata ํ์ธ๋ง ์ํํ๋ค")
+ void directProfileUpload_UsesPresignAndMetadataOnly() {
+ StoragePort storagePort = mock(StoragePort.class);
+ UserService directService = new UserService(
+ userRepository, fileService, storagePort, mongoOperations);
+ ReflectionTestUtils.setField(directService, "maxProfileImageSize", 5242880L);
+ ReflectionTestUtils.setField(directService, "profilePresignTtl", Duration.ofMinutes(10));
+ User user = User.builder().id("user-1").email(EMAIL).build();
+ when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(user));
+ when(storagePort.presignUploadUrl(any(), eq("image/jpeg"), eq(Duration.ofMinutes(10))))
+ .thenReturn(Optional.of(URI.create("https://s3.example/upload")));
+
+ UserService.PreparedProfileUpload prepared = directService.prepareProfileImageUpload(
+ EMAIL, "profile.jpg", "image/jpeg", 1024L);
+
+ assertThat(prepared.uploadUrl()).isEqualTo("https://s3.example/upload");
+ assertThat(prepared.key()).startsWith("profiles/user-1_");
+
+ when(storagePort.stat(prepared.key()))
+ .thenReturn(Optional.of(new StoredObjectMetadata(1024L, "image/jpeg")));
+ when(mongoOperations.findAndModify(
+ any(Query.class), any(Update.class), any(FindAndModifyOptions.class), eq(User.class)))
+ .thenReturn(user);
+
+ ProfileImageResponse response = directService.completeProfileImageUpload(
+ EMAIL, prepared.key(), "profile.jpg", "image/jpeg", 1024L);
+
+ assertThat(response.getImageUrl()).isEqualTo("/api/files/" + prepared.key());
+ }
+
@Test
@DisplayName("ํ๋กํ ์ด๋ฏธ์ง ์ญ์ ์ ๊ธฐ์กด ์ด๋ฏธ์ง ์ค๋ฌผ ํ์ผ์ ์ญ์ ํ๋ค")
void deleteProfileImage_DeletesProfileImageFile() throws IOException {
@@ -90,11 +162,32 @@ void deleteProfileImage_DeletesProfileImageFile() throws IOException {
.email(EMAIL)
.profileImage("profiles/old2.jpg")
.build();
- when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(user));
+ when(mongoOperations.findAndModify(
+ any(Query.class), any(Update.class), any(FindAndModifyOptions.class), eq(User.class)))
+ .thenReturn(user);
userService.deleteProfileImage(EMAIL);
assertThat(Files.exists(oldFile)).isFalse();
- assertThat(user.getProfileImage()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("ํ๋กํ ์ด๋ฏธ์ง DB ์ญ์ ๋ฐ์ ์คํจ ์ ๊ธฐ์กด ํ์ผ์ ๋ณด์กดํ๋ค")
+ void deleteProfileImage_WhenDatabaseSaveFails_PreservesProfileImageFile() throws IOException {
+ Path oldFile = createOldProfileImageFile("delete-save-failure.jpg");
+ User user = User.builder()
+ .id("user-1")
+ .email(EMAIL)
+ .profileImage("profiles/delete-save-failure.jpg")
+ .build();
+ when(mongoOperations.findAndModify(
+ any(Query.class), any(Update.class), any(FindAndModifyOptions.class), eq(User.class)))
+ .thenThrow(new RuntimeException("mongo unavailable"));
+
+ assertThatThrownBy(() -> userService.deleteProfileImage(EMAIL))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessage("mongo unavailable");
+
+ assertThat(oldFile).exists();
}
}
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/storage/LocalStorageTest.java b/apps/backend/src/test/java/com/ktb/chatapp/storage/LocalStorageTest.java
index 0b4c3ed3..d3ce2418 100644
--- a/apps/backend/src/test/java/com/ktb/chatapp/storage/LocalStorageTest.java
+++ b/apps/backend/src/test/java/com/ktb/chatapp/storage/LocalStorageTest.java
@@ -52,8 +52,10 @@ void open_returnsResourceForStoredKey() throws Exception {
Optional resource = localStorage.open("chat/photo.jpg");
assertThat(resource).isPresent();
- assertThat(StreamUtils.copyToString(resource.get().getInputStream(), StandardCharsets.UTF_8))
- .isEqualTo("hello");
+ try (InputStream inputStream = resource.get().getInputStream()) {
+ assertThat(StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8))
+ .isEqualTo("hello");
+ }
}
@Test
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/storage/S3StorageTest.java b/apps/backend/src/test/java/com/ktb/chatapp/storage/S3StorageTest.java
new file mode 100644
index 00000000..2bd918dd
--- /dev/null
+++ b/apps/backend/src/test/java/com/ktb/chatapp/storage/S3StorageTest.java
@@ -0,0 +1,110 @@
+package com.ktb.chatapp.storage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayInputStream;
+import java.net.URI;
+import java.net.URL;
+import java.time.Duration;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.http.ContentDisposition;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
+import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
+import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
+import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
+
+@DisplayName("S3Storage ๋จ์ ํ
์คํธ")
+class S3StorageTest {
+
+ private final S3Client s3Client = mock(S3Client.class);
+ private final S3Presigner presigner = mock(S3Presigner.class);
+ private final S3Storage storage = new S3Storage(
+ s3Client, presigner, "profile-bucket", "loadtest/run-1", Duration.ofMinutes(10));
+
+ @Test
+ void profileUploadUsesPrefixContentTypeAndImmutableCache() {
+ byte[] content = "image".getBytes();
+
+ StoredObject result = storage.put(
+ new ByteArrayInputStream(content), "profiles/avatar.jpg", "image/jpeg", content.length);
+
+ ArgumentCaptor request = ArgumentCaptor.forClass(PutObjectRequest.class);
+ verify(s3Client).putObject(request.capture(), any(RequestBody.class));
+ assertThat(request.getValue().bucket()).isEqualTo("profile-bucket");
+ assertThat(request.getValue().key()).isEqualTo("loadtest/run-1/profiles/avatar.jpg");
+ assertThat(request.getValue().contentType()).isEqualTo("image/jpeg");
+ assertThat(request.getValue().cacheControl()).isEqualTo(S3Storage.PROFILE_CACHE_CONTROL);
+ assertThat(result.key()).isEqualTo("profiles/avatar.jpg");
+ }
+
+ @Test
+ void chatUploadUsesBoundedPrivateCache() {
+ byte[] content = "image".getBytes();
+
+ storage.put(
+ new ByteArrayInputStream(content),
+ "chat/user-1/file.jpg",
+ "image/jpeg",
+ content.length);
+
+ ArgumentCaptor request = ArgumentCaptor.forClass(PutObjectRequest.class);
+ verify(s3Client).putObject(request.capture(), any(RequestBody.class));
+ assertThat(request.getValue().cacheControl()).isEqualTo(S3Storage.CHAT_PREVIEW_CACHE_CONTROL);
+ }
+
+ @Test
+ void deleteUsesSamePhysicalPrefix() {
+ storage.delete("profiles/avatar.jpg");
+
+ ArgumentCaptor request = ArgumentCaptor.forClass(DeleteObjectRequest.class);
+ verify(s3Client).deleteObject(request.capture());
+ assertThat(request.getValue().key()).isEqualTo("loadtest/run-1/profiles/avatar.jpg");
+ }
+
+ @Test
+ void offloadUrlReturnsPresignedGetUrl() throws Exception {
+ PresignedGetObjectRequest signed = mock(PresignedGetObjectRequest.class);
+ when(signed.url()).thenReturn(new URL("https://example.test/signed-profile"));
+ when(presigner.presignGetObject(any(GetObjectPresignRequest.class))).thenReturn(signed);
+
+ URI result = storage.offloadUrl(
+ "profiles/avatar.jpg",
+ Duration.ofMinutes(10),
+ ContentDisposition.inline().filename("avatar.jpg").build())
+ .orElseThrow();
+
+ assertThat(result).isEqualTo(URI.create("https://example.test/signed-profile"));
+ ArgumentCaptor request =
+ ArgumentCaptor.forClass(GetObjectPresignRequest.class);
+ verify(presigner).presignGetObject(request.capture());
+ assertThat(request.getValue().getObjectRequest().responseCacheControl())
+ .isEqualTo(S3Storage.CHAT_PREVIEW_CACHE_CONTROL);
+ }
+
+ @Test
+ void directUploadSignsContentTypeAndBoundedPrivateCache() throws Exception {
+ PresignedPutObjectRequest signed = mock(PresignedPutObjectRequest.class);
+ when(signed.url()).thenReturn(new URL("https://example.test/signed-upload"));
+ when(presigner.presignPutObject(any(PutObjectPresignRequest.class))).thenReturn(signed);
+
+ storage.presignUploadUrl("chat/user-1/file.jpg", "image/jpeg", Duration.ofMinutes(10));
+
+ ArgumentCaptor request =
+ ArgumentCaptor.forClass(PutObjectPresignRequest.class);
+ verify(presigner).presignPutObject(request.capture());
+ assertThat(request.getValue().putObjectRequest().contentType()).isEqualTo("image/jpeg");
+ assertThat(request.getValue().putObjectRequest().cacheControl())
+ .isEqualTo(S3Storage.CHAT_PREVIEW_CACHE_CONTROL);
+ }
+}
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/ConnectedUsersTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/ConnectedUsersTest.java
new file mode 100644
index 00000000..b077a01d
--- /dev/null
+++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/ConnectedUsersTest.java
@@ -0,0 +1,41 @@
+package com.ktb.chatapp.websocket.socketio;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class ConnectedUsersTest {
+
+ @Mock private ChatDataStore store;
+
+ @Test
+ void refreshesLeaseOnlyForCurrentOwner() {
+ SocketUser current = new SocketUser("user-1", "name", "session-1", "socket-1");
+ String key = "conn_users:userid:user-1";
+ when(store.get(key, SocketUser.class)).thenReturn(Optional.of(current));
+ when(store.refresh(key)).thenReturn(true);
+
+ assertThat(new ConnectedUsers(store).refreshIfCurrent(current)).isTrue();
+ verify(store).refresh(key);
+ }
+
+ @Test
+ void staleSocketCannotRefreshNewOwnersLease() {
+ SocketUser stale = new SocketUser("user-1", "name", "session-1", "socket-1");
+ SocketUser current = new SocketUser("user-1", "name", "session-2", "socket-2");
+ String key = "conn_users:userid:user-1";
+ when(store.get(key, SocketUser.class)).thenReturn(Optional.of(current));
+
+ assertThat(new ConnectedUsers(store).refreshIfCurrent(stale)).isFalse();
+ verify(store, never()).refresh(key);
+ }
+}
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/PresenceLeaseRefresherTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/PresenceLeaseRefresherTest.java
new file mode 100644
index 00000000..e9df5472
--- /dev/null
+++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/PresenceLeaseRefresherTest.java
@@ -0,0 +1,72 @@
+package com.ktb.chatapp.websocket.socketio;
+
+import java.time.Duration;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import org.mockito.Mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.corundumstudio.socketio.SocketIOClient;
+import com.corundumstudio.socketio.SocketIOServer;
+import com.corundumstudio.socketio.listener.PongListener;
+
+@ExtendWith(MockitoExtension.class)
+class PresenceLeaseRefresherTest {
+
+ @Mock
+ private SocketIOServer socketIOServer;
+ @Mock
+ private ConnectedUsers connectedUsers;
+ @Mock
+ private SocketIOClient client;
+
+ @Test
+ void pongRefreshesOnlyAuthenticatedSocketPresence() {
+ SocketUser user = new SocketUser("user-1", "name", "session-1", "socket-1");
+ ArgumentCaptor listener = ArgumentCaptor.forClass(PongListener.class);
+ new PresenceLeaseRefresher(socketIOServer, connectedUsers, Duration.ZERO);
+ verify(socketIOServer).addPongListener(listener.capture());
+
+ when(client.get("user")).thenReturn(user);
+ when(connectedUsers.refreshIfCurrent(user)).thenReturn(true);
+ listener.getValue().onPong(client);
+
+ verify(connectedUsers).refreshIfCurrent(user);
+ verify(client).set(eq(PresenceLeaseRefresher.LAST_PRESENCE_REFRESH_AT), anyLong());
+ }
+
+ @Test
+ void pongWithoutAuthenticatedUserDoesNotRefreshPresence() {
+ ArgumentCaptor listener = ArgumentCaptor.forClass(PongListener.class);
+ new PresenceLeaseRefresher(socketIOServer, connectedUsers, Duration.ZERO);
+ verify(socketIOServer).addPongListener(listener.capture());
+
+ when(client.get("user")).thenReturn(null);
+ listener.getValue().onPong(client);
+
+ verify(connectedUsers, never()).refreshIfCurrent(org.mockito.ArgumentMatchers.any());
+ }
+
+ @Test
+ void pongWithinRefreshIntervalDoesNotTouchPresenceStore() {
+ SocketUser user = new SocketUser("user-1", "name", "session-1", "socket-1");
+ ArgumentCaptor listener = ArgumentCaptor.forClass(PongListener.class);
+ new PresenceLeaseRefresher(socketIOServer, connectedUsers, Duration.ofMinutes(1));
+ verify(socketIOServer).addPongListener(listener.capture());
+
+ when(client.get("user")).thenReturn(user);
+ when(client.get(PresenceLeaseRefresher.LAST_PRESENCE_REFRESH_AT))
+ .thenReturn(System.currentTimeMillis());
+
+ listener.getValue().onPong(client);
+
+ verify(connectedUsers, never()).refreshIfCurrent(user);
+ }
+}
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStoreIntegrationTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStoreIntegrationTest.java
new file mode 100644
index 00000000..8ac4b882
--- /dev/null
+++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/RedisChatDataStoreIntegrationTest.java
@@ -0,0 +1,76 @@
+package com.ktb.chatapp.websocket.socketio;
+
+import java.util.Set;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.redisson.Redisson;
+import org.redisson.api.RedissonClient;
+import org.redisson.config.Config;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@Testcontainers
+class RedisChatDataStoreIntegrationTest {
+
+ @Container
+ static final GenericContainer> REDIS = new GenericContainer<>("redis:8.8.0-alpine")
+ .withExposedPorts(6379);
+
+ private static RedissonClient firstClient;
+ private static RedissonClient secondClient;
+ private static RedisChatDataStore firstStore;
+ private static RedisChatDataStore secondStore;
+
+ @BeforeAll
+ static void setUp() {
+ firstClient = createClient();
+ secondClient = createClient();
+ firstStore = new RedisChatDataStore(firstClient);
+ secondStore = new RedisChatDataStore(secondClient);
+ }
+
+ @AfterAll
+ static void tearDown() {
+ if (firstClient != null) firstClient.shutdown();
+ if (secondClient != null) secondClient.shutdown();
+ }
+
+ @Test
+ void connectionOwnershipIsSharedAndConditionallyDeletedAcrossInstances() {
+ String key = "conn_users:userid:integration-user";
+ SocketUser first = new SocketUser("integration-user", "name", "session-1", "socket-1");
+ SocketUser second = new SocketUser("integration-user", "name", "session-2", "socket-2");
+
+ firstStore.set(key, first);
+ assertThat(secondStore.getAndSet(key, second, SocketUser.class)).isEqualTo(first);
+ assertThat(firstStore.compareAndDelete(key, first)).isFalse();
+ assertThat(firstStore.compareAndDelete(key, second)).isTrue();
+ assertThat(secondStore.get(key, SocketUser.class)).isEmpty();
+ }
+
+ @Test
+ void roomSetUpdatesAreVisibleAcrossInstances() {
+ String key = "userroom:roomids:integration-user";
+
+ firstStore.addToSet(key, "room-1");
+ secondStore.addToSet(key, "room-2");
+ assertThat(firstStore.getSet(key)).isEqualTo(Set.of("room-1", "room-2"));
+ assertThat(secondStore.setContains(key, "room-1")).isTrue();
+ assertThat(secondStore.setContains(key, "missing-room")).isFalse();
+
+ secondStore.removeFromSet(key, "room-1");
+ assertThat(firstStore.getSet(key)).containsExactly("room-2");
+ firstStore.delete(key);
+ }
+
+ private static RedissonClient createClient() {
+ Config config = new Config();
+ config.useSingleServer().setAddress(
+ "redis://" + REDIS.getHost() + ":" + REDIS.getMappedPort(6379));
+ return Redisson.create(config);
+ }
+}
diff --git a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/SocketIOEventListenerTest.java b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/SocketIOEventListenerTest.java
index 42453f6a..5b6aa198 100644
--- a/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/SocketIOEventListenerTest.java
+++ b/apps/backend/src/test/java/com/ktb/chatapp/websocket/socketio/SocketIOEventListenerTest.java
@@ -2,10 +2,12 @@
import com.corundumstudio.socketio.BroadcastOperations;
import com.corundumstudio.socketio.SocketIOServer;
-import com.ktb.chatapp.dto.RoomResponse;
+import com.ktb.chatapp.dto.RoomListItemResponse;
+import com.ktb.chatapp.event.RoomActivitiesEvent;
import com.ktb.chatapp.event.RoomActivityEvent;
import com.ktb.chatapp.event.RoomUpdatedEvent;
import com.ktb.chatapp.event.SessionEndedEvent;
+import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -21,6 +23,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -70,7 +73,7 @@ void handleSessionEndedEvent_swallowsBroadcastException() {
@Test
void handleRoomUpdatedEvent_sendsRoomUpdatedToRoomList() {
- RoomResponse roomResponse = RoomResponse.builder()
+ RoomListItemResponse roomResponse = RoomListItemResponse.builder()
.id("room-1")
.name("Updated room")
.build();
@@ -96,4 +99,32 @@ void handleRoomActivityEvent_sendsRecentMessageCountToRoomList() {
assertEquals("room-1", payload.get("_id"));
assertEquals(12, payload.get("recentMessageCount"));
}
+
+ @Test
+ void handleRoomActivitiesEvent_sendsBatchWithSingleRoomListLookup() {
+ RoomActivitiesEvent event = new RoomActivitiesEvent(this, Map.of(
+ "room-1", 12,
+ "room-2", 3
+ ));
+ when(socketIOServer.getRoomOperations("room-list")).thenReturn(roomListOperations);
+
+ listener.handleRoomActivitiesEvent(event);
+
+ ArgumentCaptor