diff --git a/chat_service/build.gradle b/chat_service/build.gradle index 19b26ef..607a5e7 100644 --- a/chat_service/build.gradle +++ b/chat_service/build.gradle @@ -20,10 +20,16 @@ repositories { dependencies { // Spring Web implementation 'org.springframework.boot:spring-boot-starter-web' + // WebSocket + implementation 'org.springframework.boot:spring-boot-starter-websocket' // JPA implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // Validation implementation 'org.springframework.boot:spring-boot-starter-validation' + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // Session Redis + implementation 'org.springframework.session:spring-session-data-redis' // H2 runtimeOnly 'com.h2database:h2' // PostgreSQL diff --git a/chat_service/src/main/java/com/synapse/chat_service/common/annotation/RedisOperation.java b/chat_service/src/main/java/com/synapse/chat_service/common/annotation/RedisOperation.java new file mode 100644 index 0000000..46c956f --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/common/annotation/RedisOperation.java @@ -0,0 +1,29 @@ +package com.synapse.chat_service.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Redis 작업에 대한 공통 예외 처리를 위한 어노테이션 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RedisOperation { + + /** + * 작업 설명 (로깅용) + */ + String value() default ""; + + /** + * 예외 발생 시 기본값 반환 여부 + */ + boolean returnDefaultOnError() default false; + + /** + * 예외를 다시 던질지 여부 + */ + boolean rethrowException() default true; +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/common/aspect/RedisOperationAspect.java b/chat_service/src/main/java/com/synapse/chat_service/common/aspect/RedisOperationAspect.java new file mode 100644 index 0000000..db6fa11 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/common/aspect/RedisOperationAspect.java @@ -0,0 +1,60 @@ +package com.synapse.chat_service.common.aspect; + +import com.synapse.chat_service.common.annotation.RedisOperation; +import com.synapse.chat_service.exception.commonexception.RedisOperationException; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.stereotype.Component; + +@Slf4j +@Aspect +@Component +public class RedisOperationAspect { + + @Around("@annotation(redisOperation)") + public Object handleRedisOperation(ProceedingJoinPoint joinPoint, RedisOperation redisOperation) throws Throwable { + String methodName = joinPoint.getSignature().getName(); + String className = joinPoint.getTarget().getClass().getSimpleName(); + String operation = redisOperation.value().isEmpty() ? methodName : redisOperation.value(); + + try { + Object result = joinPoint.proceed(); + log.debug("Redis 작업 성공: {}.{}", className, operation); + return result; + + } catch (Exception e) { + log.error("Redis 작업 실패: {}.{} - 원인: {}", className, operation, e.getMessage(), e); + + if (redisOperation.returnDefaultOnError()) { + log.debug("Redis 작업 실패 시 기본값 반환: {}.{}", className, operation); + MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); + return getDefaultValue(methodSignature.getReturnType()); + } + + if (redisOperation.rethrowException()) { + // 예외 체이닝을 통해 원본 예외의 스택 트레이스 보존 + String operationDescription = String.format("%s.%s", className, operation); + throw RedisOperationException.operationError(operationDescription, e); + } + + log.debug("Redis 작업 실패 시 null 반환: {}.{}", className, operation); + return null; + } + } + + private Object getDefaultValue(Class returnType) { + if (returnType == boolean.class || returnType == Boolean.class) { + return false; + } + if (returnType == int.class || returnType == Integer.class) { + return 0; + } + if (returnType == long.class || returnType == Long.class) { + return 0L; + } + return null; + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/common/util/RedisTypeConverter.java b/chat_service/src/main/java/com/synapse/chat_service/common/util/RedisTypeConverter.java new file mode 100644 index 0000000..93cd6c2 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/common/util/RedisTypeConverter.java @@ -0,0 +1,68 @@ +package com.synapse.chat_service.common.util; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisTypeConverter { + + private final ObjectMapper objectMapper; + + /** + * Redis에서 조회한 원시 값을 지정된 타입으로 안전하게 변환 + * + * @param rawValue Redis에서 조회한 원시 값 + * @param targetType 변환할 대상 타입 + * @return 변환된 객체 (실패 시 null) + */ + public T convertValue(Object rawValue, Class targetType) { + if (rawValue == null) { + return null; + } + + try { + // 이미 올바른 타입인 경우 + if (targetType.isInstance(rawValue)) { + return targetType.cast(rawValue); + } + + // ObjectMapper를 사용한 타입 변환 + return objectMapper.convertValue(rawValue, targetType); + + } catch (Exception e) { + log.warn("Redis 값 타입 변환 실패: rawValue={}, targetType={}", + rawValue.getClass().getSimpleName(), targetType.getSimpleName(), e); + return null; + } + } + + /** + * String 타입으로 안전하게 변환 + */ + public String convertToString(Object rawValue) { + return convertValue(rawValue, String.class); + } + + /** + * 객체를 byte 배열로 변환 (Redis 트랜잭션에서 사용) + * + * @param value 변환할 객체 + * @return byte 배열 (실패 시 빈 배열) + */ + public byte[] convertToBytes(Object value) { + if (value == null) { + return new byte[0]; + } + + try { + return objectMapper.writeValueAsBytes(value); + } catch (Exception e) { + log.warn("객체를 byte 배열로 변환 실패: value={}", value.getClass().getSimpleName(), e); + return new byte[0]; + } + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/config/ObjectMapperConfig.java b/chat_service/src/main/java/com/synapse/chat_service/config/ObjectMapperConfig.java new file mode 100644 index 0000000..732e57b --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/config/ObjectMapperConfig.java @@ -0,0 +1,30 @@ +package com.synapse.chat_service.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +/** + * ObjectMapper 설정 클래스 + * + * 보안 고려사항: + * - Default Typing은 안전하지 않은 역직렬화 취약점을 유발할 수 있어 비활성화 + * - 다형성 타입 처리가 필요한 경우 @JsonTypeInfo와 @JsonSubTypes 어노테이션을 + * - 해당 클래스에 직접 사용하는 것을 권장 + */ +@Configuration +public class ObjectMapperConfig { + @Bean + public ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + return objectMapper; + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/config/RedisConfig.java b/chat_service/src/main/java/com/synapse/chat_service/config/RedisConfig.java new file mode 100644 index 0000000..86aec0b --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/config/RedisConfig.java @@ -0,0 +1,27 @@ +package com.synapse.chat_service.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@RequiredArgsConstructor +public class RedisConfig { + + private final ObjectMapper objectMapper; + + @Bean + public RedisTemplate objectRedisTemplate(RedisConnectionFactory connectionFactory) { + var template = new RedisTemplate(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper)); + + return template; + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/config/WebSocketConfig.java b/chat_service/src/main/java/com/synapse/chat_service/config/WebSocketConfig.java new file mode 100644 index 0000000..afb0dba --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/config/WebSocketConfig.java @@ -0,0 +1,42 @@ +package com.synapse.chat_service.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + // 클라이언트에서 메시지를 받을 때 사용할 prefix + config.setApplicationDestinationPrefixes("/app"); + + // 클라이언트가 구독할 때 사용할 prefix + config.enableSimpleBroker("/topic", "/queue") + .setTaskScheduler(heartbeatScheduler()) + .setHeartbeatValue(new long[] {10000, 10000}); + + // AI 응답을 특정 사용자에게 보낼 때 사용할 prefix + config.setUserDestinationPrefix("/ai"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws") + .setAllowedOriginPatterns("*"); // CORS 설정: 모든 도메인 허용 (개발 환경) + } + + @Bean + public ThreadPoolTaskScheduler heartbeatScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("ws-heartbeat-"); + return scheduler; + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/controller/AiChatController.java b/chat_service/src/main/java/com/synapse/chat_service/controller/AiChatController.java new file mode 100644 index 0000000..b5de7c1 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/controller/AiChatController.java @@ -0,0 +1,77 @@ +package com.synapse.chat_service.controller; + +import com.synapse.chat_service.dto.response.MessageResponse; +import com.synapse.chat_service.service.MessageService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/ai-chat") +@RequiredArgsConstructor +public class AiChatController { + + private final MessageService messageService; + + @GetMapping("/history") + public ResponseEntity> getMyAiChatHistory( + @RequestHeader("X-User-Id") Long userId + ) { + List response = messageService.getMessagesByUserId(userId); + return ResponseEntity.ok(response); + } + + @GetMapping("/history/paging") + public ResponseEntity> getMyAiChatHistoryWithPaging( + @RequestHeader("X-User-Id") Long userId, + @PageableDefault(size = 50, sort = "createdDate", direction = Sort.Direction.ASC) Pageable pageable + ) { + Page response = messageService.getMessagesByUserIdWithPaging(userId, pageable); + return ResponseEntity.ok(response); + } + + @GetMapping("/history/recent") + public ResponseEntity> getMyAiChatHistoryRecentFirst( + @RequestHeader("X-User-Id") Long userId, + @PageableDefault(size = 50, sort = "createdDate", direction = Sort.Direction.DESC) Pageable pageable + ) { + Page response = messageService.getMessagesRecentFirst(userId, pageable); + return ResponseEntity.ok(response); + } + + @GetMapping("/search") + public ResponseEntity> searchMyAiChatHistory( + @RequestHeader("X-User-Id") Long userId, + @RequestParam String keyword + ) { + List response = messageService.searchMessages(userId, keyword); + return ResponseEntity.ok(response); + } + + @GetMapping("/stats") + public ResponseEntity getMyAiChatStats( + @RequestHeader("X-User-Id") Long userId + ) { + long messageCount = messageService.getMessageCountByUserId(userId); + UUID conversationId = messageService.getConversationId(userId); + + AiChatStatsResponse response = new AiChatStatsResponse( + conversationId, + messageCount + ); + + return ResponseEntity.ok(response); + } + + public record AiChatStatsResponse( + UUID conversationId, + long totalMessageCount + ) {} +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/controller/ChatRoomController.java b/chat_service/src/main/java/com/synapse/chat_service/controller/ChatRoomController.java deleted file mode 100644 index cb17044..0000000 --- a/chat_service/src/main/java/com/synapse/chat_service/controller/ChatRoomController.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.synapse.chat_service.controller; - -import com.synapse.chat_service.dto.request.ChatRoomRequest; -import com.synapse.chat_service.dto.response.ChatRoomResponse; -import com.synapse.chat_service.service.ChatRoomService; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.web.PageableDefault; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.UUID; - -@RestController -@RequestMapping("/api/v1/chat-rooms") -@RequiredArgsConstructor -public class ChatRoomController { - - private final ChatRoomService chatRoomService; - - /** - * 채팅방 생성 - */ - @PostMapping - public ResponseEntity createChatRoom( - @Valid @RequestBody ChatRoomRequest.Create request - ) { - ChatRoomResponse.Detail response = chatRoomService.createChatRoom(request); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } - - /** - * 채팅방 단건 조회 - */ - @GetMapping("/{chatRoomId}") - public ResponseEntity getChatRoom( - @PathVariable UUID chatRoomId - ) { - ChatRoomResponse.Detail response = chatRoomService.getChatRoom(chatRoomId); - return ResponseEntity.ok(response); - } - - /** - * 사용자별 채팅방 목록 조회 (페이징) - */ - @GetMapping - public ResponseEntity> getChatRoomsByUserId( - @RequestParam Long userId, - @PageableDefault(size = 20, sort = "createdDate", direction = Sort.Direction.DESC) Pageable pageable - ) { - Page response = chatRoomService.getChatRoomsByUserId(userId, pageable); - return ResponseEntity.ok(response); - } - - /** - * 채팅방 제목 검색 - */ - @GetMapping("/search") - public ResponseEntity> searchChatRooms( - @RequestParam Long userId, - @RequestParam String keyword - ) { - List response = chatRoomService.searchChatRooms(userId, keyword); - return ResponseEntity.ok(response); - } - - /** - * 채팅방 수정 - */ - @PutMapping("/{chatRoomId}") - public ResponseEntity updateChatRoom( - @PathVariable UUID chatRoomId, - @Valid @RequestBody ChatRoomRequest.Update request - ) { - ChatRoomResponse.Detail response = chatRoomService.updateChatRoom(chatRoomId, request); - return ResponseEntity.ok(response); - } - - /** - * 채팅방 삭제 - */ - @DeleteMapping("/{chatRoomId}") - public ResponseEntity deleteChatRoom(@PathVariable UUID chatRoomId) { - chatRoomService.deleteChatRoom(chatRoomId); - return ResponseEntity.noContent().build(); - } -} diff --git a/chat_service/src/main/java/com/synapse/chat_service/controller/MessageController.java b/chat_service/src/main/java/com/synapse/chat_service/controller/MessageController.java index c0ed50c..9974096 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/controller/MessageController.java +++ b/chat_service/src/main/java/com/synapse/chat_service/controller/MessageController.java @@ -5,17 +5,10 @@ import com.synapse.chat_service.service.MessageService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.List; -import java.util.UUID; - @RestController @RequestMapping("/api/v1/messages") @RequiredArgsConstructor @@ -23,9 +16,6 @@ public class MessageController { private final MessageService messageService; - /** - * 메시지 생성 - */ @PostMapping public ResponseEntity createMessage( @Valid @RequestBody MessageRequest.Create request @@ -34,9 +24,6 @@ public ResponseEntity createMessage( return ResponseEntity.status(HttpStatus.CREATED).body(response); } - /** - * 메시지 단건 조회 - */ @GetMapping("/{messageId}") public ResponseEntity getMessage( @PathVariable Long messageId @@ -45,56 +32,6 @@ public ResponseEntity getMessage( return ResponseEntity.ok(response); } - /** - * 채팅방별 메시지 목록 조회 (시간순 정렬) - */ - @GetMapping("/chat-room/{chatRoomId}") - public ResponseEntity> getMessagesByChatRoomId( - @PathVariable UUID chatRoomId - ) { - List response = messageService.getMessagesByChatRoomId(chatRoomId); - return ResponseEntity.ok(response); - } - - /** - * 채팅방별 메시지 목록 조회 (페이징, 시간순 정렬) - */ - @GetMapping("/chat-room/{chatRoomId}/paging") - public ResponseEntity> getMessagesByChatRoomIdWithPaging( - @PathVariable UUID chatRoomId, - @PageableDefault(size = 50, sort = "createdDate", direction = Sort.Direction.ASC) Pageable pageable - ) { - Page response = messageService.getMessagesByChatRoomIdWithPaging(chatRoomId, pageable); - return ResponseEntity.ok(response); - } - - /** - * 채팅방별 메시지 목록 조회 (페이징, 최신순 정렬) - */ - @GetMapping("/chat-room/{chatRoomId}/recent") - public ResponseEntity> getMessagesRecentFirst( - @PathVariable UUID chatRoomId, - @PageableDefault(size = 50, sort = "createdDate", direction = Sort.Direction.DESC) Pageable pageable - ) { - Page response = messageService.getMessagesRecentFirst(chatRoomId, pageable); - return ResponseEntity.ok(response); - } - - /** - * 메시지 내용 검색 - */ - @GetMapping("/chat-room/{chatRoomId}/search") - public ResponseEntity> searchMessages( - @PathVariable UUID chatRoomId, - @RequestParam String keyword - ) { - List response = messageService.searchMessages(chatRoomId, keyword); - return ResponseEntity.ok(response); - } - - /** - * 메시지 삭제 - */ @DeleteMapping("/{messageId}") public ResponseEntity deleteMessage(@PathVariable Long messageId) { messageService.deleteMessage(messageId); diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/ChatRoom.java b/chat_service/src/main/java/com/synapse/chat_service/domain/entity/ChatRoom.java deleted file mode 100644 index c9dcd69..0000000 --- a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/ChatRoom.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.synapse.chat_service.domain.entity; - -import jakarta.persistence.*; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -import com.synapse.chat_service.exception.commonexception.ValidException; -import com.synapse.chat_service.exception.domain.ExceptionType; - -import com.synapse.chat_service.domain.common.BaseTimeEntity; - -@Entity -@Table(name = "chat_rooms") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class ChatRoom extends BaseTimeEntity { - - @Id - @GeneratedValue(strategy = GenerationType.UUID) - @Column(name = "chat_room_id", columnDefinition = "UUID") - private UUID id; - - @NotNull - @Column(name = "user_id", nullable = false) - private Long userId; - - @NotBlank - @Column(name = "title", nullable = false, length = 255) - private String title; - - @OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL, orphanRemoval = true) - private List messages = new ArrayList<>(); - - @Builder - public ChatRoom(Long userId, String title) { - this.userId = userId; - this.title = title; - } - - public void updateTitle(String newTitle) { - validateTitle(newTitle); - this.title = newTitle.trim(); - } - - private void validateTitle(String title) { - if (title == null || title.trim().isEmpty()) { - throw new ValidException(ExceptionType.INVALID_INPUT_VALUE, "채팅방 제목은 비어있을 수 없습니다."); - } - if (title.trim().length() > 255) { - throw new ValidException(ExceptionType.INVALID_INPUT_VALUE, "채팅방 제목은 255자를 초과할 수 없습니다."); - } - } -} diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/ChatUsage.java b/chat_service/src/main/java/com/synapse/chat_service/domain/entity/ChatUsage.java index 4a78234..20e5563 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/ChatUsage.java +++ b/chat_service/src/main/java/com/synapse/chat_service/domain/entity/ChatUsage.java @@ -11,6 +11,10 @@ import lombok.Getter; import lombok.NoArgsConstructor; +/** + * 사용자의 채팅 사용량 및 구독 정보를 관리하는 엔티티 + * MSA 원칙에 따라 외부 서비스의 userId만을 참조하여 사용자를 식별합니다. + */ @Entity @Table(name = "chat_usages") @Getter diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/Conversation.java b/chat_service/src/main/java/com/synapse/chat_service/domain/entity/Conversation.java new file mode 100644 index 0000000..aa2c559 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/domain/entity/Conversation.java @@ -0,0 +1,43 @@ +package com.synapse.chat_service.domain.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import com.synapse.chat_service.domain.common.BaseTimeEntity; + +/** + * 사용자와 AI 간의 1:1 대화를 나타내는 엔티티 + * 각 사용자는 하나의 대화(Conversation)를 가지며, 이는 자동으로 생성됩니다. + * MSA 원칙에 따라 외부 서비스의 userId만을 참조하여 사용자 정보를 식별합니다. + */ +@Entity +@Table(name = "conversations") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Conversation extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "conversation_id", columnDefinition = "UUID") + private UUID id; + + @NotNull + @Column(name = "user_id", nullable = false, unique = true) + private Long userId; + + @OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true) + private List messages = new ArrayList<>(); + + @Builder + public Conversation(Long userId) { + this.userId = userId; + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/Message.java b/chat_service/src/main/java/com/synapse/chat_service/domain/entity/Message.java index ccf80f9..4f3b008 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/Message.java +++ b/chat_service/src/main/java/com/synapse/chat_service/domain/entity/Message.java @@ -24,9 +24,9 @@ public class Message extends BaseTimeEntity { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "chat_room_id", nullable = false) + @JoinColumn(name = "conversation_id", nullable = false) @NotNull - private ChatRoom chatRoom; + private Conversation conversation; @Enumerated(EnumType.STRING) @Column(name = "sender_type", nullable = false) @@ -38,9 +38,9 @@ public class Message extends BaseTimeEntity { private String content; @Builder - public Message(ChatRoom chatRoom, SenderType senderType, String content) { + public Message(Conversation conversation, SenderType senderType, String content) { validateContent(content); - this.chatRoom = chatRoom; + this.conversation = conversation; this.senderType = senderType; this.content = content; } diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/User.java b/chat_service/src/main/java/com/synapse/chat_service/domain/entity/User.java deleted file mode 100644 index c7a7c89..0000000 --- a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/User.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.synapse.chat_service.domain.entity; - -import com.synapse.chat_service.domain.common.BaseTimeEntity; - -import jakarta.persistence.*; -import jakarta.validation.constraints.NotBlank; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.ArrayList; -import java.util.List; - -@Entity -@Table(name = "users") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class User extends BaseTimeEntity { - - @Id - @Column(name = "id", columnDefinition = "BIGINT") - private Long id; - - @NotBlank - @Column(name = "username", nullable = false, unique = true, length = 50) - private String username; - - @NotBlank - @Column(name = "email", nullable = false, unique = true, length = 100) - private String email; - - @OneToMany(mappedBy = "userId", cascade = CascadeType.ALL, orphanRemoval = true) - private List chatRooms = new ArrayList<>(); - - @Builder - public User(Long id, String username, String email) { - this.id = id; - this.username = username; - this.email = email; - } -} diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ChatRoomRepository.java b/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ChatRoomRepository.java deleted file mode 100644 index 7bd1108..0000000 --- a/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ChatRoomRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.synapse.chat_service.domain.repository; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import com.synapse.chat_service.domain.entity.ChatRoom; - -import java.util.List; -import java.util.UUID; - -@Repository -public interface ChatRoomRepository extends JpaRepository { - - Page findByUserIdOrderByCreatedDateDesc(Long userId, Pageable pageable); - - @Query("SELECT cr FROM ChatRoom cr WHERE cr.userId = :userId AND cr.title LIKE %:keyword%") - List findByUserIdAndTitleContaining(@Param("userId") Long userId, @Param("keyword") String keyword); -} diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ConversationRepository.java b/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ConversationRepository.java new file mode 100644 index 0000000..aecc977 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ConversationRepository.java @@ -0,0 +1,23 @@ +package com.synapse.chat_service.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.synapse.chat_service.domain.entity.Conversation; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface ConversationRepository extends JpaRepository { + + /** + * 사용자 ID로 대화 조회 (각 사용자는 하나의 대화만 가짐) + */ + Optional findByUserId(Long userId); + + /** + * 사용자 ID로 대화 존재 여부 확인 + */ + boolean existsByUserId(Long userId); +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/repository/MessageRepository.java b/chat_service/src/main/java/com/synapse/chat_service/domain/repository/MessageRepository.java index ca139e4..5a0dec3 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/domain/repository/MessageRepository.java +++ b/chat_service/src/main/java/com/synapse/chat_service/domain/repository/MessageRepository.java @@ -15,14 +15,14 @@ @Repository public interface MessageRepository extends JpaRepository { - List findByChatRoomIdOrderByCreatedDateAsc(UUID chatRoomId); + List findByConversationIdOrderByCreatedDateAsc(UUID conversationId); - Page findByChatRoomIdOrderByCreatedDateAsc(UUID chatRoomId, Pageable pageable); + Page findByConversationIdOrderByCreatedDateAsc(UUID conversationId, Pageable pageable); - Page findByChatRoomIdOrderByCreatedDateDesc(UUID chatRoomId, Pageable pageable); + Page findByConversationIdOrderByCreatedDateDesc(UUID conversationId, Pageable pageable); - @Query("SELECT m FROM Message m WHERE m.chatRoom.id = :chatRoomId AND m.content LIKE %:keyword%") - List findByChatRoomIdAndContentContaining(@Param("chatRoomId") UUID chatRoomId, @Param("keyword") String keyword); + @Query("SELECT m FROM Message m WHERE m.conversation.id = :conversationId AND m.content LIKE %:keyword%") + List findByConversationIdAndContentContaining(@Param("conversationId") UUID conversationId, @Param("keyword") String keyword); - long countByChatRoomId(UUID chatRoomId); + long countByConversationId(UUID conversationId); } diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/repository/UserRepository.java b/chat_service/src/main/java/com/synapse/chat_service/domain/repository/UserRepository.java deleted file mode 100644 index 45626f5..0000000 --- a/chat_service/src/main/java/com/synapse/chat_service/domain/repository/UserRepository.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.synapse.chat_service.domain.repository; - -import com.synapse.chat_service.domain.entity.User; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.Optional; - -@Repository -public interface UserRepository extends JpaRepository { - - Optional findByUsername(String username); - - Optional findByEmail(String email); - - boolean existsByUsername(String username); - - boolean existsByEmail(String email); -} diff --git a/chat_service/src/main/java/com/synapse/chat_service/dto/request/ChatRoomRequest.java b/chat_service/src/main/java/com/synapse/chat_service/dto/request/ChatRoomRequest.java deleted file mode 100644 index d40449a..0000000 --- a/chat_service/src/main/java/com/synapse/chat_service/dto/request/ChatRoomRequest.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.synapse.chat_service.dto.request; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -public class ChatRoomRequest { - - public record Create( - @NotNull(message = "사용자 ID는 필수입니다.") - Long userId, - - @NotBlank(message = "채팅방 제목은 필수입니다.") - @Size(min = 1, max = 255, message = "채팅방 제목은 1자 이상 255자 이하여야 합니다.") - String title - ) {} - - public record Update( - @NotBlank(message = "채팅방 제목은 필수입니다.") - @Size(min = 1, max = 255, message = "채팅방 제목은 1자 이상 255자 이하여야 합니다.") - String title - ) {} -} diff --git a/chat_service/src/main/java/com/synapse/chat_service/dto/request/MessageRequest.java b/chat_service/src/main/java/com/synapse/chat_service/dto/request/MessageRequest.java index a2d8c0e..066b46c 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/dto/request/MessageRequest.java +++ b/chat_service/src/main/java/com/synapse/chat_service/dto/request/MessageRequest.java @@ -1,7 +1,5 @@ package com.synapse.chat_service.dto.request; -import java.util.UUID; - import com.synapse.chat_service.domain.entity.enums.SenderType; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -9,8 +7,8 @@ public class MessageRequest { public record Create( - @NotNull(message = "채팅방 ID는 필수입니다.") - UUID chatRoomId, + @NotNull(message = "사용자 ID는 필수입니다.") + Long userId, @NotNull(message = "발신자 타입은 필수입니다.") SenderType senderType, diff --git a/chat_service/src/main/java/com/synapse/chat_service/dto/response/ChatRoomResponse.java b/chat_service/src/main/java/com/synapse/chat_service/dto/response/ChatRoomResponse.java deleted file mode 100644 index cdc57a6..0000000 --- a/chat_service/src/main/java/com/synapse/chat_service/dto/response/ChatRoomResponse.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.synapse.chat_service.dto.response; - -import com.synapse.chat_service.domain.entity.ChatRoom; - -import java.time.LocalDateTime; -import java.util.UUID; - -public class ChatRoomResponse { - - /** - * 채팅방 목록 조회용 간단한 정보 - * 목록에서 필요한 최소한의 정보만 포함 - */ - public record Simple( - UUID id, - String title, - LocalDateTime createdDate, - long messageCount - ) { - public static Simple from(ChatRoom chatRoom, long messageCount) { - return new Simple( - chatRoom.getId(), - chatRoom.getTitle(), - chatRoom.getCreatedDate(), - messageCount - ); - } - - public static Simple from(ChatRoom chatRoom) { - return new Simple( - chatRoom.getId(), - chatRoom.getTitle(), - chatRoom.getCreatedDate(), - chatRoom.getMessages().size() - ); - } - } - - /** - * 채팅방 상세 조회용 완전한 정보 - * 단일 채팅방 조회 시 필요한 모든 정보 포함 - */ - public record Detail( - UUID id, - Long userId, - String title, - LocalDateTime createdDate, - LocalDateTime updatedDate, - long messageCount - ) { - public static Detail from(ChatRoom chatRoom, long messageCount) { - return new Detail( - chatRoom.getId(), - chatRoom.getUserId(), - chatRoom.getTitle(), - chatRoom.getCreatedDate(), - chatRoom.getUpdatedDate(), - messageCount - ); - } - } -} diff --git a/chat_service/src/main/java/com/synapse/chat_service/dto/response/MessageResponse.java b/chat_service/src/main/java/com/synapse/chat_service/dto/response/MessageResponse.java index 63fb3cb..e7dbdbc 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/dto/response/MessageResponse.java +++ b/chat_service/src/main/java/com/synapse/chat_service/dto/response/MessageResponse.java @@ -10,7 +10,7 @@ public class MessageResponse { public record Simple( Long id, - UUID chatRoomId, + UUID conversationId, SenderType senderType, String content, LocalDateTime createdDate @@ -18,7 +18,7 @@ public record Simple( public static Simple from(Message message) { return new Simple( message.getId(), - message.getChatRoom().getId(), + message.getConversation().getId(), message.getSenderType(), message.getContent(), message.getCreatedDate() @@ -28,7 +28,7 @@ public static Simple from(Message message) { public record Detail( Long id, - UUID chatRoomId, + UUID conversationId, SenderType senderType, String content, LocalDateTime createdDate, @@ -37,7 +37,7 @@ public record Detail( public static Detail from(Message message) { return new Detail( message.getId(), - message.getChatRoom().getId(), + message.getConversation().getId(), message.getSenderType(), message.getContent(), message.getCreatedDate(), diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/NotFoundException.java b/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/NotFoundException.java index 4063f2c..b1f9295 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/NotFoundException.java +++ b/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/NotFoundException.java @@ -11,4 +11,4 @@ public NotFoundException(ExceptionType exceptionType) { public NotFoundException(ExceptionType exceptionType, String customMessage) { super(exceptionType, customMessage); } -} \ No newline at end of file +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/RedisOperationException.java b/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/RedisOperationException.java new file mode 100644 index 0000000..3111400 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/RedisOperationException.java @@ -0,0 +1,34 @@ +package com.synapse.chat_service.exception.commonexception; + +import com.synapse.chat_service.exception.domain.ExceptionType; + +/** + * Redis 작업 중 발생하는 예외를 처리하는 커스텀 예외 클래스 + * BusinessException을 상속하여 GlobalExceptionHandler에서 일관된 예외 처리가 가능합니다. + */ +public class RedisOperationException extends BusinessException { + + /** + * 커스텀 메시지와 원인 예외를 포함한 Redis 작업 예외 생성자 + * @param exceptionType Redis 관련 예외 타입 + * @param customMessage 사용자 정의 메시지 + * @param cause 원인 예외 + */ + private RedisOperationException(ExceptionType exceptionType, String customMessage, Throwable cause) { + super(exceptionType, customMessage, cause); + } + + /** + * Redis 작업 오류 예외 생성 팩토리 메소드 + * @param operation 실패한 작업명 + * @param cause 원인 예외 + * @return RedisOperationException 인스턴스 + */ + public static RedisOperationException operationError(String operation, Throwable cause) { + return new RedisOperationException( + ExceptionType.REDIS_OPERATION_ERROR, + String.format("Redis 작업 실패: %s", operation), + cause + ); + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/domain/ExceptionType.java b/chat_service/src/main/java/com/synapse/chat_service/exception/domain/ExceptionType.java index b709aa4..2c33583 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/exception/domain/ExceptionType.java +++ b/chat_service/src/main/java/com/synapse/chat_service/exception/domain/ExceptionType.java @@ -23,7 +23,7 @@ public enum ExceptionType { INSUFFICIENT_PERMISSION(HttpStatus.FORBIDDEN, "E202", "권한이 부족합니다."), // 404 Not Found - CHAT_ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "E301", "채팅방을 찾을 수 없습니다."), + CONVERSATION_NOT_FOUND(HttpStatus.NOT_FOUND, "E301", "대화를 찾을 수 없습니다."), MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "E302", "메시지를 찾을 수 없습니다."), USER_NOT_FOUND(HttpStatus.NOT_FOUND, "E303", "사용자를 찾을 수 없습니다."), RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "E304", "요청한 리소스를 찾을 수 없습니다."), @@ -44,6 +44,9 @@ public enum ExceptionType { INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E901", "서버 내부 오류가 발생했습니다."), DATABASE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E902", "데이터베이스 오류가 발생했습니다."), EXTERNAL_SERVICE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E903", "외부 서비스 연동 중 오류가 발생했습니다."), + REDIS_CONNECTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E904", "Redis 연결 오류가 발생했습니다."), + REDIS_OPERATION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E905", "Redis 작업 중 오류가 발생했습니다."), + REDIS_TRANSACTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E906", "Redis 트랜잭션 처리 중 오류가 발생했습니다."), // 502 Bad Gateway BAD_GATEWAY(HttpStatus.BAD_GATEWAY, "E951", "게이트웨이 오류가 발생했습니다."), diff --git a/chat_service/src/main/java/com/synapse/chat_service/service/ChatRoomService.java b/chat_service/src/main/java/com/synapse/chat_service/service/ChatRoomService.java deleted file mode 100644 index cee1ab7..0000000 --- a/chat_service/src/main/java/com/synapse/chat_service/service/ChatRoomService.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.synapse.chat_service.service; - -import com.synapse.chat_service.domain.entity.ChatRoom; -import com.synapse.chat_service.domain.repository.ChatRoomRepository; -import com.synapse.chat_service.domain.repository.MessageRepository; -import com.synapse.chat_service.dto.request.ChatRoomRequest; -import com.synapse.chat_service.dto.response.ChatRoomResponse; -import com.synapse.chat_service.exception.commonexception.NotFoundException; -import com.synapse.chat_service.exception.domain.ExceptionType; - -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.UUID; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class ChatRoomService { - - private final ChatRoomRepository chatRoomRepository; - private final MessageRepository messageRepository; - - @Transactional - public ChatRoomResponse.Detail createChatRoom(ChatRoomRequest.Create request) { - ChatRoom chatRoom = ChatRoom.builder() - .userId(request.userId()) - .title(request.title()) - .build(); - - ChatRoom savedChatRoom = chatRoomRepository.save(chatRoom); - long messageCount = messageRepository.countByChatRoomId(savedChatRoom.getId()); - return ChatRoomResponse.Detail.from(savedChatRoom, messageCount); - } - - public ChatRoomResponse.Detail getChatRoom(UUID chatRoomId) { - ChatRoom chatRoom = findChatRoomById(chatRoomId); - long messageCount = messageRepository.countByChatRoomId(chatRoomId); - return ChatRoomResponse.Detail.from(chatRoom, messageCount); - } - - public Page getChatRoomsByUserId(Long userId, Pageable pageable) { - Page chatRooms = chatRoomRepository.findByUserIdOrderByCreatedDateDesc(userId, pageable); - return chatRooms.map(chatRoom -> { - long messageCount = messageRepository.countByChatRoomId(chatRoom.getId()); - return ChatRoomResponse.Simple.from(chatRoom, messageCount); - }); - } - - @Transactional - public ChatRoomResponse.Detail updateChatRoom(UUID chatRoomId, ChatRoomRequest.Update request) { - ChatRoom chatRoom = findChatRoomById(chatRoomId); - - // 명확한 의도를 가진 메소드를 통한 상태 변경 (도메인 로직에서 유효성 검증 포함) - chatRoom.updateTitle(request.title()); - - ChatRoom updatedChatRoom = chatRoomRepository.save(chatRoom); - long messageCount = messageRepository.countByChatRoomId(chatRoomId); - return ChatRoomResponse.Detail.from(updatedChatRoom, messageCount); - } - - @Transactional - public void deleteChatRoom(UUID chatRoomId) { - ChatRoom chatRoom = findChatRoomById(chatRoomId); - chatRoomRepository.delete(chatRoom); - } - - public List searchChatRooms(Long userId, String keyword) { - List chatRooms = chatRoomRepository.findByUserIdAndTitleContaining(userId, keyword); - return chatRooms.stream() - .map(chatRoom -> { - long messageCount = messageRepository.countByChatRoomId(chatRoom.getId()); - return ChatRoomResponse.Simple.from(chatRoom, messageCount); - }) - .collect(Collectors.toList()); - } - - private ChatRoom findChatRoomById(UUID chatRoomId) { - return chatRoomRepository.findById(chatRoomId) - .orElseThrow(() -> new NotFoundException(ExceptionType.CHAT_ROOM_NOT_FOUND, "ID: " + chatRoomId)); - } -} diff --git a/chat_service/src/main/java/com/synapse/chat_service/service/MessageService.java b/chat_service/src/main/java/com/synapse/chat_service/service/MessageService.java index dee192b..ffb97eb 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/service/MessageService.java +++ b/chat_service/src/main/java/com/synapse/chat_service/service/MessageService.java @@ -1,13 +1,14 @@ package com.synapse.chat_service.service; -import com.synapse.chat_service.domain.entity.ChatRoom; +import com.synapse.chat_service.domain.entity.Conversation; import com.synapse.chat_service.domain.entity.Message; -import com.synapse.chat_service.domain.repository.ChatRoomRepository; +import com.synapse.chat_service.domain.repository.ConversationRepository; import com.synapse.chat_service.domain.repository.MessageRepository; import com.synapse.chat_service.dto.request.MessageRequest; import com.synapse.chat_service.dto.response.MessageResponse; import com.synapse.chat_service.exception.commonexception.NotFoundException; import com.synapse.chat_service.exception.domain.ExceptionType; +import com.synapse.chat_service.session.RedisAiChatManager; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -16,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Optional; import java.util.UUID; import java.util.stream.Collectors; @@ -25,14 +27,16 @@ public class MessageService { private final MessageRepository messageRepository; - private final ChatRoomRepository chatRoomRepository; + private final ConversationRepository conversationRepository; + private final RedisAiChatManager redisAiChatManager; @Transactional public MessageResponse.Detail createMessage(MessageRequest.Create request) { - ChatRoom chatRoom = findChatRoomById(request.chatRoomId()); + // 사용자의 대화가 존재하지 않으면 자동으로 생성 + Conversation conversation = getOrCreateConversation(request.userId()); Message message = Message.builder() - .chatRoom(chatRoom) + .conversation(conversation) .senderType(request.senderType()) .content(request.content()) .build(); @@ -41,45 +45,89 @@ public MessageResponse.Detail createMessage(MessageRequest.Create request) { return MessageResponse.Detail.from(savedMessage); } + /** + * 사용자의 대화 조회 (공통 메소드) + * 모든 conversation 조회 로직을 통합하여 중복을 제거 + */ + private Optional findConversationByUserId(Long userId) { + return conversationRepository.findByUserId(userId); + } + + /** + * 사용자의 대화를 조회하거나 없으면 새로 생성 + * Redis의 AiChatInfo와 DB의 Conversation 간 일관성을 보장 + */ + private Conversation getOrCreateConversation(Long userId) { + return findConversationByUserId(userId) + .map(conversation -> { + // 기존 대화가 있으면 Redis 정보 동기화 + redisAiChatManager.syncConversationId(userId.toString(), conversation.getId()); + return conversation; + }) + .orElseGet(() -> { + // 새로운 대화 생성 + Conversation newConversation = Conversation.builder() + .userId(userId) + .build(); + Conversation savedConversation = conversationRepository.save(newConversation); + + // Redis에 새로운 대화 정보 저장 + redisAiChatManager.createOrUpdateAiChatWithConversation( + userId.toString(), + savedConversation.getId() + ); + + return savedConversation; + }); + } + public MessageResponse.Detail getMessage(Long messageId) { Message message = findMessageById(messageId); return MessageResponse.Detail.from(message); } - public List getMessagesByChatRoomId(UUID chatRoomId) { - // 채팅방 존재 여부 확인 - findChatRoomById(chatRoomId); - - List messages = messageRepository.findByChatRoomIdOrderByCreatedDateAsc(chatRoomId); - return messages.stream() - .map(MessageResponse.Simple::from) - .collect(Collectors.toList()); + public List getMessagesByUserId(Long userId) { + // 사용자의 대화 조회 (없으면 빈 리스트 반환) + return findConversationByUserId(userId) + .map(conversation -> { + List messages = messageRepository.findByConversationIdOrderByCreatedDateAsc(conversation.getId()); + return messages.stream() + .map(MessageResponse.Simple::from) + .collect(Collectors.toList()); + }) + .orElse(List.of()); } - public Page getMessagesByChatRoomIdWithPaging(UUID chatRoomId, Pageable pageable) { - // 채팅방 존재 여부 확인 - findChatRoomById(chatRoomId); - - Page messages = messageRepository.findByChatRoomIdOrderByCreatedDateAsc(chatRoomId, pageable); - return messages.map(MessageResponse.Simple::from); + public Page getMessagesByUserIdWithPaging(Long userId, Pageable pageable) { + // 사용자의 대화 조회 (없으면 빈 페이지 반환) + return findConversationByUserId(userId) + .map(conversation -> { + Page messages = messageRepository.findByConversationIdOrderByCreatedDateAsc(conversation.getId(), pageable); + return messages.map(MessageResponse.Simple::from); + }) + .orElse(Page.empty(pageable)); } - public Page getMessagesRecentFirst(UUID chatRoomId, Pageable pageable) { - // 채팅방 존재 여부 확인 - findChatRoomById(chatRoomId); - - Page messages = messageRepository.findByChatRoomIdOrderByCreatedDateDesc(chatRoomId, pageable); - return messages.map(MessageResponse.Simple::from); + public Page getMessagesRecentFirst(Long userId, Pageable pageable) { + // 사용자의 대화 조회 (없으면 빈 페이지 반환) + return findConversationByUserId(userId) + .map(conversation -> { + Page messages = messageRepository.findByConversationIdOrderByCreatedDateDesc(conversation.getId(), pageable); + return messages.map(MessageResponse.Simple::from); + }) + .orElse(Page.empty(pageable)); } - public List searchMessages(UUID chatRoomId, String keyword) { - // 채팅방 존재 여부 확인 - findChatRoomById(chatRoomId); - - List messages = messageRepository.findByChatRoomIdAndContentContaining(chatRoomId, keyword); - return messages.stream() - .map(MessageResponse.Simple::from) - .collect(Collectors.toList()); + public List searchMessages(Long userId, String keyword) { + // 사용자의 대화 조회 (없으면 빈 리스트 반환) + return findConversationByUserId(userId) + .map(conversation -> { + List messages = messageRepository.findByConversationIdAndContentContaining(conversation.getId(), keyword); + return messages.stream() + .map(MessageResponse.Simple::from) + .collect(Collectors.toList()); + }) + .orElse(List.of()); } @Transactional @@ -88,16 +136,24 @@ public void deleteMessage(Long messageId) { messageRepository.delete(message); } - public long getMessageCount(UUID chatRoomId) { - // 채팅방 존재 여부 확인 - findChatRoomById(chatRoomId); - - return messageRepository.countByChatRoomId(chatRoomId); + public long getMessageCount(Long userId) { + // 사용자의 대화 조회 (없으면 0 반환) + return findConversationByUserId(userId) + .map(conversation -> messageRepository.countByConversationId(conversation.getId())) + .orElse(0L); + } + + public long getMessageCountByUserId(Long userId) { + return getMessageCount(userId); } - private ChatRoom findChatRoomById(UUID chatRoomId) { - return chatRoomRepository.findById(chatRoomId) - .orElseThrow(() -> new NotFoundException(ExceptionType.CHAT_ROOM_NOT_FOUND, "ID: " + chatRoomId)); + /** + * 사용자의 대화 ID 조회 (없으면 null 반환) + */ + public UUID getConversationId(Long userId) { + return findConversationByUserId(userId) + .map(Conversation::getId) + .orElse(null); } private Message findMessageById(Long messageId) { diff --git a/chat_service/src/main/java/com/synapse/chat_service/session/RedisAiChatManager.java b/chat_service/src/main/java/com/synapse/chat_service/session/RedisAiChatManager.java new file mode 100644 index 0000000..79f8574 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/session/RedisAiChatManager.java @@ -0,0 +1,149 @@ +package com.synapse.chat_service.session; + +import com.synapse.chat_service.common.annotation.RedisOperation; +import com.synapse.chat_service.common.util.RedisTypeConverter; +import com.synapse.chat_service.session.dto.AiChatInfo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; + +/** + * AI 채팅 세션을 Redis로 관리하는 매니저 + * 사용자와 AI 간의 1:1 채팅 세션 정보를 관리합니다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RedisAiChatManager { + + private final RedisTemplate redisTemplate; + private final RedisKeyGenerator keyGenerator; + private final RedisTypeConverter typeConverter; + + // AI 채팅 정보는 30일간 유지 (사용자가 다시 접속할 수 있도록) + private static final Duration AI_CHAT_EXPIRATION = Duration.ofDays(30); + + /** + * AI 채팅 정보 조회 + */ + @RedisOperation(value = "AI 채팅 정보 조회", returnDefaultOnError = true) + public Optional getAiChat(String userId) { + String key = keyGenerator.generateAIConversationKey(userId); + Object rawValue = redisTemplate.opsForValue().get(key); + AiChatInfo aiChat = typeConverter.convertValue(rawValue, AiChatInfo.class); + return Optional.ofNullable(aiChat); + } + + /** + * AI 채팅 활동 시간 업데이트 + */ + @RedisOperation(value = "AI 채팅 활동 시간 업데이트", rethrowException = false) + public void updateAiChatActivity(String userId) { + String key = keyGenerator.generateAIConversationKey(userId); + Object rawValue = redisTemplate.opsForValue().get(key); + AiChatInfo aiChat = typeConverter.convertValue(rawValue, AiChatInfo.class); + + if (aiChat != null) { + AiChatInfo updatedChat = aiChat.updateLastActivity(); + redisTemplate.opsForValue().set(key, updatedChat, AI_CHAT_EXPIRATION); + + log.debug("AI 채팅 활동 시간 업데이트: userId={}", userId); + } + } + + /** + * AI 채팅 메시지 수 증가 + */ + @RedisOperation(value = "AI 채팅 메시지 수 증가", rethrowException = false) + public void incrementMessageCount(String userId) { + String key = keyGenerator.generateAIConversationKey(userId); + Object rawValue = redisTemplate.opsForValue().get(key); + AiChatInfo aiChat = typeConverter.convertValue(rawValue, AiChatInfo.class); + + if (aiChat != null) { + AiChatInfo updatedChat = aiChat.incrementMessageCount(); + redisTemplate.opsForValue().set(key, updatedChat, AI_CHAT_EXPIRATION); + + log.debug("AI 채팅 메시지 수 증가: userId={}, count={}", + userId, updatedChat.messageCount()); + } + } + + /** + * AI 채팅 정보 삭제 (사용자 탈퇴 등의 경우) + */ + @RedisOperation(value = "AI 채팅 정보 삭제", rethrowException = false) + public void deleteAiChat(String userId) { + String key = keyGenerator.generateAIConversationKey(userId); + redisTemplate.delete(key); + + log.info("AI 채팅 정보 삭제: userId={}", userId); + } + + /** + * 실제 Conversation UUID를 사용하여 AI 채팅 세션 생성 또는 업데이트 + * Redis와 DB 간의 일관성을 보장합니다. + */ + @RedisOperation("UUID 기반 AI 채팅 세션 생성/업데이트") + public AiChatInfo createOrUpdateAiChatWithConversation(String userId, UUID conversationId) { + String key = keyGenerator.generateAIConversationKey(userId); + + // 1. 기존 AI 채팅 정보 조회 + Object rawValue = redisTemplate.opsForValue().get(key); + AiChatInfo existingChat = typeConverter.convertValue(rawValue, AiChatInfo.class); + + if (existingChat != null) { + // 기존 채팅이 있으면 conversationId 업데이트 및 활동 시간 갱신 + AiChatInfo updatedChat = new AiChatInfo( + existingChat.userId(), + conversationId, // 실제 DB의 UUID로 업데이트 + existingChat.createdAt(), + java.time.LocalDateTime.now(), // 활동 시간 갱신 + existingChat.messageCount() + ); + redisTemplate.opsForValue().set(key, updatedChat, AI_CHAT_EXPIRATION); + + log.debug("AI 채팅 정보 업데이트: userId={}, conversationId={}", + userId, conversationId); + return updatedChat; + } + + // 2. 새로운 AI 채팅 정보 생성 + AiChatInfo newChat = AiChatInfo.create(userId, conversationId); + redisTemplate.opsForValue().set(key, newChat, AI_CHAT_EXPIRATION); + + log.info("새로운 AI 채팅 정보 생성: userId={}, conversationId={}", + userId, conversationId); + return newChat; + } + + /** + * 기존 Redis 정보의 conversationId를 실제 DB UUID와 동기화 + */ + @RedisOperation(value = "Conversation ID 동기화", rethrowException = false) + public void syncConversationId(String userId, UUID conversationId) { + String key = keyGenerator.generateAIConversationKey(userId); + Object rawValue = redisTemplate.opsForValue().get(key); + AiChatInfo aiChat = typeConverter.convertValue(rawValue, AiChatInfo.class); + + if (aiChat != null && !conversationId.equals(aiChat.conversationId())) { + // conversationId가 다르면 동기화 + AiChatInfo syncedChat = new AiChatInfo( + aiChat.userId(), + conversationId, // 실제 DB의 UUID로 동기화 + aiChat.createdAt(), + java.time.LocalDateTime.now(), // 활동 시간 갱신 + aiChat.messageCount() + ); + redisTemplate.opsForValue().set(key, syncedChat, AI_CHAT_EXPIRATION); + + log.info("Conversation ID 동기화: userId={}, oldId={}, newId={}", + userId, aiChat.conversationId(), conversationId); + } + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/session/RedisKeyGenerator.java b/chat_service/src/main/java/com/synapse/chat_service/session/RedisKeyGenerator.java new file mode 100644 index 0000000..37f64ad --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/session/RedisKeyGenerator.java @@ -0,0 +1,70 @@ +package com.synapse.chat_service.session; + +import org.springframework.stereotype.Component; + +/** + * Redis 키 생성 전략을 담당하는 유틸리티 클래스 + * 일관된 키 네이밍 규칙을 통해 Redis 데이터 관리의 효율성을 높입니다. + */ +@Component +public class RedisKeyGenerator { + + // 키 접두사 상수 + private static final String SESSION_PREFIX = "session:"; + private static final String USER_SESSION_PREFIX = "user:session:"; + private static final String AI_CONVERSATION_PREFIX = "ai:conversation:"; + + /** + * WebSocket 세션 키 생성 + * @param sessionId WebSocket 세션 ID + * @return Redis 키 (예: "session:abc123") + */ + public String generateSessionKey(String sessionId) { + return SESSION_PREFIX + sessionId; + } + + /** + * 사용자별 세션 키 생성 + * @param userId 사용자 ID + * @return Redis 키 (예: "user:session:user123") + */ + public String generateUserSessionKey(String userId) { + return USER_SESSION_PREFIX + userId; + } + + + + /** + * AI 대화 세션 키 생성 + * @param userId 사용자 ID + * @return Redis 키 (예: "ai:conversation:user123") + */ + public String generateAIConversationKey(String userId) { + return AI_CONVERSATION_PREFIX + userId; + } + + /** + * AI 채팅 정보 키 생성 + * 패턴: "ai:chat:{userId}" + */ + public String generateAiChatKey(String userId) { + return "ai:chat:" + userId; + } + + /** + * 패턴 매칭을 위한 와일드카드 키 생성 + * @param prefix 접두사 + * @return 와일드카드 패턴 (예: "session:*") + */ + public String generatePatternKey(String prefix) { + return prefix + "*"; + } + + /** + * 모든 세션 키 패턴 + * @return "session:*" + */ + public String getAllSessionsPattern() { + return generatePatternKey(SESSION_PREFIX); + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/session/RedisSessionManager.java b/chat_service/src/main/java/com/synapse/chat_service/session/RedisSessionManager.java new file mode 100644 index 0000000..db8462c --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/session/RedisSessionManager.java @@ -0,0 +1,281 @@ +package com.synapse.chat_service.session; + +import com.synapse.chat_service.common.annotation.RedisOperation; +import com.synapse.chat_service.common.util.RedisTypeConverter; +import com.synapse.chat_service.session.dto.SessionInfo; +import com.synapse.chat_service.session.dto.SessionStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Redis를 사용한 WebSocket 세션 관리 서비스 + * 다중 기기 동시 접속을 지원하는 세션의 생성, 조회, 업데이트, 삭제를 담당합니다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@EnableConfigurationProperties(SessionProperties.class) +public class RedisSessionManager { + + private final RedisTemplate redisTemplate; + private final RedisKeyGenerator keyGenerator; + private final RedisTypeConverter typeConverter; + private final SessionProperties sessionProperties; + + /** + * 새로운 세션 생성 (다중 세션 지원, 트랜잭션 원자성 보장) + */ + @RedisOperation("세션 생성") + public void createSession(SessionInfo sessionInfo) { + String sessionKey = keyGenerator.generateSessionKey(sessionInfo.sessionId()); + String userSessionKey = keyGenerator.generateUserSessionKey(sessionInfo.userId()); + + // 최대 세션 수 확인 및 제한 + int currentSessionCount = getActiveSessionCount(sessionInfo.userId()); + if (currentSessionCount >= sessionProperties.maxSessionsPerUser()) { + // 가장 오래된 세션 하나를 제거 + removeOldestSession(sessionInfo.userId()); + log.info("최대 세션 수 초과로 가장 오래된 세션 제거: userId={}", sessionInfo.userId()); + } + + // Redis 트랜잭션을 사용하여 원자성 보장 + redisTemplate.execute((RedisCallback) connection -> { + try { + // 트랜잭션 시작 + connection.multi(); + + // 1. 세션 정보 저장 (설정된 시간 TTL) + byte[] sessionKeyBytes = sessionKey.getBytes(); + byte[] sessionValueBytes = typeConverter.convertToBytes(sessionInfo); + connection.stringCommands().setEx(sessionKeyBytes, Duration.ofHours(sessionProperties.expirationHours()).toSeconds(), sessionValueBytes); + + // 2. 사용자별 세션 Set에 sessionId 추가 + byte[] userSessionKeyBytes = userSessionKey.getBytes(); + byte[] sessionIdBytes = sessionInfo.sessionId().getBytes(); + connection.setCommands().sAdd(userSessionKeyBytes, sessionIdBytes); + + // 3. 사용자 세션 Set TTL 설정 (설정된 시간) + connection.keyCommands().expire(userSessionKeyBytes, Duration.ofHours(sessionProperties.expirationHours()).toSeconds()); + + // 트랜잭션 실행 + connection.exec(); + + log.info("세션 생성 완료 (트랜잭션): sessionId={}, userId={}, 총 세션 수={}", + sessionInfo.sessionId(), sessionInfo.userId(), currentSessionCount + 1); + + return null; + + } catch (Exception e) { + log.error("세션 생성 트랜잭션 실패: sessionId={}, userId={}", + sessionInfo.sessionId(), sessionInfo.userId(), e); + throw new RuntimeException("세션 생성 트랜잭션 실패", e); + } + }); + } + + /** + * 세션 ID로 세션 조회 + */ + @RedisOperation("세션 조회") + public SessionInfo getSession(String sessionId) { + String sessionKey = keyGenerator.generateSessionKey(sessionId); + Object rawValue = redisTemplate.opsForValue().get(sessionKey); + SessionInfo sessionInfo = typeConverter.convertValue(rawValue, SessionInfo.class); + + log.debug("세션 조회: sessionId={}, found={}", sessionId, sessionInfo != null); + return sessionInfo; + } + + /** + * 사용자 ID로 세션 정보 조회 (첫 번째 세션 반환) + */ + @RedisOperation("사용자 세션 조회") + public SessionInfo getSessionByUserId(String userId) { + String userSessionKey = keyGenerator.generateUserSessionKey(userId); + Set sessionIds = redisTemplate.opsForSet().members(userSessionKey); + + if (sessionIds == null || sessionIds.isEmpty()) { + log.debug("사용자 세션 ID를 찾을 수 없음: userId={}", userId); + return null; + } + + // 첫 번째 세션 반환 (기존 호환성 유지) + String sessionId = typeConverter.convertToString(sessionIds.iterator().next()); + return getSession(sessionId); + } + + /** + * 세션 정보 업데이트 + */ + @RedisOperation("세션 업데이트") + public void updateSession(SessionInfo sessionInfo) { + String sessionKey = keyGenerator.generateSessionKey(sessionInfo.sessionId()); + + // 세션 정보 업데이트 (설정된 시간 TTL) + redisTemplate.opsForValue().set(sessionKey, sessionInfo, Duration.ofHours(sessionProperties.expirationHours())); + + log.debug("세션 업데이트 완료: sessionId={}", sessionInfo.sessionId()); + } + + /** + * 세션 삭제 (다중 세션 지원, 트랜잭션 원자성 보장) + */ + @RedisOperation("세션 삭제") + public void deleteSession(String sessionId) { + SessionInfo sessionInfo = getSession(sessionId); + if (sessionInfo != null) { + String sessionKey = keyGenerator.generateSessionKey(sessionId); + String userSessionKey = keyGenerator.generateUserSessionKey(sessionInfo.userId()); + + // Redis 트랜잭션으로 원자성 보장 + redisTemplate.execute((RedisCallback) connection -> { + try { + // 트랜잭션 시작 + connection.multi(); + + // 1. 개별 세션 삭제 + byte[] sessionKeyBytes = sessionKey.getBytes(); + connection.keyCommands().del(sessionKeyBytes); + + // 2. 사용자 세션 Set에서 해당 sessionId 제거 + byte[] userSessionKeyBytes = userSessionKey.getBytes(); + byte[] sessionIdBytes = sessionId.getBytes(); + connection.setCommands().sRem(userSessionKeyBytes, sessionIdBytes); + + // 트랜잭션 실행 + connection.exec(); + + log.info("세션 삭제 완료 (트랜잭션): sessionId={}, userId={}", sessionId, sessionInfo.userId()); + + return null; + + } catch (Exception e) { + log.error("세션 삭제 트랜잭션 실패: sessionId={}, userId={}", + sessionId, sessionInfo.userId(), e); + throw new RuntimeException("세션 삭제 트랜잭션 실패", e); + } + }); + } + } + + /** + * 사용자의 모든 세션 강제 삭제 (관리자 기능) + */ + @RedisOperation(value = "사용자 모든 세션 삭제", rethrowException = false) + public void deleteAllUserSessions(String userId) { + String userSessionKey = keyGenerator.generateUserSessionKey(userId); + + // 1. 모든 세션 ID 조회 + Set sessionIds = redisTemplate.opsForSet().members(userSessionKey); + + if (sessionIds != null && !sessionIds.isEmpty()) { + // 2. 각 세션 개별 삭제 + for (Object sessionIdObj : sessionIds) { + String sessionId = typeConverter.convertToString(sessionIdObj); + if (sessionId != null) { + String sessionKey = keyGenerator.generateSessionKey(sessionId); + redisTemplate.delete(sessionKey); + log.debug("세션 삭제: sessionId={}", sessionId); + } + } + } + + // 3. 사용자-세션 Set 삭제 + redisTemplate.delete(userSessionKey); + log.info("사용자 모든 세션 삭제 완료: userId={}, 삭제된 세션 수={}", + userId, sessionIds != null ? sessionIds.size() : 0); + } + + /** + * 세션 상태 변경 + */ + @RedisOperation("세션 상태 변경") + public void changeSessionStatus(String sessionId, SessionStatus newStatus) { + SessionInfo currentSession = getSession(sessionId); + if (currentSession != null) { + SessionInfo updatedSession = currentSession.changeStatus(newStatus); + updateSession(updatedSession); + log.info("세션 상태 변경: sessionId={}, status={}", sessionId, newStatus); + } + } + + /** + * 세션 존재 여부 확인 + */ + @RedisOperation(value = "세션 존재 확인", returnDefaultOnError = true) + public boolean existsSession(String sessionId) { + String sessionKey = keyGenerator.generateSessionKey(sessionId); + return Boolean.TRUE.equals(redisTemplate.hasKey(sessionKey)); + } + + /** + * 사용자 세션 존재 여부 확인 (다중 세션 지원) + */ + @RedisOperation(value = "사용자 세션 존재 확인", returnDefaultOnError = true) + public boolean existsSessionByUserId(String userId) { + String userSessionKey = keyGenerator.generateUserSessionKey(userId); + Long sessionCount = redisTemplate.opsForSet().size(userSessionKey); + return sessionCount != null && sessionCount > 0; + } + + /** + * 사용자의 모든 세션 정보 조회 + */ + @RedisOperation("사용자 모든 세션 조회") + public List getSessionsByUserId(String userId) { + String userSessionKey = keyGenerator.generateUserSessionKey(userId); + Set sessionIds = redisTemplate.opsForSet().members(userSessionKey); + + if (sessionIds == null || sessionIds.isEmpty()) { + log.debug("사용자 세션을 찾을 수 없음: userId={}", userId); + return List.of(); + } + + return sessionIds.stream() + .map(sessionIdObj -> typeConverter.convertToString(sessionIdObj)) + .filter(sessionId -> sessionId != null) + .map(this::getSession) + .filter(sessionInfo -> sessionInfo != null) + .collect(Collectors.toList()); + } + + /** + * 사용자의 활성 세션 수 조회 + */ + @RedisOperation(value = "활성 세션 수 조회", returnDefaultOnError = true) + public int getActiveSessionCount(String userId) { + String userSessionKey = keyGenerator.generateUserSessionKey(userId); + Long sessionCount = redisTemplate.opsForSet().size(userSessionKey); + return sessionCount != null ? sessionCount.intValue() : 0; + } + + /** + * 가장 오래된 세션 제거 (최대 세션 수 초과 시 사용) + */ + @RedisOperation(value = "가장 오래된 세션 제거", rethrowException = false) + private void removeOldestSession(String userId) { + List sessions = getSessionsByUserId(userId); + if (!sessions.isEmpty()) { + // 가장 오래된 세션 찾기 (연결 시간 기준) + SessionInfo oldestSession = sessions.stream() + .min((s1, s2) -> s1.connectedAt().compareTo(s2.connectedAt())) + .orElse(null); + + if (oldestSession != null) { + deleteSession(oldestSession.sessionId()); + log.info("가장 오래된 세션 제거: sessionId={}, userId={}", + oldestSession.sessionId(), userId); + } + } + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/session/SessionProperties.java b/chat_service/src/main/java/com/synapse/chat_service/session/SessionProperties.java new file mode 100644 index 0000000..52509ce --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/session/SessionProperties.java @@ -0,0 +1,23 @@ +package com.synapse.chat_service.session; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import jakarta.validation.constraints.Min; + +/** + * 세션 관련 설정 프로퍼티 + * + * @param expirationHours 세션 만료 시간 (시간 단위) + * @param maxSessionsPerUser 사용자당 최대 세션 수 + */ +@Validated +@ConfigurationProperties(prefix = "session") +public record SessionProperties( + @Min(value = 1, message = "세션 만료 시간은 최소 1시간 이상이어야 합니다.") + int expirationHours, + + @Min(value = 1, message = "사용자당 최대 세션 수는 최소 1개 이상이어야 합니다.") + int maxSessionsPerUser +) { +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/session/WebSocketSessionFacade.java b/chat_service/src/main/java/com/synapse/chat_service/session/WebSocketSessionFacade.java new file mode 100644 index 0000000..895445a --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/session/WebSocketSessionFacade.java @@ -0,0 +1,112 @@ +package com.synapse.chat_service.session; + +import com.synapse.chat_service.session.dto.SessionInfo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * AI 채팅 WebSocket 세션 관리를 위한 Facade 클래스 + * AI와의 1:1 채팅에 최적화된 간단한 세션 관리 로직을 제공합니다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WebSocketSessionFacade { + + private final RedisSessionManager sessionManager; + private final RedisAiChatManager aiChatManager; + + /** + * 사용자 연결 처리 + * 1. 새로운 세션 생성 (다중 기기 동시 접속 지원) + * 2. AI 채팅 정보 조회 (MessageService에서 DB와 Redis 동기화 처리) + */ + public SessionInfo handleUserConnection(String sessionId, String userId, String username, String clientInfo) { + log.info("AI 채팅 사용자 연결 처리 시작: sessionId={}, userId={}", sessionId, userId); + + // 1. 새로운 세션 생성 (다중 세션 지원) + SessionInfo sessionInfo = SessionInfo.create(sessionId, userId, username, clientInfo); + sessionManager.createSession(sessionInfo); + + // 2. AI 채팅 정보 조회 (MessageService에서 DB와 Redis 동기화가 이미 처리됨) + // 기존 정보가 있으면 활동 시간만 업데이트 + aiChatManager.updateAiChatActivity(userId); + + log.info("AI 채팅 사용자 연결 처리 완료: sessionId={}, userId={}", sessionId, userId); + + return sessionInfo; + } + + /** + * 사용자 연결 해제 처리 + * 1. 세션 정보 조회 + * 2. AI 채팅 활동 시간 업데이트 + * 3. 세션 삭제 + */ + public void handleUserDisconnection(String sessionId) { + log.info("AI 채팅 사용자 연결 해제 처리 시작: sessionId={}", sessionId); + + // 1. 세션 정보 조회 + SessionInfo sessionInfo = sessionManager.getSession(sessionId); + if (sessionInfo == null) { + log.warn("연결 해제 시 세션을 찾을 수 없음: sessionId={}", sessionId); + return; + } + + // 2. AI 채팅 활동 시간 업데이트 (rethrowException = false) + aiChatManager.updateAiChatActivity(sessionInfo.userId()); + + // 3. 세션 삭제 + sessionManager.deleteSession(sessionId); + + log.info("AI 채팅 사용자 연결 해제 처리 완료: sessionId={}, userId={}", + sessionId, sessionInfo.userId()); + } + + /** + * 메시지 활동 처리 + * 1. AI 채팅 메시지 수 증가 + * 2. AI 채팅 활동 시간 업데이트 + */ + public void handleMessageActivity(String userId) { + log.debug("AI 채팅 메시지 활동 처리: userId={}", userId); + + // 1. AI 채팅 메시지 수 증가 (rethrowException = false) + aiChatManager.incrementMessageCount(userId); + + // 2. AI 채팅 활동 시간 업데이트 (rethrowException = false) + aiChatManager.updateAiChatActivity(userId); + } + + /** + * 세션 활동 업데이트 + */ + public void updateSessionActivity(String sessionId) { + log.debug("세션 활동 업데이트: sessionId={}", sessionId); + + SessionInfo sessionInfo = sessionManager.getSession(sessionId); + if (sessionInfo != null) { + SessionInfo updatedSession = sessionInfo.updateLastActivity(); + sessionManager.updateSession(updatedSession); + } + } + + /** + * 사용자의 대화 ID 조회 + */ + public String getConversationId(String userId) { + return aiChatManager.getAiChat(userId) + .map(aiChat -> aiChat.conversationId().toString()) + .orElse("ai-chat-" + userId); // 기본 패턴 반환 (호환성 유지) + } + + /** + * 사용자의 모든 세션 강제 삭제 (관리자 기능) + */ + public void forceDeleteAllUserSessions(String userId) { + log.info("사용자 모든 세션 강제 삭제: userId={}", userId); + // deleteAllUserSessions는 rethrowException = false로 설정되어 예외를 던지지 않음 + sessionManager.deleteAllUserSessions(userId); + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/session/dto/AiChatInfo.java b/chat_service/src/main/java/com/synapse/chat_service/session/dto/AiChatInfo.java new file mode 100644 index 0000000..1f29271 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/session/dto/AiChatInfo.java @@ -0,0 +1,67 @@ +package com.synapse.chat_service.session.dto; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * AI 채팅 정보를 저장하는 Record + * Redis에서 현재 활성화된 WebSocket 세션과 관련된 상태 정보 및 캐시 역할을 합니다. + * 데이터베이스의 Conversation 엔티티가 영구적인 저장소(Source of Truth) 역할을 하며, + * 이 레코드는 자주 접근하지만 휘발되어도 괜찮은 메타데이터를 저장하여 DB 조회를 줄입니다. + * + * @param userId 사용자 ID + * @param conversationId 실제 데이터베이스의 Conversation UUID (Redis와 DB 간 일관성 보장) + * @param createdAt 채팅방 생성 시간 + * @param lastActivityAt 마지막 활동 시간 + * @param messageCount 총 메시지 수 (선택적 통계) + */ +public record AiChatInfo( + String userId, + UUID conversationId, + LocalDateTime createdAt, + LocalDateTime lastActivityAt, + Long messageCount +) { + + /** + * 새로운 AI 채팅 생성을 위한 팩토리 메서드 + * 실제 데이터베이스의 Conversation UUID를 사용하여 Redis와 DB 간 일관성을 보장합니다. + */ + public static AiChatInfo create(String userId, UUID conversationId) { + LocalDateTime now = LocalDateTime.now(); + + return new AiChatInfo( + userId, + conversationId, + now, + now, + 0L + ); + } + + /** + * 마지막 활동 시간 업데이트 + */ + public AiChatInfo updateLastActivity() { + return new AiChatInfo( + userId, + conversationId, + createdAt, + LocalDateTime.now(), + messageCount + ); + } + + /** + * 메시지 수 증가 + */ + public AiChatInfo incrementMessageCount() { + return new AiChatInfo( + userId, + conversationId, + createdAt, + LocalDateTime.now(), + messageCount + 1 + ); + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/session/dto/SessionInfo.java b/chat_service/src/main/java/com/synapse/chat_service/session/dto/SessionInfo.java new file mode 100644 index 0000000..c36da95 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/session/dto/SessionInfo.java @@ -0,0 +1,72 @@ +package com.synapse.chat_service.session.dto; + +import java.time.LocalDateTime; + +/** + * AI 채팅 WebSocket 세션 정보를 저장하는 Record + * Redis에 JSON 형태로 직렬화되어 저장됩니다. + * + * @param sessionId WebSocket 세션 ID + * @param userId 사용자 ID + * @param username 사용자 이름 + * @param connectedAt 세션 연결 시간 + * @param lastActivityAt 마지막 활동 시간 + * @param status 세션 상태 (CONNECTED, DISCONNECTED, IDLE) + * @param clientInfo 클라이언트 정보 (브라우저, 모바일 앱 등) + */ +public record SessionInfo( + String sessionId, + String userId, + String username, + LocalDateTime connectedAt, + LocalDateTime lastActivityAt, + SessionStatus status, + String clientInfo +) { + + /** + * 새로운 AI 채팅 세션 생성을 위한 팩토리 메서드 + */ + public static SessionInfo create(String sessionId, String userId, String username, String clientInfo) { + LocalDateTime now = LocalDateTime.now(); + return new SessionInfo( + sessionId, + userId, + username, + now, + now, + SessionStatus.CONNECTED, + clientInfo + ); + } + + /** + * 마지막 활동 시간 업데이트 + */ + public SessionInfo updateLastActivity() { + return new SessionInfo( + sessionId, + userId, + username, + connectedAt, + LocalDateTime.now(), + status, + clientInfo + ); + } + + /** + * 세션 상태 변경 + */ + public SessionInfo changeStatus(SessionStatus newStatus) { + return new SessionInfo( + sessionId, + userId, + username, + connectedAt, + LocalDateTime.now(), + newStatus, + clientInfo + ); + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/session/dto/SessionStatus.java b/chat_service/src/main/java/com/synapse/chat_service/session/dto/SessionStatus.java new file mode 100644 index 0000000..77f4404 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/session/dto/SessionStatus.java @@ -0,0 +1,27 @@ +package com.synapse.chat_service.session.dto; + +/** + * WebSocket 세션의 상태를 나타내는 열거형 + */ +public enum SessionStatus { + + /** + * 연결된 상태 - 정상적으로 WebSocket 연결이 활성화된 상태 + */ + CONNECTED, + + /** + * 연결 해제된 상태 - WebSocket 연결이 종료된 상태 + */ + DISCONNECTED, + + /** + * 유휴 상태 - 연결은 유지되지만 일정 시간 동안 활동이 없는 상태 + */ + IDLE, + + /** + * 재연결 중 상태 - 네트워크 문제 등으로 재연결을 시도하는 상태 + */ + RECONNECTING +} diff --git a/chat_service/src/main/resources/application-local.yml b/chat_service/src/main/resources/application-local.yml index 0622468..81b336e 100644 --- a/chat_service/src/main/resources/application-local.yml +++ b/chat_service/src/main/resources/application-local.yml @@ -17,6 +17,26 @@ spring: dialect: org.hibernate.dialect.PostgreSQLDialect open-in-view: false show-sql: true + + data: + redis: + host: ${local-db.redis.host} + port: ${local-db.redis.port} + timeout: ${local-db.redis.timeout} + lettuce: + pool: + max-active: ${local-db.redis.max-active} + max-idle: ${local-db.redis.max-idle} + min-idle: ${local-db.redis.min-idle} + + session: + store-type: redis + redis: + namespace: spring:session + +session: + expiration-hours: 24 + max-sessions-per-user: 5 logging: level: diff --git a/chat_service/src/main/resources/security/application-db.yml b/chat_service/src/main/resources/security/application-db.yml index a4294ff..a7eb998 100644 --- a/chat_service/src/main/resources/security/application-db.yml +++ b/chat_service/src/main/resources/security/application-db.yml @@ -5,3 +5,12 @@ local-db: name: chat-service username: donghyeon password: adzc1973 + + redis: + host: localhost + port: 6379 + password: 1234 + timeout: 10000 + max-active: 8 + max-idle: 8 + min-idle: 0 diff --git a/chat_service/src/test/java/com/synapse/chat_service/controller/ChatRoomControllerTest.java b/chat_service/src/test/java/com/synapse/chat_service/controller/ChatRoomControllerTest.java deleted file mode 100644 index 4f2394e..0000000 --- a/chat_service/src/test/java/com/synapse/chat_service/controller/ChatRoomControllerTest.java +++ /dev/null @@ -1,402 +0,0 @@ -package com.synapse.chat_service.controller; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.synapse.chat_service.dto.request.ChatRoomRequest; -import com.synapse.chat_service.dto.response.ChatRoomResponse; -import com.synapse.chat_service.service.ChatRoomService; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@SpringBootTest -@AutoConfigureMockMvc -@ActiveProfiles("test") -@Transactional -@DisplayName("ChatRoomController 통합 테스트") -class ChatRoomControllerTest { - - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @Autowired - private ChatRoomService chatRoomService; - - @Nested - @DisplayName("POST /api/v1/chat-rooms - 채팅방 생성") - class CreateChatRoom { - - @Test - @DisplayName("성공: 유효한 요청으로 채팅방 생성") - void createChatRoom_Success() throws Exception { - // given - ChatRoomRequest.Create request = new ChatRoomRequest.Create(1L, "테스트 채팅방"); - - // when & then - mockMvc.perform(post("/api/v1/chat-rooms") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.id").exists()) - .andExpect(jsonPath("$.userId").value(1L)) - .andExpect(jsonPath("$.title").value("테스트 채팅방")) - .andExpect(jsonPath("$.createdDate").exists()) - .andExpect(jsonPath("$.updatedDate").exists()) - .andExpect(jsonPath("$.messageCount").value(0)); - } - - @Test - @DisplayName("실패: userId가 null인 경우") - void createChatRoom_Fail_UserIdNull() throws Exception { - // given - ChatRoomRequest.Create request = new ChatRoomRequest.Create(null, "테스트 채팅방"); - - // when & then - mockMvc.perform(post("/api/v1/chat-rooms") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()); - } - - @Test - @DisplayName("실패: title이 비어있는 경우") - void createChatRoom_Fail_TitleBlank() throws Exception { - // given - ChatRoomRequest.Create request = new ChatRoomRequest.Create(1L, ""); - - // when & then - mockMvc.perform(post("/api/v1/chat-rooms") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()); - } - - @Test - @DisplayName("실패: title이 255자를 초과하는 경우") - void createChatRoom_Fail_TitleTooLong() throws Exception { - // given - String longTitle = "a".repeat(256); - ChatRoomRequest.Create request = new ChatRoomRequest.Create(1L, longTitle); - - // when & then - mockMvc.perform(post("/api/v1/chat-rooms") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()); - } - } - - @Nested - @DisplayName("GET /api/v1/chat-rooms/{chatRoomId} - 채팅방 단건 조회") - class GetChatRoom { - - @Test - @DisplayName("성공: 존재하는 채팅방 조회") - void getChatRoom_Success() throws Exception { - // given - ChatRoomRequest.Create createRequest = new ChatRoomRequest.Create(1L, "테스트 채팅방"); - ChatRoomResponse.Detail createdChatRoom = chatRoomService.createChatRoom(createRequest); - - // when & then - mockMvc.perform(get("/api/v1/chat-rooms/{chatRoomId}", createdChatRoom.id())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(createdChatRoom.id().toString())) - .andExpect(jsonPath("$.userId").value(1L)) - .andExpect(jsonPath("$.title").value("테스트 채팅방")) - .andExpect(jsonPath("$.messageCount").value(0)); - } - - @Test - @DisplayName("실패: 존재하지 않는 채팅방 조회") - void getChatRoom_Fail_NotFound() throws Exception { - // given - UUID nonExistentId = UUID.randomUUID(); - - // when & then - mockMvc.perform(get("/api/v1/chat-rooms/{chatRoomId}", nonExistentId)) - .andDo(print()) - .andExpect(status().isNotFound()); - } - } - - @Nested - @DisplayName("GET /api/v1/chat-rooms/paging - 채팅방 페이징 조회") - class GetChatRoomsByUserIdPaging { - - @Test - @DisplayName("성공: 페이징 파라미터로 채팅방 조회") - void getChatRoomsByUserId_Success() throws Exception { - // given - Long userId = 1L; - for (int i = 1; i <= 25; i++) { - chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "채팅방 " + i)); - } - - // when & then - mockMvc.perform(get("/api/v1/chat-rooms") - .param("userId", userId.toString()) - .param("page", "0") - .param("size", "10") - .param("sort", "createdDate,desc")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(10)) - .andExpect(jsonPath("$.totalElements").value(25)) - .andExpect(jsonPath("$.totalPages").value(3)) - .andExpect(jsonPath("$.size").value(10)) - .andExpect(jsonPath("$.number").value(0)); - } - - @Test - @DisplayName("성공: 기본 페이징 설정으로 조회") - void getChatRoomsByUserId_Success_DefaultPaging() throws Exception { - // given - Long userId = 1L; - chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "채팅방 1")); - - // when & then - mockMvc.perform(get("/api/v1/chat-rooms") - .param("userId", userId.toString())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.size").value(20)); // 기본 size - } - } - - @Nested - @DisplayName("GET /api/v1/chat-rooms/search - 채팅방 제목 검색") - class SearchChatRooms { - - @Test - @DisplayName("성공: 특정 userId와 keyword로 검색 시 조건에 맞는 채팅방 목록 반환") - void searchChatRooms_Success() throws Exception { - // given - Long userId = 1L; - chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "Java 스터디")); - chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "Spring Boot 프로젝트")); - chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "React 개발")); - chatRoomService.createChatRoom(new ChatRoomRequest.Create(2L, "Java 마스터")); // 다른 사용자 - - // when & then - mockMvc.perform(get("/api/v1/chat-rooms/search") - .param("userId", userId.toString()) - .param("keyword", "Java")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].title").value("Java 스터디")) - .andExpect(jsonPath("$[0].id").exists()) - .andExpect(jsonPath("$[0].createdDate").exists()) - .andExpect(jsonPath("$[0].messageCount").exists()); - } - - @Test - @DisplayName("성공: 부분 문자열 검색") - void searchChatRooms_Success_PartialMatch() throws Exception { - // given - Long userId = 1L; - chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "Spring Boot 프로젝트")); - chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "Spring Security 학습")); - chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "React 개발")); - - // when & then - mockMvc.perform(get("/api/v1/chat-rooms/search") - .param("userId", userId.toString()) - .param("keyword", "Spring")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$.length()").value(2)) - .andExpect(jsonPath("$[0].title").value("Spring Boot 프로젝트")) - .andExpect(jsonPath("$[1].title").value("Spring Security 학습")); - } - - @Test - @DisplayName("성공: 검색 결과가 없는 경우 빈 배열 반환") - void searchChatRooms_Success_EmptyResult() throws Exception { - // given - Long userId = 1L; - chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "Java 스터디")); - chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "Spring Boot 프로젝트")); - - // when & then - mockMvc.perform(get("/api/v1/chat-rooms/search") - .param("userId", userId.toString()) - .param("keyword", "Python")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$.length()").value(0)); - } - - @Test - @DisplayName("성공: 사용자 격리 - 다른 사용자의 채팅방은 검색되지 않음") - void searchChatRooms_Success_UserIsolation() throws Exception { - // given - Long userId1 = 1L; - Long userId2 = 2L; - chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId1, "Java 스터디")); - chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId2, "Java 마스터")); - - // when & then - mockMvc.perform(get("/api/v1/chat-rooms/search") - .param("userId", userId1.toString()) - .param("keyword", "Java")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].title").value("Java 스터디")); - } - - @Test - @DisplayName("성공: 키워드 포함 검색") - void searchChatRooms_Success_KeywordContaining() throws Exception { - // given - Long userId = 1L; - chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "Java 스터디")); - chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "JavaScript 프로젝트")); - - // when & then - mockMvc.perform(get("/api/v1/chat-rooms/search") - .param("userId", userId.toString()) - .param("keyword", "Java")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$.length()").value(2)); - } - - @Test - @DisplayName("성공: 빈 키워드로 검색 시 모든 채팅방 반환") - void searchChatRooms_Success_EmptyKeyword() throws Exception { - // given - Long userId = 1L; - chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "채팅방 1")); - chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "채팅방 2")); - - // when & then - mockMvc.perform(get("/api/v1/chat-rooms/search") - .param("userId", userId.toString()) - .param("keyword", "")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$.length()").value(2)); - } - } - - @Nested - @DisplayName("PUT /api/v1/chat-rooms/{chatRoomId} - 채팅방 수정") - class UpdateChatRoom { - - @Test - @DisplayName("성공: 유효한 요청으로 채팅방 수정") - void updateChatRoom_Success() throws Exception { - // given - ChatRoomRequest.Create createRequest = new ChatRoomRequest.Create(1L, "원본 제목"); - ChatRoomResponse.Detail createdChatRoom = chatRoomService.createChatRoom(createRequest); - - ChatRoomRequest.Update updateRequest = new ChatRoomRequest.Update("수정된 제목"); - - // when & then - mockMvc.perform(put("/api/v1/chat-rooms/{chatRoomId}", createdChatRoom.id()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(updateRequest))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(createdChatRoom.id().toString())) - .andExpect(jsonPath("$.title").value("수정된 제목")) - .andExpect(jsonPath("$.userId").value(1L)); - } - - @Test - @DisplayName("실패: title이 비어있는 경우") - void updateChatRoom_Fail_TitleBlank() throws Exception { - // given - ChatRoomRequest.Create createRequest = new ChatRoomRequest.Create(1L, "원본 제목"); - ChatRoomResponse.Detail createdChatRoom = chatRoomService.createChatRoom(createRequest); - - ChatRoomRequest.Update updateRequest = new ChatRoomRequest.Update(""); - - // when & then - mockMvc.perform(put("/api/v1/chat-rooms/{chatRoomId}", createdChatRoom.id()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(updateRequest))) - .andDo(print()) - .andExpect(status().isBadRequest()); - } - - @Test - @DisplayName("실패: 존재하지 않는 채팅방 수정") - void updateChatRoom_Fail_NotFound() throws Exception { - // given - UUID nonExistentId = UUID.randomUUID(); - ChatRoomRequest.Update updateRequest = new ChatRoomRequest.Update("수정된 제목"); - - // when & then - mockMvc.perform(put("/api/v1/chat-rooms/{chatRoomId}", nonExistentId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(updateRequest))) - .andDo(print()) - .andExpect(status().isNotFound()); - } - } - - @Nested - @DisplayName("DELETE /api/v1/chat-rooms/{chatRoomId} - 채팅방 삭제") - class DeleteChatRoom { - - @Test - @DisplayName("성공: 존재하는 채팅방 삭제") - void deleteChatRoom_Success() throws Exception { - // given - ChatRoomRequest.Create createRequest = new ChatRoomRequest.Create(1L, "삭제할 채팅방"); - ChatRoomResponse.Detail createdChatRoom = chatRoomService.createChatRoom(createRequest); - - // when & then - mockMvc.perform(delete("/api/v1/chat-rooms/{chatRoomId}", createdChatRoom.id())) - .andDo(print()) - .andExpect(status().isNoContent()); - - // 삭제 확인 - mockMvc.perform(get("/api/v1/chat-rooms/{chatRoomId}", createdChatRoom.id())) - .andExpect(status().isNotFound()); - } - - @Test - @DisplayName("실패: 존재하지 않는 채팅방 삭제") - void deleteChatRoom_Fail_NotFound() throws Exception { - // given - UUID nonExistentId = UUID.randomUUID(); - - // when & then - mockMvc.perform(delete("/api/v1/chat-rooms/{chatRoomId}", nonExistentId)) - .andDo(print()) - .andExpect(status().isNotFound()); - } - } -} diff --git a/chat_service/src/test/java/com/synapse/chat_service/controller/MessageControllerTest.java b/chat_service/src/test/java/com/synapse/chat_service/controller/MessageControllerTest.java index 5596d52..3699b27 100644 --- a/chat_service/src/test/java/com/synapse/chat_service/controller/MessageControllerTest.java +++ b/chat_service/src/test/java/com/synapse/chat_service/controller/MessageControllerTest.java @@ -1,10 +1,10 @@ package com.synapse.chat_service.controller; import com.fasterxml.jackson.databind.ObjectMapper; -import com.synapse.chat_service.domain.entity.ChatRoom; +import com.synapse.chat_service.domain.entity.Conversation; import com.synapse.chat_service.domain.entity.Message; import com.synapse.chat_service.domain.entity.enums.SenderType; -import com.synapse.chat_service.domain.repository.ChatRoomRepository; +import com.synapse.chat_service.domain.repository.ConversationRepository; import com.synapse.chat_service.domain.repository.MessageRepository; import com.synapse.chat_service.dto.request.MessageRequest; import com.synapse.chat_service.testutil.TestObjectFactory; @@ -19,11 +19,8 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; -import java.util.UUID; - import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @SpringBootTest @AutoConfigureMockMvc @@ -38,22 +35,22 @@ class MessageControllerTest { private ObjectMapper objectMapper; @Autowired - private ChatRoomRepository chatRoomRepository; + private ConversationRepository conversationRepository; @Autowired private MessageRepository messageRepository; - private ChatRoom testChatRoom; + private Conversation testConversation; private Message testMessage; @BeforeEach void setUp() { - // 테스트용 채팅방 생성 - testChatRoom = TestObjectFactory.createChatRoom(1L, "테스트 채팅방"); - testChatRoom = chatRoomRepository.save(testChatRoom); + // 테스트용 대화 생성 + testConversation = TestObjectFactory.createConversation(1L); + testConversation = conversationRepository.save(testConversation); // 테스트용 메시지 생성 - testMessage = TestObjectFactory.createUserMessage(testChatRoom, "테스트 메시지"); + testMessage = TestObjectFactory.createUserMessage(testConversation, "테스트 메시지"); testMessage = messageRepository.save(testMessage); } @@ -66,7 +63,7 @@ class CreateMessage { void createMessage_Success() throws Exception { // given MessageRequest.Create request = new MessageRequest.Create( - testChatRoom.getId(), + testConversation.getUserId(), SenderType.USER, "새로운 메시지" ); @@ -77,7 +74,7 @@ void createMessage_Success() throws Exception { .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.id").exists()) - .andExpect(jsonPath("$.chatRoomId").value(testChatRoom.getId().toString())) + .andExpect(jsonPath("$.conversationId").value(testConversation.getId().toString())) .andExpect(jsonPath("$.senderType").value("USER")) .andExpect(jsonPath("$.content").value("새로운 메시지")) .andExpect(jsonPath("$.createdDate").exists()) @@ -85,8 +82,8 @@ void createMessage_Success() throws Exception { } @Test - @DisplayName("실패: 채팅방 ID가 null인 경우") - void createMessage_Fail_NullChatRoomId() throws Exception { + @DisplayName("실패: 사용자 ID가 null인 경우") + void createMessage_Fail_NullUserId() throws Exception { // given MessageRequest.Create request = new MessageRequest.Create( null, @@ -106,7 +103,7 @@ void createMessage_Fail_NullChatRoomId() throws Exception { void createMessage_Fail_NullSenderType() throws Exception { // given MessageRequest.Create request = new MessageRequest.Create( - testChatRoom.getId(), + testConversation.getUserId(), null, "메시지 내용" ); @@ -123,7 +120,7 @@ void createMessage_Fail_NullSenderType() throws Exception { void createMessage_Fail_BlankContent() throws Exception { // given MessageRequest.Create request = new MessageRequest.Create( - testChatRoom.getId(), + testConversation.getUserId(), SenderType.USER, "" ); @@ -136,21 +133,22 @@ void createMessage_Fail_BlankContent() throws Exception { } @Test - @DisplayName("실패: 존재하지 않는 채팅방 ID") - void createMessage_Fail_ChatRoomNotFound() throws Exception { + @DisplayName("성공: 새로운 사용자 ID로 대화 생성") + void createMessage_Success_NewUser() throws Exception { // given - UUID nonExistentChatRoomId = UUID.randomUUID(); + Long newUserId = 999L; MessageRequest.Create request = new MessageRequest.Create( - nonExistentChatRoomId, + newUserId, SenderType.USER, - "메시지 내용" + "새 사용자의 첫 메시지" ); // when & then mockMvc.perform(post("/api/v1/messages") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isNotFound()); + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.content").value("새 사용자의 첫 메시지")); } } @@ -165,7 +163,7 @@ void getMessage_Success() throws Exception { mockMvc.perform(get("/api/v1/messages/{messageId}", testMessage.getId())) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(testMessage.getId())) - .andExpect(jsonPath("$.chatRoomId").value(testChatRoom.getId().toString())) + .andExpect(jsonPath("$.conversationId").value(testConversation.getId().toString())) .andExpect(jsonPath("$.senderType").value("USER")) .andExpect(jsonPath("$.content").value("테스트 메시지")) .andExpect(jsonPath("$.createdDate").exists()) @@ -184,284 +182,7 @@ void getMessage_Fail_NotFound() throws Exception { } } - @Nested - @DisplayName("GET /api/v1/messages/chat-room/{chatRoomId} - 채팅방별 메시지 목록 조회") - class GetMessagesByChatRoomId { - - @Test - @DisplayName("성공: 채팅방의 메시지 목록 조회") - void getMessagesByChatRoomId_Success() throws Exception { - // when & then - mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}", testChatRoom.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$[0].id").value(testMessage.getId())) - .andExpect(jsonPath("$[0].chatRoomId").value(testChatRoom.getId().toString())) - .andExpect(jsonPath("$[0].senderType").value("USER")) - .andExpect(jsonPath("$[0].content").value("테스트 메시지")); - } - - @Test - @DisplayName("실패: 존재하지 않는 채팅방 ID") - void getMessagesByChatRoomId_Fail_ChatRoomNotFound() throws Exception { - // given - UUID nonExistentChatRoomId = UUID.randomUUID(); - - // when & then - mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}", nonExistentChatRoomId)) - .andExpect(status().isNotFound()); - } - } - - @Nested - @DisplayName("GET /api/v1/messages/chat-room/{chatRoomId}/paging - 채팅방별 메시지 페이징 조회") - class GetMessagesByChatRoomIdWithPaging { - - @Test - @DisplayName("성공: 기본 페이징 파라미터로 조회") - void getMessagesByChatRoomIdWithPaging_Success_DefaultParams() throws Exception { - // when & then - mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/paging", testChatRoom.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content[0].id").value(testMessage.getId())) - .andExpect(jsonPath("$.totalElements").value(1)) - .andExpect(jsonPath("$.totalPages").value(1)) - .andExpect(jsonPath("$.size").value(50)) - .andExpect(jsonPath("$.number").value(0)); - } - - @Test - @DisplayName("성공: 커스텀 페이징 파라미터로 조회") - void getMessagesByChatRoomIdWithPaging_Success_CustomParams() throws Exception { - // when & then - mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/paging", testChatRoom.getId()) - .param("page", "0") - .param("size", "10") - .param("sort", "createdDate,desc")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.size").value(10)) - .andExpect(jsonPath("$.number").value(0)); - } - } - - @Nested - @DisplayName("GET /api/v1/messages/chat-room/{chatRoomId}/recent - 최신순 메시지 페이징 조회") - class GetRecentMessages { - - @Test - @DisplayName("성공: 메시지가 createdDate 기준 내림차순(DESC)으로 정렬되어 반환") - void getRecentMessages_Success_DescendingOrder() throws Exception { - // given - // 추가 메시지들을 생성하여 정렬 테스트 - Message message1 = Message.builder() - .chatRoom(testChatRoom) - .senderType(SenderType.USER) - .content("첫 번째 메시지") - .build(); - Message message2 = Message.builder() - .chatRoom(testChatRoom) - .senderType(SenderType.USER) - .content("두 번째 메시지") - .build(); - Message message3 = Message.builder() - .chatRoom(testChatRoom) - .senderType(SenderType.USER) - .content("세 번째 메시지") - .build(); - - messageRepository.save(message1); - Thread.sleep(10); // 시간 차이를 위한 짧은 대기 - messageRepository.save(message2); - Thread.sleep(10); - messageRepository.save(message3); - - // when & then - mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/recent", testChatRoom.getId()) - .param("page", "0") - .param("size", "10")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(4)) // 기존 testMessage + 3개 추가 - .andExpect(jsonPath("$.content[0].content").value("세 번째 메시지")) // 가장 최신 - .andExpect(jsonPath("$.content[1].content").value("두 번째 메시지")) - .andExpect(jsonPath("$.content[2].content").value("첫 번째 메시지")) - .andExpect(jsonPath("$.content[3].content").value("테스트 메시지")) // 가장 오래된 - .andExpect(jsonPath("$.totalElements").value(4)) - .andExpect(jsonPath("$.totalPages").value(1)) - .andExpect(jsonPath("$.size").value(10)) - .andExpect(jsonPath("$.number").value(0)) - .andExpect(jsonPath("$.first").value(true)) - .andExpect(jsonPath("$.last").value(true)); - } - - @Test - @DisplayName("성공: 페이징 정보가 정확한지 확인") - void getRecentMessages_Success_PagingInfo() throws Exception { - // given - // 페이징 테스트를 위해 여러 메시지 생성 - for (int i = 1; i <= 15; i++) { - Message message = Message.builder() - .chatRoom(testChatRoom) - .senderType(SenderType.USER) - .content("메시지 " + i) - .build(); - messageRepository.save(message); - Thread.sleep(5); // 시간 차이를 위한 짧은 대기 - } - - // when & then - 첫 번째 페이지 (size=5) - mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/recent", testChatRoom.getId()) - .param("page", "0") - .param("size", "5")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(5)) - .andExpect(jsonPath("$.totalElements").value(16)) // 기존 testMessage + 15개 추가 - .andExpect(jsonPath("$.totalPages").value(4)) // 16개 / 5 = 4페이지 - .andExpect(jsonPath("$.size").value(5)) - .andExpect(jsonPath("$.number").value(0)) - .andExpect(jsonPath("$.first").value(true)) - .andExpect(jsonPath("$.last").value(false)); - // when & then - 두 번째 페이지 - mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/recent", testChatRoom.getId()) - .param("page", "1") - .param("size", "5")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(5)) - .andExpect(jsonPath("$.number").value(1)) - .andExpect(jsonPath("$.first").value(false)) - .andExpect(jsonPath("$.last").value(false)); - - // when & then - 마지막 페이지 - mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/recent", testChatRoom.getId()) - .param("page", "3") - .param("size", "5")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(1)) // 마지막 페이지는 1개만 - .andExpect(jsonPath("$.number").value(3)) - .andExpect(jsonPath("$.first").value(false)) - .andExpect(jsonPath("$.last").value(true)); - } - - @Test - @DisplayName("성공: 기본 페이징 파라미터 적용") - void getRecentMessages_Success_DefaultParams() throws Exception { - // when & then - mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/recent", testChatRoom.getId())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.size").value(50)) // 기본 size - .andExpect(jsonPath("$.number").value(0)) // 기본 page - .andExpect(jsonPath("$.first").value(true)) - .andExpect(jsonPath("$.last").value(true)); - } - - @Test - @DisplayName("성공: 빈 채팅방의 경우 빈 페이지 반환") - void getRecentMessages_Success_EmptyResult() throws Exception { - // given - ChatRoom emptyChatRoom = ChatRoom.builder() - .userId(1L) - .title("빈 채팅방") - .build(); - emptyChatRoom = chatRoomRepository.save(emptyChatRoom); - - // when & then - mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/recent", emptyChatRoom.getId())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(0)) - .andExpect(jsonPath("$.totalElements").value(0)) - .andExpect(jsonPath("$.totalPages").value(0)) - .andExpect(jsonPath("$.first").value(true)) - .andExpect(jsonPath("$.last").value(true)); - } - - @Test - @DisplayName("실패: 존재하지 않는 채팅방 ID") - void getRecentMessages_Fail_ChatRoomNotFound() throws Exception { - // given - UUID nonExistentChatRoomId = UUID.randomUUID(); - - // when & then - mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/recent", nonExistentChatRoomId)) - .andDo(print()) - .andExpect(status().isNotFound()); - } - - @Test - @DisplayName("성공: 다양한 SenderType 메시지 혼재 시 정렬 확인") - void getRecentMessages_Success_MixedSenderTypes() throws Exception { - // given - Message userMessage = Message.builder() - .chatRoom(testChatRoom) - .senderType(SenderType.USER) - .content("사용자 메시지") - .build(); - Message assistantMessage = Message.builder() - .chatRoom(testChatRoom) - .senderType(SenderType.ASSISTANT) - .content("어시스턴트 메시지") - .build(); - - messageRepository.save(userMessage); - Thread.sleep(10); - messageRepository.save(assistantMessage); - - // when & then - mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/recent", testChatRoom.getId()) - .param("page", "0") - .param("size", "10")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(3)) - .andExpect(jsonPath("$.content[0].content").value("어시스턴트 메시지")) - .andExpect(jsonPath("$.content[0].senderType").value("ASSISTANT")) - .andExpect(jsonPath("$.content[1].content").value("사용자 메시지")) - .andExpect(jsonPath("$.content[1].senderType").value("USER")) - .andExpect(jsonPath("$.content[2].content").value("테스트 메시지")) - .andExpect(jsonPath("$.content[2].senderType").value("USER")); - } - } - - @Nested - @DisplayName("GET /api/v1/messages/chat-room/{chatRoomId}/search - 메시지 내용 검색") - class SearchMessages { - - @Test - @DisplayName("성공: 키워드로 메시지 검색") - void searchMessages_Success() throws Exception { - // when & then - mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/search", testChatRoom.getId()) - .param("keyword", "테스트")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$[0].content").value("테스트 메시지")); - } - - @Test - @DisplayName("성공: 존재하지 않는 키워드 검색 (빈 결과)") - void searchMessages_Success_NoResults() throws Exception { - // when & then - mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/search", testChatRoom.getId()) - .param("keyword", "존재하지않는키워드")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$").isArray()) - .andExpect(jsonPath("$").isEmpty()); - } - } @Nested @DisplayName("DELETE /api/v1/messages/{messageId} - 메시지 삭제") diff --git a/chat_service/src/test/java/com/synapse/chat_service/domain/entity/ChatRoomTest.java b/chat_service/src/test/java/com/synapse/chat_service/domain/entity/ChatRoomTest.java deleted file mode 100644 index 7692937..0000000 --- a/chat_service/src/test/java/com/synapse/chat_service/domain/entity/ChatRoomTest.java +++ /dev/null @@ -1,206 +0,0 @@ -package com.synapse.chat_service.domain.entity; - -import com.synapse.chat_service.exception.commonexception.ValidException; -import com.synapse.chat_service.testutil.TestObjectFactory; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@DisplayName("ChatRoom 도메인 엔티티 테스트") -class ChatRoomTest { - - private ChatRoom chatRoom; - private final Long userId = 1L; - private final String initialTitle = "초기 채팅방 제목"; - - @BeforeEach - void setUp() { - chatRoom = TestObjectFactory.createChatRoom(userId, initialTitle); - } - - @Nested - @DisplayName("updateTitle 메소드 테스트") - class UpdateTitleTest { - - @Test - @DisplayName("성공: 유효한 새 제목으로 업데이트") - void updateTitle_Success() { - // given - String newTitle = "새로운 채팅방 제목"; - - // when - chatRoom.updateTitle(newTitle); - - // then - assertThat(chatRoom.getTitle()).isEqualTo(newTitle); - } - - @Test - @DisplayName("성공: 앞뒤 공백이 있는 제목으로 업데이트 시 trim() 적용") - void updateTitle_Success_WithWhitespace() { - // given - String newTitleWithWhitespace = " 새로운 채팅방 제목 "; - String expectedTitle = "새로운 채팅방 제목"; - - // when - chatRoom.updateTitle(newTitleWithWhitespace); - - // then - assertThat(chatRoom.getTitle()).isEqualTo(expectedTitle); - } - - @Test - @DisplayName("성공: 최대 길이(255자) 제목으로 업데이트") - void updateTitle_Success_MaxLength() { - // given - String maxLengthTitle = "a".repeat(255); - - // when - chatRoom.updateTitle(maxLengthTitle); - - // then - assertThat(chatRoom.getTitle()).isEqualTo(maxLengthTitle); - assertThat(chatRoom.getTitle().length()).isEqualTo(255); - } - - @Test - @DisplayName("성공: 한글 제목으로 업데이트") - void updateTitle_Success_Korean() { - // given - String koreanTitle = "한글 채팅방 제목입니다"; - - // when - chatRoom.updateTitle(koreanTitle); - - // then - assertThat(chatRoom.getTitle()).isEqualTo(koreanTitle); - } - - @Test - @DisplayName("실패: null 제목으로 업데이트 시 ValidException 발생") - void updateTitle_Fail_NullTitle() { - // given - String nullTitle = null; - - // when & then - ValidException exception = assertThrows(ValidException.class, () -> { - chatRoom.updateTitle(nullTitle); - }); - - assertThat(exception.getMessage()).contains("채팅방 제목은 비어있을 수 없습니다"); - assertThat(chatRoom.getTitle()).isEqualTo(initialTitle); // 기존 제목 유지 - } - - @Test - @DisplayName("실패: 빈 문자열 제목으로 업데이트 시 ValidException 발생") - void updateTitle_Fail_EmptyTitle() { - // given - String emptyTitle = ""; - - // when & then - ValidException exception = assertThrows(ValidException.class, () -> { - chatRoom.updateTitle(emptyTitle); - }); - - assertThat(exception.getMessage()).contains("채팅방 제목은 비어있을 수 없습니다"); - assertThat(chatRoom.getTitle()).isEqualTo(initialTitle); // 기존 제목 유지 - } - - @Test - @DisplayName("실패: 공백만 있는 제목으로 업데이트 시 ValidException 발생") - void updateTitle_Fail_WhitespaceOnlyTitle() { - // given - String whitespaceOnlyTitle = " "; - - // when & then - ValidException exception = assertThrows(ValidException.class, () -> { - chatRoom.updateTitle(whitespaceOnlyTitle); - }); - - assertThat(exception.getMessage()).contains("채팅방 제목은 비어있을 수 없습니다"); - assertThat(chatRoom.getTitle()).isEqualTo(initialTitle); // 기존 제목 유지 - } - - @Test - @DisplayName("실패: 255자를 초과하는 제목으로 업데이트 시 ValidException 발생") - void updateTitle_Fail_ExceedsMaxLength() { - // given - String tooLongTitle = "a".repeat(256); // 256자 - - // when & then - ValidException exception = assertThrows(ValidException.class, () -> { - chatRoom.updateTitle(tooLongTitle); - }); - - assertThat(exception.getMessage()).contains("채팅방 제목은 255자를 초과할 수 없습니다"); - assertThat(chatRoom.getTitle()).isEqualTo(initialTitle); // 기존 제목 유지 - } - - @Test - @DisplayName("실패: trim 후 255자를 초과하는 제목으로 업데이트 시 ValidException 발생") - void updateTitle_Fail_ExceedsMaxLengthAfterTrim() { - // given - String tooLongTitleWithWhitespace = " " + "a".repeat(256) + " "; // trim 후 256자 - - // when & then - ValidException exception = assertThrows(ValidException.class, () -> { - chatRoom.updateTitle(tooLongTitleWithWhitespace); - }); - - assertThat(exception.getMessage()).contains("채팅방 제목은 255자를 초과할 수 없습니다"); - assertThat(chatRoom.getTitle()).isEqualTo(initialTitle); // 기존 제목 유지 - } - - @Test - @DisplayName("경계값 테스트: trim 후 정확히 255자인 제목으로 업데이트") - void updateTitle_BoundaryTest_ExactlyMaxLengthAfterTrim() { - // given - String exactMaxLengthWithWhitespace = " " + "a".repeat(255) + " "; // trim 후 정확히 255자 - String expectedTitle = "a".repeat(255); - - // when - chatRoom.updateTitle(exactMaxLengthWithWhitespace); - - // then - assertThat(chatRoom.getTitle()).isEqualTo(expectedTitle); - assertThat(chatRoom.getTitle().length()).isEqualTo(255); - } - } - - @Nested - @DisplayName("ChatRoom 생성자 테스트") - class ConstructorTest { - - @Test - @DisplayName("성공: 유효한 파라미터로 ChatRoom 생성") - void constructor_Success() { - // given - Long testUserId = 123L; - String testTitle = "테스트 채팅방"; - - // when - ChatRoom newChatRoom = TestObjectFactory.createChatRoom(testUserId, testTitle); - - // then - assertThat(newChatRoom.getUserId()).isEqualTo(testUserId); - assertThat(newChatRoom.getTitle()).isEqualTo(testTitle); - assertThat(newChatRoom.getMessages()).isNotNull(); - assertThat(newChatRoom.getMessages()).isEmpty(); - } - - @Test - @DisplayName("성공: 빈 메시지 리스트로 초기화") - void constructor_Success_EmptyMessagesList() { - // when - ChatRoom newChatRoom = TestObjectFactory.createChatRoom(1L, "테스트"); - - // then - assertThat(newChatRoom.getMessages()).isNotNull(); - assertThat(newChatRoom.getMessages()).hasSize(0); - } - } -} diff --git a/chat_service/src/test/java/com/synapse/chat_service/domain/entity/MessageTest.java b/chat_service/src/test/java/com/synapse/chat_service/domain/entity/MessageTest.java index 3a71bc4..d51546a 100644 --- a/chat_service/src/test/java/com/synapse/chat_service/domain/entity/MessageTest.java +++ b/chat_service/src/test/java/com/synapse/chat_service/domain/entity/MessageTest.java @@ -14,15 +14,15 @@ @DisplayName("Message 도메인 엔티티 테스트") class MessageTest { - private ChatRoom chatRoom; + private Conversation conversation; private Message message; private final String initialContent = "초기 메시지 내용"; @BeforeEach void setUp() { - chatRoom = TestObjectFactory.createChatRoom(1L, "테스트 채팅방"); + conversation = TestObjectFactory.createConversation(1L); - message = TestObjectFactory.createUserMessage(chatRoom, initialContent); + message = TestObjectFactory.createUserMessage(conversation, initialContent); } @Nested @@ -181,10 +181,10 @@ void constructor_Success() { String testContent = "테스트 메시지 내용"; // when - Message newMessage = TestObjectFactory.createAssistantMessage(chatRoom, testContent); + Message newMessage = TestObjectFactory.createAssistantMessage(conversation, testContent); // then - assertThat(newMessage.getChatRoom()).isEqualTo(chatRoom); + assertThat(newMessage.getConversation()).isEqualTo(conversation); assertThat(newMessage.getSenderType()).isEqualTo(SenderType.ASSISTANT); assertThat(newMessage.getContent()).isEqualTo(testContent); } @@ -196,7 +196,7 @@ void constructor_Success_UserType() { String testContent = "사용자 메시지"; // when - Message userMessage = TestObjectFactory.createUserMessage(chatRoom, testContent); + Message userMessage = TestObjectFactory.createUserMessage(conversation, testContent); // then assertThat(userMessage.getSenderType()).isEqualTo(SenderType.USER); @@ -210,7 +210,7 @@ void constructor_Success_AIType() { String testContent = "AI 응답 메시지"; // when - Message aiMessage = TestObjectFactory.createAssistantMessage(chatRoom, testContent); + Message aiMessage = TestObjectFactory.createAssistantMessage(conversation, testContent); // then assertThat(aiMessage.getSenderType()).isEqualTo(SenderType.ASSISTANT); @@ -226,7 +226,7 @@ void constructor_Fail_NullContent() { // when & then ValidException exception = assertThrows(ValidException.class, () -> { Message.builder() - .chatRoom(chatRoom) + .conversation(conversation) .senderType(SenderType.USER) .content(nullContent) .build(); @@ -244,7 +244,7 @@ void constructor_Fail_EmptyContent() { // when & then ValidException exception = assertThrows(ValidException.class, () -> { Message.builder() - .chatRoom(chatRoom) + .conversation(conversation) .senderType(SenderType.USER) .content(emptyContent) .build(); @@ -262,7 +262,7 @@ void constructor_Fail_WhitespaceOnlyContent() { // when & then ValidException exception = assertThrows(ValidException.class, () -> { Message.builder() - .chatRoom(chatRoom) + .conversation(conversation) .senderType(SenderType.USER) .content(whitespaceOnlyContent) .build(); @@ -280,7 +280,7 @@ void constructor_Fail_ExceedsMaxLength() { // when & then ValidException exception = assertThrows(ValidException.class, () -> { Message.builder() - .chatRoom(chatRoom) + .conversation(conversation) .senderType(SenderType.USER) .content(tooLongContent) .build(); @@ -296,7 +296,7 @@ void constructor_Success_MaxLength() { String maxLengthContent = "a".repeat(1000); // when - Message newMessage = TestObjectFactory.createUserMessage(chatRoom, maxLengthContent); + Message newMessage = TestObjectFactory.createUserMessage(conversation, maxLengthContent); // then assertThat(newMessage.getContent()).isEqualTo(maxLengthContent); @@ -310,7 +310,7 @@ void constructor_BoundaryTest_ExactlyMaxLength() { String exactMaxLengthContent = "b".repeat(1000); // when - Message newMessage = TestObjectFactory.createAssistantMessage(chatRoom, exactMaxLengthContent); + Message newMessage = TestObjectFactory.createAssistantMessage(conversation, exactMaxLengthContent); // then assertThat(newMessage.getContent()).isEqualTo(exactMaxLengthContent); diff --git a/chat_service/src/test/java/com/synapse/chat_service/repository/ChatRoomRepositoryTest.java b/chat_service/src/test/java/com/synapse/chat_service/repository/ChatRoomRepositoryTest.java deleted file mode 100644 index 5afd505..0000000 --- a/chat_service/src/test/java/com/synapse/chat_service/repository/ChatRoomRepositoryTest.java +++ /dev/null @@ -1,355 +0,0 @@ -package com.synapse.chat_service.repository; - -import com.synapse.chat_service.domain.entity.ChatRoom; -import com.synapse.chat_service.domain.entity.Message; -import com.synapse.chat_service.domain.entity.enums.SenderType; -import com.synapse.chat_service.domain.repository.ChatRoomRepository; -import com.synapse.chat_service.domain.repository.MessageRepository; -import com.synapse.chat_service.testutil.TestObjectFactory; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.test.context.ActiveProfiles; - - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; - -@DataJpaTest -@ActiveProfiles("test") -@DisplayName("ChatRoomRepository 단위 테스트") -class ChatRoomRepositoryTest { - - @Autowired - private TestEntityManager entityManager; - - @Autowired - private ChatRoomRepository chatRoomRepository; - - @Autowired - private MessageRepository messageRepository; - - private Long userId1; - private Long userId2; - private ChatRoom chatRoom1; - private ChatRoom chatRoom2; - private ChatRoom chatRoom3; - private ChatRoom chatRoom4; - - @BeforeEach - void setUp() throws Exception { - userId1 = 1L; - userId2 = 2L; - - // 테스트용 ChatRoom 데이터 생성 - chatRoom1 = TestObjectFactory.createChatRoomWithCreatedDate(userId1, "자바 스터디", LocalDateTime.now().minusDays(3)); - chatRoom2 = TestObjectFactory.createChatRoomWithCreatedDate(userId1, "스프링 부트 학습", LocalDateTime.now().minusDays(2)); - chatRoom3 = TestObjectFactory.createChatRoomWithCreatedDate(userId1, "리액트 프로젝트", LocalDateTime.now().minusDays(1)); - chatRoom4 = TestObjectFactory.createChatRoomWithCreatedDate(userId2, "파이썬 기초", LocalDateTime.now()); - - // 데이터베이스에 저장 - entityManager.persistAndFlush(chatRoom1); - entityManager.persistAndFlush(chatRoom2); - entityManager.persistAndFlush(chatRoom3); - entityManager.persistAndFlush(chatRoom4); - } - - - - @Nested - @DisplayName("findByUserIdAndTitleContaining 테스트") - class FindByUserIdAndTitleContainingTest { - - @Test - @DisplayName("성공: 특정 사용자의 제목에 키워드가 포함된 채팅방 조회") - void findByUserIdAndTitleContaining_Success() { - // given - String keyword = "스"; - - // when - List result = chatRoomRepository.findByUserIdAndTitleContaining(userId1, keyword); - - // then - assertThat(result).hasSize(2); - assertThat(result).extracting(ChatRoom::getTitle) - .containsExactlyInAnyOrder("자바 스터디", "스프링 부트 학습"); - assertThat(result).allMatch(chatRoom -> chatRoom.getUserId().equals(userId1)); - } - - @Test - @DisplayName("성공: 키워드가 정확히 일치하는 경우") - void findByUserIdAndTitleContaining_ExactMatch() { - // given - String keyword = "자바"; - - // when - List result = chatRoomRepository.findByUserIdAndTitleContaining(userId1, keyword); - - // then - assertThat(result).hasSize(1); - assertThat(result.get(0).getTitle()).isEqualTo("자바 스터디"); - assertThat(result.get(0).getUserId()).isEqualTo(userId1); - } - - @Test - @DisplayName("성공: 검색 결과가 없는 경우 빈 리스트 반환") - void findByUserIdAndTitleContaining_EmptyResult() { - // given - String keyword = "존재하지않는키워드"; - - // when - List result = chatRoomRepository.findByUserIdAndTitleContaining(userId1, keyword); - - // then - assertThat(result).isEmpty(); - } - - @Test - @DisplayName("성공: 다른 사용자의 채팅방은 검색되지 않음") - void findByUserIdAndTitleContaining_DifferentUser() { - // given - String keyword = "파이썬"; - - // when - List result = chatRoomRepository.findByUserIdAndTitleContaining(userId1, keyword); - - // then - assertThat(result).isEmpty(); // userId1에는 파이썬 관련 채팅방이 없음 - } - - @Test - @DisplayName("성공: 대소문자 구분 없이 검색") - void findByUserIdAndTitleContaining_CaseInsensitive() { - // given - String keyword = "JAVA"; - - // when - List result = chatRoomRepository.findByUserIdAndTitleContaining(userId1, keyword); - - // then - // 한글 제목이므로 대소문자 테스트는 영문 제목으로 추가 데이터 생성 필요 - // 현재는 빈 결과 확인 - assertThat(result).isEmpty(); - } - } - - @Nested - @DisplayName("findByUserIdOrderByCreatedDateDesc 테스트") - class FindByUserIdOrderByCreatedDateDescTest { - - @Test - @DisplayName("성공: 특정 사용자의 채팅방을 생성일 기준 내림차순으로 페이징 조회") - void findByUserIdOrderByCreatedDateDesc_Success() { - // given - Pageable pageable = PageRequest.of(0, 2); - - // when - Page result = chatRoomRepository.findByUserIdOrderByCreatedDateDesc(userId1, pageable); - - // then - assertThat(result.getContent()).hasSize(2); - assertThat(result.getTotalElements()).isEqualTo(3); // userId1의 총 채팅방 개수 - assertThat(result.getTotalPages()).isEqualTo(2); // 총 페이지 수 (3개를 2개씩 나누면 2페이지) - assertThat(result.getNumber()).isEqualTo(0); // 현재 페이지 번호 - assertThat(result.getSize()).isEqualTo(2); // 페이지 크기 - assertThat(result.isFirst()).isTrue(); - assertThat(result.isLast()).isFalse(); - - // 생성일 기준 내림차순 정렬 확인 (최신순) - List content = result.getContent(); - // 첫 번째가 두 번째보다 더 최신이거나 같아야 함 - assertThat(content.get(0).getCreatedDate()).isAfterOrEqualTo(content.get(1).getCreatedDate()); - } - - @Test - @DisplayName("성공: 두 번째 페이지 조회") - void findByUserIdOrderByCreatedDateDesc_SecondPage() { - // given - Pageable pageable = PageRequest.of(1, 2); - - // when - Page result = chatRoomRepository.findByUserIdOrderByCreatedDateDesc(userId1, pageable); - - // then - assertThat(result.getContent()).hasSize(1); - assertThat(result.getTotalElements()).isEqualTo(3); - assertThat(result.getTotalPages()).isEqualTo(2); - assertThat(result.getNumber()).isEqualTo(1); - assertThat(result.isFirst()).isFalse(); - assertThat(result.isLast()).isTrue(); - - // 가장 오래된 채팅방 (두 번째 페이지이므로 첫 번째 페이지보다 오래된 것) - // 실제 데이터 검증보다는 페이징이 올바르게 동작하는지 확인 - assertThat(result.getContent().get(0)).isNotNull(); - } - - @Test - @DisplayName("성공: 채팅방이 없는 사용자의 경우 빈 페이지 반환") - void findByUserIdOrderByCreatedDateDesc_EmptyResult() { - // given - Long nonExistentUserId = 999L; - Pageable pageable = PageRequest.of(0, 10); - - // when - Page result = chatRoomRepository.findByUserIdOrderByCreatedDateDesc(nonExistentUserId, pageable); - - // then - assertThat(result.getContent()).isEmpty(); - assertThat(result.getTotalElements()).isEqualTo(0); - assertThat(result.getTotalPages()).isEqualTo(0); - assertThat(result.isEmpty()).isTrue(); - } - - @Test - @DisplayName("성공: 페이지 크기가 전체 데이터보다 큰 경우") - void findByUserIdOrderByCreatedDateDesc_LargePageSize() { - // given - Pageable pageable = PageRequest.of(0, 10); - - // when - Page result = chatRoomRepository.findByUserIdOrderByCreatedDateDesc(userId1, pageable); - - // then - assertThat(result.getContent()).hasSize(3); // 실제 데이터 개수 - assertThat(result.getTotalElements()).isEqualTo(3); - assertThat(result.getTotalPages()).isEqualTo(1); - assertThat(result.isFirst()).isTrue(); - assertThat(result.isLast()).isTrue(); - - // 정렬 순서 확인 - List content = result.getContent(); - assertThat(content.get(0).getTitle()).isEqualTo("리액트 프로젝트"); - assertThat(content.get(1).getTitle()).isEqualTo("스프링 부트 학습"); - assertThat(content.get(2).getTitle()).isEqualTo("자바 스터디"); - } - } - - @Nested - @DisplayName("연관관계 영속성 테스트 (CascadeType.ALL)") - class CascadePersistenceTest { - - @Test - @DisplayName("성공: 채팅방 삭제 시 연관된 메시지들이 함께 삭제된다 (CascadeType.ALL)") - void deleteChatRoom_CascadeDeleteMessages_Success() { - // given - // 테스트용 채팅방 생성 - ChatRoom testChatRoom = ChatRoom.builder() - .title("테스트 채팅방") - .userId(userId1) - .build(); - entityManager.persistAndFlush(testChatRoom); - - // 해당 채팅방에 여러 메시지 생성 (CascadeType.ALL을 활용하여 ChatRoom을 통해 저장) - Message message1 = Message.builder() - .chatRoom(testChatRoom) - .senderType(SenderType.USER) - .content("첫 번째 메시지") - .build(); - - Message message2 = Message.builder() - .chatRoom(testChatRoom) - .senderType(SenderType.ASSISTANT) - .content("두 번째 메시지") - .build(); - - Message message3 = Message.builder() - .chatRoom(testChatRoom) - .senderType(SenderType.USER) - .content("세 번째 메시지") - .build(); - - // 양방향 관계 설정: ChatRoom의 messages 컬렉션에 추가 - testChatRoom.getMessages().add(message1); - testChatRoom.getMessages().add(message2); - testChatRoom.getMessages().add(message3); - - // CascadeType.ALL로 인해 ChatRoom 저장 시 Message들도 함께 저장됨 - entityManager.persistAndFlush(testChatRoom); - - // 메시지가 정상적으로 저장되었는지 확인 - long initialMessageCount = messageRepository.countByChatRoomId(testChatRoom.getId()); - assertThat(initialMessageCount).isEqualTo(3); - - // when - // 채팅방 삭제 - chatRoomRepository.delete(testChatRoom); - entityManager.flush(); - entityManager.clear(); - - // then - // 채팅방이 삭제되었는지 확인 - Optional deletedChatRoom = chatRoomRepository.findById(testChatRoom.getId()); - assertThat(deletedChatRoom).isEmpty(); - - // 연관된 메시지들도 함께 삭제되었는지 확인 (CascadeType.ALL 검증) - long remainingMessageCount = messageRepository.countByChatRoomId(testChatRoom.getId()); - assertThat(remainingMessageCount).isEqualTo(0); - } - - @Test - @DisplayName("성공: 다른 채팅방의 메시지는 영향받지 않는다") - void deleteChatRoom_OtherChatRoomMessagesUnaffected_Success() { - // given - // 첫 번째 채팅방과 메시지 - ChatRoom chatRoom1 = ChatRoom.builder() - .title("삭제될 채팅방") - .userId(userId1) - .build(); - - Message messageToDelete = Message.builder() - .chatRoom(chatRoom1) - .senderType(SenderType.USER) - .content("삭제될 메시지") - .build(); - - // 양방향 관계 설정 - chatRoom1.getMessages().add(messageToDelete); - entityManager.persistAndFlush(chatRoom1); - - // 두 번째 채팅방과 메시지 (영향받지 않아야 함) - ChatRoom chatRoom2 = ChatRoom.builder() - .title("유지될 채팅방") - .userId(userId1) - .build(); - - Message messageToKeep = Message.builder() - .chatRoom(chatRoom2) - .senderType(SenderType.USER) - .content("유지될 메시지") - .build(); - - // 양방향 관계 설정 - chatRoom2.getMessages().add(messageToKeep); - entityManager.persistAndFlush(chatRoom2); - - // 초기 상태 확인 - assertThat(messageRepository.countByChatRoomId(chatRoom1.getId())).isEqualTo(1); - assertThat(messageRepository.countByChatRoomId(chatRoom2.getId())).isEqualTo(1); - - // when - // 첫 번째 채팅방만 삭제 - chatRoomRepository.delete(chatRoom1); - entityManager.flush(); - entityManager.clear(); - - // then - // 첫 번째 채팅방과 그 메시지는 삭제됨 - assertThat(chatRoomRepository.findById(chatRoom1.getId())).isEmpty(); - assertThat(messageRepository.countByChatRoomId(chatRoom1.getId())).isEqualTo(0); - - // 두 번째 채팅방과 그 메시지는 유지됨 - assertThat(chatRoomRepository.findById(chatRoom2.getId())).isPresent(); - assertThat(messageRepository.countByChatRoomId(chatRoom2.getId())).isEqualTo(1); - } - } -} diff --git a/chat_service/src/test/java/com/synapse/chat_service/repository/MessageRepositoryTest.java b/chat_service/src/test/java/com/synapse/chat_service/repository/MessageRepositoryTest.java index 8f13d71..7bfc489 100644 --- a/chat_service/src/test/java/com/synapse/chat_service/repository/MessageRepositoryTest.java +++ b/chat_service/src/test/java/com/synapse/chat_service/repository/MessageRepositoryTest.java @@ -1,6 +1,6 @@ package com.synapse.chat_service.repository; -import com.synapse.chat_service.domain.entity.ChatRoom; +import com.synapse.chat_service.domain.entity.Conversation; import com.synapse.chat_service.domain.entity.Message; import com.synapse.chat_service.domain.entity.enums.SenderType; import com.synapse.chat_service.domain.repository.MessageRepository; @@ -17,7 +17,6 @@ import org.springframework.data.domain.Pageable; import org.springframework.test.context.ActiveProfiles; - import java.time.LocalDateTime; import java.util.List; import java.util.UUID; @@ -35,8 +34,8 @@ class MessageRepositoryTest { @Autowired private MessageRepository messageRepository; - private ChatRoom chatRoom1; - private ChatRoom chatRoom2; + private Conversation conversation1; + private Conversation conversation2; private Message message1; private Message message2; private Message message3; @@ -45,19 +44,22 @@ class MessageRepositoryTest { @BeforeEach void setUp() { - // 테스트용 ChatRoom 데이터 생성 - chatRoom1 = TestObjectFactory.createChatRoom(1L, "자바 스터디"); - chatRoom2 = TestObjectFactory.createChatRoom(2L, "스프링 부트 학습"); + // 테스트용 Conversation 데이터 생성 + conversation1 = TestObjectFactory.createConversation(1L); + conversation2 = TestObjectFactory.createConversation(2L); + + entityManager.persistAndFlush(conversation1); + entityManager.persistAndFlush(conversation2); - entityManager.persistAndFlush(chatRoom1); - entityManager.persistAndFlush(chatRoom2); + // 고정된 기준 시간 사용 (CI 환경에서의 안정성을 위해) + LocalDateTime baseTime = LocalDateTime.of(2024, 1, 1, 12, 0, 0); // 테스트용 Message 데이터 생성 - message1 = TestObjectFactory.createMessageWithCreatedDate(chatRoom1, SenderType.USER, "안녕하세요! 자바 공부를 시작해봅시다.", LocalDateTime.now().minusHours(4)); - message2 = TestObjectFactory.createMessageWithCreatedDate(chatRoom1, SenderType.ASSISTANT, "자바의 기본 문법에 대해 알아보겠습니다.", LocalDateTime.now().minusHours(3)); - message3 = TestObjectFactory.createMessageWithCreatedDate(chatRoom1, SenderType.USER, "객체지향 프로그래밍의 핵심 개념을 설명해주세요.", LocalDateTime.now().minusHours(2)); - message4 = TestObjectFactory.createMessageWithCreatedDate(chatRoom2, SenderType.USER, "스프링 부트 프로젝트를 생성하는 방법을 알려주세요.", LocalDateTime.now().minusHours(1)); - message5 = TestObjectFactory.createMessageWithCreatedDate(chatRoom2, SenderType.ASSISTANT, "Spring Initializr를 사용하여 프로젝트를 생성할 수 있습니다.", LocalDateTime.now()); + message1 = TestObjectFactory.createMessageWithCreatedDate(conversation1, SenderType.USER, "안녕하세요! 자바 공부를 시작해봅시다.", baseTime.minusHours(4)); + message2 = TestObjectFactory.createMessageWithCreatedDate(conversation1, SenderType.ASSISTANT, "자바의 기본 문법에 대해 알아보겠습니다.", baseTime.minusHours(3)); + message3 = TestObjectFactory.createMessageWithCreatedDate(conversation1, SenderType.USER, "객체지향 프로그래밍의 핵심 개념을 설명해주세요.", baseTime.minusHours(2)); + message4 = TestObjectFactory.createMessageWithCreatedDate(conversation2, SenderType.USER, "스프링 부트 프로젝트를 생성하는 방법을 알려주세요.", baseTime.minusHours(1)); + message5 = TestObjectFactory.createMessageWithCreatedDate(conversation2, SenderType.ASSISTANT, "Spring Initializr를 사용하여 프로젝트를 생성할 수 있습니다.", baseTime); // 데이터베이스에 저장 entityManager.persistAndFlush(message1); @@ -67,20 +69,18 @@ void setUp() { entityManager.persistAndFlush(message5); } - - @Nested - @DisplayName("findByChatRoomIdAndContentContaining 테스트") - class FindByChatRoomIdAndContentContainingTest { + @DisplayName("findByConversationIdAndContentContaining 테스트") + class FindByConversationIdAndContentContainingTest { @Test - @DisplayName("성공: 특정 채팅방에서 키워드가 포함된 메시지 조회") - void findByChatRoomIdAndContentContaining_Success() { + @DisplayName("성공: 특정 대화에서 키워드가 포함된 메시지 조회") + void findByConversationIdAndContentContaining_Success() { // given String keyword = "자바"; // when - List result = messageRepository.findByChatRoomIdAndContentContaining(chatRoom1.getId(), keyword); + List result = messageRepository.findByConversationIdAndContentContaining(conversation1.getId(), keyword); // then assertThat(result).hasSize(2); @@ -89,58 +89,58 @@ void findByChatRoomIdAndContentContaining_Success() { "안녕하세요! 자바 공부를 시작해봅시다.", "자바의 기본 문법에 대해 알아보겠습니다." ); - assertThat(result).allMatch(message -> message.getChatRoom().getId().equals(chatRoom1.getId())); + assertThat(result).allMatch(message -> message.getConversation().getId().equals(conversation1.getId())); } @Test @DisplayName("성공: 키워드가 정확히 일치하는 경우") - void findByChatRoomIdAndContentContaining_ExactMatch() { + void findByConversationIdAndContentContaining_ExactMatch() { // given String keyword = "객체지향"; // when - List result = messageRepository.findByChatRoomIdAndContentContaining(chatRoom1.getId(), keyword); + List result = messageRepository.findByConversationIdAndContentContaining(conversation1.getId(), keyword); // then assertThat(result).hasSize(1); assertThat(result.get(0).getContent()).isEqualTo("객체지향 프로그래밍의 핵심 개념을 설명해주세요."); - assertThat(result.get(0).getChatRoom().getId()).isEqualTo(chatRoom1.getId()); + assertThat(result.get(0).getConversation().getId()).isEqualTo(conversation1.getId()); } @Test @DisplayName("성공: 검색 결과가 없는 경우 빈 리스트 반환") - void findByChatRoomIdAndContentContaining_EmptyResult() { + void findByConversationIdAndContentContaining_EmptyResult() { // given String keyword = "존재하지않는키워드"; // when - List result = messageRepository.findByChatRoomIdAndContentContaining(chatRoom1.getId(), keyword); + List result = messageRepository.findByConversationIdAndContentContaining(conversation1.getId(), keyword); // then assertThat(result).isEmpty(); } @Test - @DisplayName("성공: 다른 채팅방의 메시지는 검색되지 않음") - void findByChatRoomIdAndContentContaining_DifferentChatRoom() { + @DisplayName("성공: 다른 대화의 메시지는 검색되지 않음") + void findByConversationIdAndContentContaining_DifferentConversation() { // given String keyword = "스프링"; // when - List result = messageRepository.findByChatRoomIdAndContentContaining(chatRoom1.getId(), keyword); + List result = messageRepository.findByConversationIdAndContentContaining(conversation1.getId(), keyword); // then - assertThat(result).isEmpty(); // chatRoom1에는 스프링 관련 메시지가 없음 + assertThat(result).isEmpty(); // conversation1에는 스프링 관련 메시지가 없음 } @Test @DisplayName("성공: 부분 문자열 검색") - void findByChatRoomIdAndContentContaining_PartialMatch() { + void findByConversationIdAndContentContaining_PartialMatch() { // given String keyword = "프로그래밍"; // when - List result = messageRepository.findByChatRoomIdAndContentContaining(chatRoom1.getId(), keyword); + List result = messageRepository.findByConversationIdAndContentContaining(conversation1.getId(), keyword); // then assertThat(result).hasSize(1); @@ -148,64 +148,63 @@ void findByChatRoomIdAndContentContaining_PartialMatch() { } @Test - @DisplayName("성공: 여러 채팅방에서 같은 키워드 검색") - void findByChatRoomIdAndContentContaining_MultipleKeywords() { + @DisplayName("성공: 여러 대화에서 같은 키워드 검색") + void findByConversationIdAndContentContaining_MultipleKeywords() { // given String keyword = "프로젝트"; // when - List chatRoom1Result = messageRepository.findByChatRoomIdAndContentContaining(chatRoom1.getId(), keyword); - List chatRoom2Result = messageRepository.findByChatRoomIdAndContentContaining(chatRoom2.getId(), keyword); + List conversation1Result = messageRepository.findByConversationIdAndContentContaining(conversation1.getId(), keyword); + List conversation2Result = messageRepository.findByConversationIdAndContentContaining(conversation2.getId(), keyword); // then - assertThat(chatRoom1Result).isEmpty(); // chatRoom1에는 "프로젝트" 키워드가 없음 - assertThat(chatRoom2Result).hasSize(2); // chatRoom2에는 "프로젝트" 키워드가 2개 메시지에 있음 - assertThat(chatRoom2Result).extracting(Message::getContent) + assertThat(conversation1Result).isEmpty(); // conversation1에는 "프로젝트" 키워드가 없음 + assertThat(conversation2Result).hasSize(2); // conversation2에는 "프로젝트" 키워드가 2개 메시지에 있음 + assertThat(conversation2Result).extracting(Message::getContent) .allMatch(content -> content.contains("프로젝트")); } } @Nested - @DisplayName("countByChatRoomId 테스트") - class CountByChatRoomIdTest { + @DisplayName("countByConversationId 테스트") + class CountByConversationIdTest { @Test - @DisplayName("성공: 특정 채팅방의 메시지 개수 조회") - void countByChatRoomId_Success() { + @DisplayName("성공: 특정 대화의 메시지 개수 조회") + void countByConversationId_Success() { // when - long chatRoom1Count = messageRepository.countByChatRoomId(chatRoom1.getId()); - long chatRoom2Count = messageRepository.countByChatRoomId(chatRoom2.getId()); + long conversation1Count = messageRepository.countByConversationId(conversation1.getId()); + long conversation2Count = messageRepository.countByConversationId(conversation2.getId()); // then - assertThat(chatRoom1Count).isEqualTo(3); // chatRoom1에 3개의 메시지 - assertThat(chatRoom2Count).isEqualTo(2); // chatRoom2에 2개의 메시지 + assertThat(conversation1Count).isEqualTo(3); // conversation1에 3개의 메시지 + assertThat(conversation2Count).isEqualTo(2); // conversation2에 2개의 메시지 } @Test - @DisplayName("성공: 메시지가 없는 채팅방의 경우 0 반환") - void countByChatRoomId_EmptyResult() { + @DisplayName("성공: 메시지가 없는 대화의 경우 0 반환") + void countByConversationId_EmptyResult() { // given - ChatRoom emptyChatRoom = ChatRoom.builder() - .title("빈 채팅방") + Conversation emptyConversation = Conversation.builder() .userId(3L) .build(); - entityManager.persistAndFlush(emptyChatRoom); + entityManager.persistAndFlush(emptyConversation); // when - long count = messageRepository.countByChatRoomId(emptyChatRoom.getId()); + long count = messageRepository.countByConversationId(emptyConversation.getId()); // then assertThat(count).isEqualTo(0); } @Test - @DisplayName("성공: 존재하지 않는 채팅방 ID의 경우 0 반환") - void countByChatRoomId_NonExistentChatRoom() { + @DisplayName("성공: 존재하지 않는 대화 ID의 경우 0 반환") + void countByConversationId_NonExistentConversation() { // given - UUID nonExistentChatRoomId = UUID.randomUUID(); + UUID nonExistentConversationId = UUID.randomUUID(); // when - long count = messageRepository.countByChatRoomId(nonExistentChatRoomId); + long count = messageRepository.countByConversationId(nonExistentConversationId); // then assertThat(count).isEqualTo(0); @@ -213,19 +212,19 @@ void countByChatRoomId_NonExistentChatRoom() { @Test @DisplayName("성공: 메시지 추가 후 개수 증가 확인") - void countByChatRoomId_AfterAddingMessage() { + void countByConversationId_AfterAddingMessage() { // given - long initialCount = messageRepository.countByChatRoomId(chatRoom1.getId()); + long initialCount = messageRepository.countByConversationId(conversation1.getId()); Message newMessage = Message.builder() .content("새로운 메시지입니다.") .senderType(SenderType.USER) - .chatRoom(chatRoom1) + .conversation(conversation1) .build(); entityManager.persistAndFlush(newMessage); // when - long updatedCount = messageRepository.countByChatRoomId(chatRoom1.getId()); + long updatedCount = messageRepository.countByConversationId(conversation1.getId()); // then assertThat(updatedCount).isEqualTo(initialCount + 1); @@ -233,166 +232,137 @@ void countByChatRoomId_AfterAddingMessage() { } @Test - @DisplayName("성공: 다른 채팅방의 메시지는 카운트에 포함되지 않음") - void countByChatRoomId_IsolatedCount() { + @DisplayName("성공: 다른 대화의 메시지는 카운트에 포함되지 않음") + void countByConversationId_IsolatedCount() { // given - long chatRoom1InitialCount = messageRepository.countByChatRoomId(chatRoom1.getId()); - long chatRoom2InitialCount = messageRepository.countByChatRoomId(chatRoom2.getId()); + long conversation1InitialCount = messageRepository.countByConversationId(conversation1.getId()); + long conversation2InitialCount = messageRepository.countByConversationId(conversation2.getId()); - // chatRoom2에 새 메시지 추가 + // conversation2에 새 메시지 추가 Message newMessage = Message.builder() - .content("chatRoom2에 추가된 메시지") + .content("conversation2에 추가된 메시지") .senderType(SenderType.ASSISTANT) - .chatRoom(chatRoom2) + .conversation(conversation2) .build(); entityManager.persistAndFlush(newMessage); // when - long chatRoom1FinalCount = messageRepository.countByChatRoomId(chatRoom1.getId()); - long chatRoom2FinalCount = messageRepository.countByChatRoomId(chatRoom2.getId()); + long conversation1FinalCount = messageRepository.countByConversationId(conversation1.getId()); + long conversation2FinalCount = messageRepository.countByConversationId(conversation2.getId()); // then - assertThat(chatRoom1FinalCount).isEqualTo(chatRoom1InitialCount); // chatRoom1 개수는 변화 없음 - assertThat(chatRoom2FinalCount).isEqualTo(chatRoom2InitialCount + 1); // chatRoom2 개수만 증가 + assertThat(conversation1FinalCount).isEqualTo(conversation1InitialCount); // conversation1 개수는 변화 없음 + assertThat(conversation2FinalCount).isEqualTo(conversation2InitialCount + 1); // conversation2 개수만 증가 } } @Nested - @DisplayName("findByChatRoomIdOrderByCreatedDateAsc 페이징 테스트") - class FindByChatRoomIdOrderByCreatedDateAscTest { + @DisplayName("findByConversationIdOrderByCreatedDateAsc 페이징 테스트") + class FindByConversationIdOrderByCreatedDateAscTest { @Test @DisplayName("성공: 시간순(ASC) 정렬이 올바르게 동작") - void findByChatRoomIdOrderByCreatedDateAsc_Success() { + void findByConversationIdOrderByCreatedDateAsc_Success() { // given Pageable pageable = PageRequest.of(0, 10); // when - Page result = messageRepository.findByChatRoomIdOrderByCreatedDateAsc(chatRoom1.getId(), pageable); + Page result = messageRepository.findByConversationIdOrderByCreatedDateAsc(conversation1.getId(), pageable); // then assertThat(result.getContent()).hasSize(3); - assertThat(result.getTotalElements()).isEqualTo(3); - - // 시간순(ASC) 정렬 확인: 가장 오래된 것부터 (같은 시간일 수도 있으므로 isBeforeOrEqualTo 사용) - List messages = result.getContent(); - assertThat(messages.get(0).getCreatedDate()).isBeforeOrEqualTo(messages.get(1).getCreatedDate()); // 첫 번째가 더 오래되거나 같음 - assertThat(messages.get(1).getCreatedDate()).isBeforeOrEqualTo(messages.get(2).getCreatedDate()); // 두 번째가 세 번째보다 오래되거나 같음 + assertThat(result.getContent().get(0).getContent()).isEqualTo("안녕하세요! 자바 공부를 시작해봅시다."); // 가장 오래된 + assertThat(result.getContent().get(1).getContent()).isEqualTo("자바의 기본 문법에 대해 알아보겠습니다."); + assertThat(result.getContent().get(2).getContent()).isEqualTo("객체지향 프로그래밍의 핵심 개념을 설명해주세요."); // 가장 최근 } @Test - @DisplayName("성공: 페이징 처리") - void findByChatRoomIdOrderByCreatedDateAsc_Paging() { + @DisplayName("성공: 페이징이 올바르게 동작") + void findByConversationIdOrderByCreatedDateAsc_Paging() { // given - Pageable firstPage = PageRequest.of(0, 2); - Pageable secondPage = PageRequest.of(1, 2); + Pageable pageable = PageRequest.of(0, 2); // 페이지 크기 2 // when - Page firstResult = messageRepository.findByChatRoomIdOrderByCreatedDateAsc(chatRoom1.getId(), firstPage); - Page secondResult = messageRepository.findByChatRoomIdOrderByCreatedDateAsc(chatRoom1.getId(), secondPage); + Page result = messageRepository.findByConversationIdOrderByCreatedDateAsc(conversation1.getId(), pageable); // then - // 첫 번째 페이지 (가장 오래된 2개) - assertThat(firstResult.getContent()).hasSize(2); - assertThat(firstResult.getTotalElements()).isEqualTo(3); - assertThat(firstResult.getTotalPages()).isEqualTo(2); - // ASC 정렬이므로 첫 번째 페이지 내에서도 시간순 정렬 확인 - assertThat(firstResult.getContent().get(0).getCreatedDate()).isBeforeOrEqualTo(firstResult.getContent().get(1).getCreatedDate()); - - // 두 번째 페이지 (가장 최신 1개) - assertThat(secondResult.getContent()).hasSize(1); - assertThat(secondResult.getTotalElements()).isEqualTo(3); - assertThat(secondResult.getTotalPages()).isEqualTo(2); - // ASC 정렬에서 두 번째 페이지의 메시지는 첫 번째 페이지의 마지막 메시지보다 더 최신이어야 함 - assertThat(secondResult.getContent().get(0).getCreatedDate()).isAfter(firstResult.getContent().get(1).getCreatedDate()); + assertThat(result.getContent()).hasSize(2); + assertThat(result.getTotalElements()).isEqualTo(3); + assertThat(result.getTotalPages()).isEqualTo(2); + assertThat(result.isFirst()).isTrue(); + assertThat(result.isLast()).isFalse(); } + } + + @Nested + @DisplayName("findByConversationIdOrderByCreatedDateDesc 페이징 테스트") + class FindByConversationIdOrderByCreatedDateDescTest { @Test - @DisplayName("성공: 빈 결과 페이지") - void findByChatRoomIdOrderByCreatedDateAsc_EmptyResult() { + @DisplayName("성공: 시간순(DESC) 정렬이 올바르게 동작") + void findByConversationIdOrderByCreatedDateDesc_Success() { // given - UUID nonExistentChatRoomId = UUID.randomUUID(); Pageable pageable = PageRequest.of(0, 10); // when - Page result = messageRepository.findByChatRoomIdOrderByCreatedDateAsc(nonExistentChatRoomId, pageable); + Page result = messageRepository.findByConversationIdOrderByCreatedDateDesc(conversation1.getId(), pageable); // then - assertThat(result.getContent()).isEmpty(); - assertThat(result.getTotalElements()).isEqualTo(0); - assertThat(result.getTotalPages()).isEqualTo(0); + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent().get(0).getContent()).isEqualTo("객체지향 프로그래밍의 핵심 개념을 설명해주세요."); // 가장 최근 + assertThat(result.getContent().get(1).getContent()).isEqualTo("자바의 기본 문법에 대해 알아보겠습니다."); + assertThat(result.getContent().get(2).getContent()).isEqualTo("안녕하세요! 자바 공부를 시작해봅시다."); // 가장 오래된 } - } - - @Nested - @DisplayName("findByChatRoomIdOrderByCreatedDateDesc 페이징 테스트") - class FindByChatRoomIdOrderByCreatedDateDescTest { @Test - @DisplayName("성공: 최신순(DESC) 정렬이 올바르게 동작") - void findByChatRoomIdOrderByCreatedDateDesc_Success() { + @DisplayName("성공: 페이징이 올바르게 동작") + void findByConversationIdOrderByCreatedDateDesc_Paging() { // given - Pageable pageable = PageRequest.of(0, 10); + Pageable pageable = PageRequest.of(0, 2); // 페이지 크기 2 // when - Page result = messageRepository.findByChatRoomIdOrderByCreatedDateDesc(chatRoom1.getId(), pageable); + Page result = messageRepository.findByConversationIdOrderByCreatedDateDesc(conversation1.getId(), pageable); // then - assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent()).hasSize(2); assertThat(result.getTotalElements()).isEqualTo(3); - - // 최신순(DESC) 정렬 확인: 가장 최신 것부터 (같은 시간일 수도 있으므로 isAfterOrEqualTo 사용) - List messages = result.getContent(); - assertThat(messages.get(0).getCreatedDate()).isAfterOrEqualTo(messages.get(1).getCreatedDate()); // 첫 번째가 더 최신이거나 같음 - assertThat(messages.get(1).getCreatedDate()).isAfterOrEqualTo(messages.get(2).getCreatedDate()); // 두 번째가 세 번째보다 최신이거나 같음 + assertThat(result.getTotalPages()).isEqualTo(2); + assertThat(result.isFirst()).isTrue(); + assertThat(result.isLast()).isFalse(); } + } - @Test - @DisplayName("성공: 페이징 처리") - void findByChatRoomIdOrderByCreatedDateDesc_Paging() { - // given - Pageable firstPage = PageRequest.of(0, 2); - Pageable secondPage = PageRequest.of(1, 2); + @Nested + @DisplayName("findByConversationIdOrderByCreatedDateAsc 리스트 테스트") + class FindByConversationIdOrderByCreatedDateAscListTest { + @Test + @DisplayName("성공: 시간순(ASC) 정렬된 전체 메시지 조회") + void findByConversationIdOrderByCreatedDateAsc_List_Success() { // when - Page firstResult = messageRepository.findByChatRoomIdOrderByCreatedDateDesc(chatRoom1.getId(), firstPage); - Page secondResult = messageRepository.findByChatRoomIdOrderByCreatedDateDesc(chatRoom1.getId(), secondPage); + List result = messageRepository.findByConversationIdOrderByCreatedDateAsc(conversation1.getId()); // then - // 첫 번째 페이지 (최신 2개) - assertThat(firstResult.getContent()).hasSize(2); - assertThat(firstResult.getTotalElements()).isEqualTo(3); - assertThat(firstResult.getTotalPages()).isEqualTo(2); - // DESC 정렬이므로 첫 번째 페이지 내에서도 최신순 정렬 확인 - assertThat(firstResult.getContent().get(0).getCreatedDate()).isAfterOrEqualTo(firstResult.getContent().get(1).getCreatedDate()); - - // 두 번째 페이지 (가장 오래된 1개) - assertThat(secondResult.getContent()).hasSize(1); - assertThat(secondResult.getTotalElements()).isEqualTo(3); - assertThat(secondResult.getTotalPages()).isEqualTo(2); - // DESC 정렬에서 두 번째 페이지의 메시지는 첫 번째 페이지의 마지막 메시지보다 더 오래되어야 함 - assertThat(secondResult.getContent().get(0).getCreatedDate()).isBefore(firstResult.getContent().get(1).getCreatedDate()); + assertThat(result).hasSize(3); + assertThat(result.get(0).getContent()).isEqualTo("안녕하세요! 자바 공부를 시작해봅시다."); // 가장 오래된 + assertThat(result.get(1).getContent()).isEqualTo("자바의 기본 문법에 대해 알아보겠습니다."); + assertThat(result.get(2).getContent()).isEqualTo("객체지향 프로그래밍의 핵심 개념을 설명해주세요."); // 가장 최근 } @Test - @DisplayName("성공: 다른 채팅방과 격리된 결과") - void findByChatRoomIdOrderByCreatedDateDesc_IsolatedResult() { + @DisplayName("성공: 빈 대화의 경우 빈 리스트 반환") + void findByConversationIdOrderByCreatedDateAsc_EmptyResult() { // given - Pageable pageable = PageRequest.of(0, 10); + Conversation emptyConversation = Conversation.builder() + .userId(3L) + .build(); + entityManager.persistAndFlush(emptyConversation); // when - Page chatRoom1Result = messageRepository.findByChatRoomIdOrderByCreatedDateDesc(chatRoom1.getId(), pageable); - Page chatRoom2Result = messageRepository.findByChatRoomIdOrderByCreatedDateDesc(chatRoom2.getId(), pageable); + List result = messageRepository.findByConversationIdOrderByCreatedDateAsc(emptyConversation.getId()); // then - assertThat(chatRoom1Result.getContent()).hasSize(3); - assertThat(chatRoom2Result.getContent()).hasSize(2); - - // 각 채팅방의 메시지만 포함되는지 확인 - assertThat(chatRoom1Result.getContent()).allMatch(message -> - message.getChatRoom().getId().equals(chatRoom1.getId())); - assertThat(chatRoom2Result.getContent()).allMatch(message -> - message.getChatRoom().getId().equals(chatRoom2.getId())); + assertThat(result).isEmpty(); } } } diff --git a/chat_service/src/test/java/com/synapse/chat_service/repository/UserRepositoryTest.java b/chat_service/src/test/java/com/synapse/chat_service/repository/UserRepositoryTest.java deleted file mode 100644 index e5a8640..0000000 --- a/chat_service/src/test/java/com/synapse/chat_service/repository/UserRepositoryTest.java +++ /dev/null @@ -1,289 +0,0 @@ -package com.synapse.chat_service.repository; - -import com.synapse.chat_service.domain.entity.User; -import com.synapse.chat_service.domain.repository.UserRepository; -import com.synapse.chat_service.testutil.TestObjectFactory; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; -import org.springframework.test.context.ActiveProfiles; - -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; - -@DataJpaTest -@ActiveProfiles("test") -@DisplayName("UserRepository 단위 테스트") -class UserRepositoryTest { - - @Autowired - private TestEntityManager entityManager; - - @Autowired - private UserRepository userRepository; - - private User user1; - private User user2; - - @BeforeEach - void setUp() { - // 테스트용 User 데이터 생성 - user1 = TestObjectFactory.createUser(1L, "testuser1", "testuser1@example.com"); - user2 = TestObjectFactory.createUser(2L, "testuser2", "testuser2@example.com"); - - entityManager.persistAndFlush(user1); - entityManager.persistAndFlush(user2); - } - - @Nested - @DisplayName("기본 CRUD 테스트") - class BasicCrudTest { - - @Test - @DisplayName("성공: 새로운 사용자 저장") - void save_Success() { - // given - User newUser = TestObjectFactory.createUser(3L, "newuser", "newuser@example.com"); - - // when - User savedUser = userRepository.save(newUser); - - // then - assertThat(savedUser).isNotNull(); - assertThat(savedUser.getId()).isNotNull(); - assertThat(savedUser.getUsername()).isEqualTo("newuser"); - assertThat(savedUser.getEmail()).isEqualTo("newuser@example.com"); - } - - @Test - @DisplayName("성공: ID로 사용자 조회") - void findById_Success() { - // when - Optional foundUser = userRepository.findById(user1.getId()); - - // then - assertThat(foundUser).isPresent(); - assertThat(foundUser.get().getUsername()).isEqualTo("testuser1"); - assertThat(foundUser.get().getEmail()).isEqualTo("testuser1@example.com"); - } - - @Test - @DisplayName("성공: 존재하지 않는 ID로 조회 시 빈 Optional 반환") - void findById_NotFound() { - // given - Long nonExistentId = 999L; - - // when - Optional foundUser = userRepository.findById(nonExistentId); - - // then - assertThat(foundUser).isEmpty(); - } - - @Test - @DisplayName("성공: 사용자 삭제") - void delete_Success() { - // given - Long userId = user1.getId(); - - // when - userRepository.delete(user1); - entityManager.flush(); - - // then - Optional deletedUser = userRepository.findById(userId); - assertThat(deletedUser).isEmpty(); - } - } - - @Nested - @DisplayName("findByUsername 테스트") - class FindByUsernameTest { - - @Test - @DisplayName("성공: 사용자명으로 사용자 조회") - void findByUsername_Success() { - // when - Optional foundUser = userRepository.findByUsername("testuser1"); - - // then - assertThat(foundUser).isPresent(); - assertThat(foundUser.get().getUsername()).isEqualTo("testuser1"); - assertThat(foundUser.get().getEmail()).isEqualTo("testuser1@example.com"); - } - - @Test - @DisplayName("성공: 존재하지 않는 사용자명으로 조회 시 빈 Optional 반환") - void findByUsername_NotFound() { - // when - Optional foundUser = userRepository.findByUsername("nonexistentuser"); - - // then - assertThat(foundUser).isEmpty(); - } - - @Test - @DisplayName("성공: 대소문자 구분하여 조회") - void findByUsername_CaseSensitive() { - // when - Optional foundUser = userRepository.findByUsername("TESTUSER1"); - - // then - assertThat(foundUser).isEmpty(); // 대소문자가 다르므로 찾을 수 없음 - } - } - - @Nested - @DisplayName("findByEmail 테스트") - class FindByEmailTest { - - @Test - @DisplayName("성공: 이메일로 사용자 조회") - void findByEmail_Success() { - // when - Optional foundUser = userRepository.findByEmail("testuser1@example.com"); - - // then - assertThat(foundUser).isPresent(); - assertThat(foundUser.get().getUsername()).isEqualTo("testuser1"); - assertThat(foundUser.get().getEmail()).isEqualTo("testuser1@example.com"); - } - - @Test - @DisplayName("성공: 존재하지 않는 이메일로 조회 시 빈 Optional 반환") - void findByEmail_NotFound() { - // when - Optional foundUser = userRepository.findByEmail("nonexistent@example.com"); - - // then - assertThat(foundUser).isEmpty(); - } - - @Test - @DisplayName("성공: 대소문자 구분하여 조회") - void findByEmail_CaseSensitive() { - // when - Optional foundUser = userRepository.findByEmail("TESTUSER1@EXAMPLE.COM"); - - // then - assertThat(foundUser).isEmpty(); // 대소문자가 다르므로 찾을 수 없음 - } - } - - @Nested - @DisplayName("existsByUsername 테스트") - class ExistsByUsernameTest { - - @Test - @DisplayName("성공: 존재하는 사용자명 확인") - void existsByUsername_True() { - // when - boolean exists = userRepository.existsByUsername("testuser1"); - - // then - assertThat(exists).isTrue(); - } - - @Test - @DisplayName("성공: 존재하지 않는 사용자명 확인") - void existsByUsername_False() { - // when - boolean exists = userRepository.existsByUsername("nonexistentuser"); - - // then - assertThat(exists).isFalse(); - } - - @Test - @DisplayName("성공: 대소문자 구분하여 확인") - void existsByUsername_CaseSensitive() { - // when - boolean exists = userRepository.existsByUsername("TESTUSER1"); - - // then - assertThat(exists).isFalse(); // 대소문자가 다르므로 존재하지 않음 - } - } - - @Nested - @DisplayName("existsByEmail 테스트") - class ExistsByEmailTest { - - @Test - @DisplayName("성공: 존재하는 이메일 확인") - void existsByEmail_True() { - // when - boolean exists = userRepository.existsByEmail("testuser1@example.com"); - - // then - assertThat(exists).isTrue(); - } - - @Test - @DisplayName("성공: 존재하지 않는 이메일 확인") - void existsByEmail_False() { - // when - boolean exists = userRepository.existsByEmail("nonexistent@example.com"); - - // then - assertThat(exists).isFalse(); - } - - @Test - @DisplayName("성공: 대소문자 구분하여 확인") - void existsByEmail_CaseSensitive() { - // when - boolean exists = userRepository.existsByEmail("TESTUSER1@EXAMPLE.COM"); - - // then - assertThat(exists).isFalse(); // 대소문자가 다르므로 존재하지 않음 - } - } - - @Nested - @DisplayName("중복성 검증 테스트") - class DuplicateValidationTest { - - @Test - @DisplayName("성공: 사용자명 중복 검증") - void validateUsernameDuplication() { - // given - String existingUsername = "testuser1"; - String newUsername = "newuser"; - - // when & then - assertThat(userRepository.existsByUsername(existingUsername)).isTrue(); - assertThat(userRepository.existsByUsername(newUsername)).isFalse(); - } - - @Test - @DisplayName("성공: 이메일 중복 검증") - void validateEmailDuplication() { - // given - String existingEmail = "testuser1@example.com"; - String newEmail = "newuser@example.com"; - - // when & then - assertThat(userRepository.existsByEmail(existingEmail)).isTrue(); - assertThat(userRepository.existsByEmail(newEmail)).isFalse(); - } - - @Test - @DisplayName("성공: 동일한 사용자명과 이메일로 새 사용자 생성 시 제약 조건 확인") - void validateUniqueConstraints() { - // given - User duplicateUser = TestObjectFactory.createUser(4L, "testuser1", "testuser1@example.com"); // 이미 존재하는 사용자명과 이메일 - - // when & then - // 실제로는 데이터베이스 제약 조건에 의해 예외가 발생할 것이지만, - // 여기서는 존재 여부만 확인 - assertThat(userRepository.existsByUsername(duplicateUser.getUsername())).isTrue(); - assertThat(userRepository.existsByEmail(duplicateUser.getEmail())).isTrue(); - } - } -} \ No newline at end of file diff --git a/chat_service/src/test/java/com/synapse/chat_service/service/ChatRoomServiceTest.java b/chat_service/src/test/java/com/synapse/chat_service/service/ChatRoomServiceTest.java deleted file mode 100644 index d3cb678..0000000 --- a/chat_service/src/test/java/com/synapse/chat_service/service/ChatRoomServiceTest.java +++ /dev/null @@ -1,313 +0,0 @@ -package com.synapse.chat_service.service; - -import com.synapse.chat_service.domain.entity.ChatRoom; -import com.synapse.chat_service.dto.request.ChatRoomRequest; -import com.synapse.chat_service.dto.response.ChatRoomResponse; -import com.synapse.chat_service.exception.commonexception.NotFoundException; -import com.synapse.chat_service.domain.repository.ChatRoomRepository; -import com.synapse.chat_service.domain.repository.MessageRepository; -import com.synapse.chat_service.testutil.TestObjectFactory; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; - - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -@DisplayName("ChatRoomService 단위 테스트") -class ChatRoomServiceTest { - - @Mock - private ChatRoomRepository chatRoomRepository; - - @Mock - private MessageRepository messageRepository; - - @InjectMocks - private ChatRoomService chatRoomService; - - private ChatRoom testChatRoom; - private UUID testChatRoomId; - - @BeforeEach - void setUp() { - testChatRoomId = UUID.randomUUID(); - testChatRoom = TestObjectFactory.createChatRoomWithId(testChatRoomId, 1L, "테스트 채팅방"); - } - - @Nested - @DisplayName("createChatRoom 테스트") - class CreateChatRoomTest { - - @Test - @DisplayName("성공: 채팅방 생성") - void createChatRoom_Success() { - // given - ChatRoomRequest.Create request = new ChatRoomRequest.Create(1L, "새 채팅방"); - ChatRoom savedChatRoom = TestObjectFactory.createChatRoomWithId(testChatRoomId, 1L, "새 채팅방"); - when(chatRoomRepository.save(any(ChatRoom.class))).thenReturn(savedChatRoom); - - // when - ChatRoomResponse.Detail result = chatRoomService.createChatRoom(request); - - // then - assertThat(result.id()).isEqualTo(testChatRoomId); - assertThat(result.title()).isEqualTo("새 채팅방"); - assertThat(result.userId()).isEqualTo(1L); - verify(chatRoomRepository, times(1)).save(any(ChatRoom.class)); - } - } - - @Nested - @DisplayName("getChatRoom 테스트") - class GetChatRoomTest { - - @Test - @DisplayName("성공: 채팅방 조회") - void getChatRoom_Success() { - // given - when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.of(testChatRoom)); - when(messageRepository.countByChatRoomId(testChatRoomId)).thenReturn(5L); - - // when - ChatRoomResponse.Detail result = chatRoomService.getChatRoom(testChatRoomId); - - // then - assertThat(result.id()).isEqualTo(testChatRoomId); - assertThat(result.title()).isEqualTo("테스트 채팅방"); - assertThat(result.userId()).isEqualTo(1L); - assertThat(result.messageCount()).isEqualTo(5L); - verify(chatRoomRepository, times(1)).findById(testChatRoomId); - verify(messageRepository, times(1)).countByChatRoomId(testChatRoomId); - } - - @Test - @DisplayName("실패: 채팅방을 찾을 수 없음") - void getChatRoom_Fail_NotFound() { - // given - when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.empty()); - - // when & then - assertThrows(NotFoundException.class, () -> chatRoomService.getChatRoom(testChatRoomId)); - verify(chatRoomRepository, times(1)).findById(testChatRoomId); - verify(messageRepository, never()).countByChatRoomId(any()); - } - } - - @Nested - @DisplayName("updateChatRoom 테스트") - class UpdateChatRoomTest { - - @Test - @DisplayName("성공: 채팅방 제목 수정") - void updateChatRoom_Success() { - // given - ChatRoomRequest.Update request = new ChatRoomRequest.Update("수정된 제목"); - ChatRoom updatedChatRoom = TestObjectFactory.createChatRoomWithId(testChatRoomId, 1L, "수정된 제목"); - - when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.of(testChatRoom)); - when(chatRoomRepository.save(any(ChatRoom.class))).thenReturn(updatedChatRoom); - - // when - ChatRoomResponse.Detail result = chatRoomService.updateChatRoom(testChatRoomId, request); - - // then - assertThat(result.title()).isEqualTo("수정된 제목"); - assertThat(result.userId()).isEqualTo(1L); - verify(chatRoomRepository, times(1)).findById(testChatRoomId); - verify(chatRoomRepository, times(1)).save(any(ChatRoom.class)); - } - - @Test - @DisplayName("실패: 수정할 채팅방을 찾을 수 없음") - void updateChatRoom_Fail_NotFound() { - // given - ChatRoomRequest.Update request = new ChatRoomRequest.Update("수정된 제목"); - when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.empty()); - - // when & then - assertThrows(NotFoundException.class, () -> chatRoomService.updateChatRoom(testChatRoomId, request)); - verify(chatRoomRepository, times(1)).findById(testChatRoomId); - verify(chatRoomRepository, never()).save(any()); - } - } - - @Nested - @DisplayName("deleteChatRoom 테스트") - class DeleteChatRoomTest { - - @Test - @DisplayName("성공: 채팅방 삭제") - void deleteChatRoom_Success() { - // given - when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.of(testChatRoom)); - - // when - chatRoomService.deleteChatRoom(testChatRoomId); - - // then - verify(chatRoomRepository, times(1)).findById(testChatRoomId); - verify(chatRoomRepository, times(1)).delete(testChatRoom); - } - - @Test - @DisplayName("실패: 삭제할 채팅방을 찾을 수 없음") - void deleteChatRoom_Fail_NotFound() { - // given - when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.empty()); - - // when & then - assertThrows(NotFoundException.class, () -> chatRoomService.deleteChatRoom(testChatRoomId)); - verify(chatRoomRepository, times(1)).findById(testChatRoomId); - verify(chatRoomRepository, never()).delete(any()); - } - } - - @Nested - @DisplayName("getChatRoomsByUserId 테스트") - class GetChatRoomsByUserIdTest { - - @Test - @DisplayName("성공: 페이징을 통한 채팅방 조회") - void getChatRoomsByUserId_Success() { - // given - Long userId = 1L; - Pageable pageable = PageRequest.of(0, 2); - ChatRoom chatRoom1 = TestObjectFactory.createChatRoom(userId, "채팅방 1"); - ChatRoom chatRoom2 = TestObjectFactory.createChatRoom(userId, "채팅방 2"); - List chatRooms = Arrays.asList(chatRoom1, chatRoom2); - Page chatRoomPage = new PageImpl<>(chatRooms, pageable, 2); - - when(chatRoomRepository.findByUserIdOrderByCreatedDateDesc(userId, pageable)).thenReturn(chatRoomPage); - when(messageRepository.countByChatRoomId(any())).thenReturn(3L); - - // when - Page result = chatRoomService.getChatRoomsByUserId(userId, pageable); - - // then (기존 검증) - assertThat(result.getContent()).hasSize(2); - assertThat(result.getTotalElements()).isEqualTo(2); - verify(chatRoomRepository, times(1)).findByUserIdOrderByCreatedDateDesc(userId, pageable); - verify(messageRepository, times(2)).countByChatRoomId(any()); - - // then (개선된 DTO 변환 로직 검증) - assertThat(result.getContent().get(0).title()).isEqualTo("채팅방 1"); - assertThat(result.getContent().get(0).messageCount()).isEqualTo(3L); - assertThat(result.getContent().get(1).title()).isEqualTo("채팅방 2"); - assertThat(result.getContent().get(1).messageCount()).isEqualTo(3L); - } - - @Test - @DisplayName("성공: 빈 페이지 반환") - void getChatRoomsByUserId_EmptyPage() { - // given - Long userId = 1L; - Pageable pageable = PageRequest.of(0, 10); - Page emptyPage = new PageImpl<>(Collections.emptyList(), pageable, 0); - - when(chatRoomRepository.findByUserIdOrderByCreatedDateDesc(userId, pageable)).thenReturn(emptyPage); - - // when - Page result = chatRoomService.getChatRoomsByUserId(userId, pageable); - - // then - assertThat(result.getContent()).isEmpty(); - assertThat(result.getTotalElements()).isEqualTo(0); - verify(chatRoomRepository, times(1)).findByUserIdOrderByCreatedDateDesc(userId, pageable); - verify(messageRepository, never()).countByChatRoomId(any()); - } - } - - @Nested - @DisplayName("searchChatRooms 테스트") - class SearchChatRoomsTest { - - @Test - @DisplayName("성공: 키워드로 채팅방 검색") - void searchChatRooms_Success() { - // given - Long userId = 1L; - String keyword = "Java"; - ChatRoom chatRoom1 = TestObjectFactory.createChatRoom(userId, "Java 스터디"); - ChatRoom chatRoom2 = TestObjectFactory.createChatRoom(userId, "JavaScript 프로젝트"); - List searchResults = Arrays.asList(chatRoom1, chatRoom2); - - when(chatRoomRepository.findByUserIdAndTitleContaining(userId, keyword)).thenReturn(searchResults); - when(messageRepository.countByChatRoomId(any())).thenReturn(5L); - - // when - List result = chatRoomService.searchChatRooms(userId, keyword); - - // then (기존 검증) - assertThat(result).hasSize(2); - verify(chatRoomRepository, times(1)).findByUserIdAndTitleContaining(userId, keyword); - verify(messageRepository, times(2)).countByChatRoomId(any()); - - // then (개선된 DTO 변환 로직 검증) - assertThat(result.get(0).title()).isEqualTo("Java 스터디"); - assertThat(result.get(0).messageCount()).isEqualTo(5L); - - assertThat(result.get(1).title()).isEqualTo("JavaScript 프로젝트"); - assertThat(result.get(1).messageCount()).isEqualTo(5L); - } - - @Test - @DisplayName("성공: 검색 결과가 없는 경우 빈 리스트 반환") - void searchChatRooms_EmptyResult() { - // given - Long userId = 1L; - String keyword = "Python"; - when(chatRoomRepository.findByUserIdAndTitleContaining(userId, keyword)).thenReturn(Collections.emptyList()); - - // when - List result = chatRoomService.searchChatRooms(userId, keyword); - - // then - assertThat(result).isEmpty(); - verify(chatRoomRepository, times(1)).findByUserIdAndTitleContaining(userId, keyword); - verify(messageRepository, never()).countByChatRoomId(any()); - } - - @Test - @DisplayName("성공: 빈 키워드로 검색") - void searchChatRooms_EmptyKeyword() { - // given - Long userId = 1L; - String keyword = ""; - ChatRoom chatRoom = TestObjectFactory.createChatRoom(userId, "테스트 채팅방"); - List allChatRooms = Arrays.asList(chatRoom); - - when(chatRoomRepository.findByUserIdAndTitleContaining(userId, keyword)).thenReturn(allChatRooms); - when(messageRepository.countByChatRoomId(any())).thenReturn(2L); - - // when - List result = chatRoomService.searchChatRooms(userId, keyword); - - // then - assertThat(result).hasSize(1); - assertThat(result.get(0).title()).isEqualTo("테스트 채팅방"); - verify(chatRoomRepository, times(1)).findByUserIdAndTitleContaining(userId, keyword); - verify(messageRepository, times(1)).countByChatRoomId(any()); - } - } -} diff --git a/chat_service/src/test/java/com/synapse/chat_service/service/MessageServiceTest.java b/chat_service/src/test/java/com/synapse/chat_service/service/MessageServiceTest.java index df5ea7c..bb3bb13 100644 --- a/chat_service/src/test/java/com/synapse/chat_service/service/MessageServiceTest.java +++ b/chat_service/src/test/java/com/synapse/chat_service/service/MessageServiceTest.java @@ -1,247 +1,233 @@ package com.synapse.chat_service.service; -import com.synapse.chat_service.domain.entity.ChatRoom; +import com.synapse.chat_service.domain.entity.Conversation; import com.synapse.chat_service.domain.entity.Message; import com.synapse.chat_service.domain.entity.enums.SenderType; import com.synapse.chat_service.dto.request.MessageRequest; import com.synapse.chat_service.dto.response.MessageResponse; import com.synapse.chat_service.exception.commonexception.NotFoundException; -import com.synapse.chat_service.domain.repository.ChatRoomRepository; +import com.synapse.chat_service.domain.repository.ConversationRepository; import com.synapse.chat_service.domain.repository.MessageRepository; +import com.synapse.chat_service.session.RedisAiChatManager; +import com.synapse.chat_service.session.RedisSessionManager; +import org.springframework.data.redis.core.RedisTemplate; import com.synapse.chat_service.testutil.TestObjectFactory; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; - -import java.util.Arrays; import java.util.List; -import java.util.Optional; -import java.util.UUID; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; +import static org.assertj.core.api.Assertions.*; -@ExtendWith(MockitoExtension.class) -@DisplayName("MessageService 단위 테스트") +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@DisplayName("MessageService 통합 테스트") class MessageServiceTest { - @Mock + @Autowired + private MessageService messageService; + + @Autowired + private ConversationRepository conversationRepository; + + @Autowired private MessageRepository messageRepository; - @Mock - private ChatRoomRepository chatRoomRepository; + @MockitoBean + private RedisTemplate redisTemplate; - @InjectMocks - private MessageService messageService; + @MockitoBean + private RedisAiChatManager redisAiChatManager; + + @MockitoBean + private RedisSessionManager redisSessionManager; - private ChatRoom testChatRoom; + private Conversation testConversation; private Message testMessage; - private UUID testChatRoomId; - private Long testMessageId; @BeforeEach void setUp() { - testChatRoomId = UUID.randomUUID(); - testMessageId = 1L; + // 테스트용 대화 생성 + testConversation = TestObjectFactory.createConversation(1L); + testConversation = conversationRepository.save(testConversation); - testChatRoom = TestObjectFactory.createChatRoomWithId(testChatRoomId, 1L, "테스트 채팅방"); - testMessage = TestObjectFactory.createUserMessageWithId(testMessageId, testChatRoom, "테스트 메시지"); + // 테스트용 메시지 생성 + testMessage = TestObjectFactory.createUserMessage(testConversation, "테스트 메시지"); + testMessage = messageRepository.save(testMessage); } @Nested - @DisplayName("createMessage 테스트") - class CreateMessageTest { + @DisplayName("메시지 생성") + class CreateMessage { @Test - @DisplayName("성공: 메시지 생성") + @DisplayName("성공: 유효한 메시지 생성") void createMessage_Success() { // given - MessageRequest.Create request = new MessageRequest.Create(testChatRoomId, SenderType.USER, "새 메시지"); - when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.of(testChatRoom)); - when(messageRepository.save(any(Message.class))).thenReturn(testMessage); + MessageRequest.Create request = new MessageRequest.Create( + testConversation.getUserId(), + SenderType.USER, + "새로운 메시지" + ); // when - MessageResponse.Detail result = messageService.createMessage(request); + MessageResponse.Detail response = messageService.createMessage(request); // then - assertThat(result.id()).isEqualTo(testMessageId); - assertThat(result.chatRoomId()).isEqualTo(testChatRoomId); - assertThat(result.senderType()).isEqualTo(SenderType.USER); - assertThat(result.content()).isEqualTo("테스트 메시지"); - verify(chatRoomRepository, times(1)).findById(testChatRoomId); - verify(messageRepository, times(1)).save(any(Message.class)); + assertThat(response).isNotNull(); + assertThat(response.content()).isEqualTo("새로운 메시지"); + assertThat(response.senderType()).isEqualTo(SenderType.USER); + assertThat(response.conversationId()).isEqualTo(testConversation.getId()); } @Test - @DisplayName("실패: 채팅방을 찾을 수 없음") - void createMessage_Fail_ChatRoomNotFound() { + @DisplayName("성공: 새로운 사용자로 대화 생성") + void createMessage_NewUser() { // given - MessageRequest.Create request = new MessageRequest.Create(testChatRoomId, SenderType.USER, "새 메시지"); - when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.empty()); + Long newUserId = 999L; + MessageRequest.Create request = new MessageRequest.Create( + newUserId, + SenderType.USER, + "새 사용자의 첫 메시지" + ); - // when & then - assertThrows(NotFoundException.class, () -> messageService.createMessage(request)); - verify(chatRoomRepository, times(1)).findById(testChatRoomId); - verify(messageRepository, never()).save(any()); + // when + MessageResponse.Detail response = messageService.createMessage(request); + + // then + assertThat(response).isNotNull(); + assertThat(response.content()).isEqualTo("새 사용자의 첫 메시지"); + assertThat(response.senderType()).isEqualTo(SenderType.USER); } } @Nested - @DisplayName("getMessage 테스트") - class GetMessageTest { + @DisplayName("메시지 조회") + class GetMessage { @Test @DisplayName("성공: 메시지 조회") void getMessage_Success() { - // given - when(messageRepository.findById(testMessageId)).thenReturn(Optional.of(testMessage)); - // when - MessageResponse.Detail result = messageService.getMessage(testMessageId); + MessageResponse.Detail result = messageService.getMessage(testMessage.getId()); // then - assertThat(result.id()).isEqualTo(testMessageId); - assertThat(result.chatRoomId()).isEqualTo(testChatRoomId); + assertThat(result).isNotNull(); + assertThat(result.id()).isEqualTo(testMessage.getId()); + assertThat(result.conversationId()).isEqualTo(testConversation.getId()); assertThat(result.senderType()).isEqualTo(SenderType.USER); assertThat(result.content()).isEqualTo("테스트 메시지"); - verify(messageRepository, times(1)).findById(testMessageId); } @Test @DisplayName("실패: 메시지를 찾을 수 없음") - void getMessage_Fail_NotFound() { + void getMessage_MessageNotFound() { // given - when(messageRepository.findById(testMessageId)).thenReturn(Optional.empty()); + Long nonExistentMessageId = 999L; // when & then - assertThrows(NotFoundException.class, () -> messageService.getMessage(testMessageId)); - verify(messageRepository, times(1)).findById(testMessageId); + assertThatThrownBy(() -> messageService.getMessage(nonExistentMessageId)) + .isInstanceOf(NotFoundException.class); } } @Nested - @DisplayName("getMessagesByChatRoomId 테스트") - class GetMessagesByChatRoomIdTest { + @DisplayName("getMessagesByConversationId 테스트") + class GetMessagesByConversationIdTest { @Test - @DisplayName("성공: 채팅방별 메시지 조회") - void getMessagesByChatRoomId_Success() { - // given - List messages = Arrays.asList(testMessage); - when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.of(testChatRoom)); - when(messageRepository.findByChatRoomIdOrderByCreatedDateAsc(testChatRoomId)).thenReturn(messages); - + @DisplayName("성공: 사용자 ID로 메시지 목록 조회") + void getMessagesByUserId_Success() { // when - List result = messageService.getMessagesByChatRoomId(testChatRoomId); + List result = messageService.getMessagesByUserId(testConversation.getUserId()); - // then (기존 검증) + // then assertThat(result).hasSize(1); - verify(chatRoomRepository, times(1)).findById(testChatRoomId); - verify(messageRepository, times(1)).findByChatRoomIdOrderByCreatedDateAsc(testChatRoomId); - - // then (개선된 DTO 변환 로직 검증) - assertThat(result.get(0).id()).isEqualTo(testMessageId); - assertThat(result.get(0).chatRoomId()).isEqualTo(testChatRoomId); + assertThat(result.get(0).id()).isEqualTo(testMessage.getId()); + assertThat(result.get(0).conversationId()).isEqualTo(testConversation.getId()); assertThat(result.get(0).senderType()).isEqualTo(SenderType.USER); assertThat(result.get(0).content()).isEqualTo("테스트 메시지"); } @Test - @DisplayName("실패: 채팅방을 찾을 수 없음") - void getMessagesByChatRoomId_Fail_ChatRoomNotFound() { + @DisplayName("성공: 존재하지 않는 사용자 ID로 빈 목록 조회") + void getMessagesByUserId_EmptyResult() { // given - when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.empty()); + Long nonExistentUserId = 999L; - // when & then - assertThrows(NotFoundException.class, () -> messageService.getMessagesByChatRoomId(testChatRoomId)); - verify(chatRoomRepository, times(1)).findById(testChatRoomId); - verify(messageRepository, never()).findByChatRoomIdOrderByCreatedDateAsc(any()); + // when + List result = messageService.getMessagesByUserId(nonExistentUserId); + + // then + assertThat(result).isEmpty(); } } @Nested - @DisplayName("getMessagesByChatRoomIdWithPaging 테스트") - class GetMessagesByChatRoomIdWithPagingTest { + @DisplayName("getMessagesByUserIdWithPaging 테스트") + class GetMessagesByUserIdWithPagingTest { @Test @DisplayName("성공: 페이징된 메시지 조회 (오름차순)") - void getMessagesByChatRoomIdWithPaging_Success_Ascending() { + void getMessagesByUserIdWithPaging_Success_Ascending() { // given Pageable pageable = PageRequest.of(0, 10); - List messages = Arrays.asList(testMessage); - Page messagePage = new PageImpl<>(messages, pageable, 1); - - when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.of(testChatRoom)); - when(messageRepository.findByChatRoomIdOrderByCreatedDateAsc(testChatRoomId, pageable)).thenReturn(messagePage); // when - Page result = messageService.getMessagesByChatRoomIdWithPaging(testChatRoomId, pageable); + Page result = messageService.getMessagesByUserIdWithPaging(testConversation.getUserId(), pageable); - // then (기존 검증) + // then assertThat(result.getContent()).hasSize(1); - verify(chatRoomRepository, times(1)).findById(testChatRoomId); - verify(messageRepository, times(1)).findByChatRoomIdOrderByCreatedDateAsc(testChatRoomId, pageable); - - // then (개선된 DTO 변환 로직 검증) - assertThat(result.getContent().get(0).id()).isEqualTo(testMessageId); - assertThat(result.getContent().get(0).chatRoomId()).isEqualTo(testChatRoomId); + assertThat(result.getContent().get(0).id()).isEqualTo(testMessage.getId()); + assertThat(result.getContent().get(0).conversationId()).isEqualTo(testConversation.getId()); assertThat(result.getContent().get(0).senderType()).isEqualTo(SenderType.USER); assertThat(result.getContent().get(0).content()).isEqualTo("테스트 메시지"); } @Test - @DisplayName("실패: 채팅방을 찾을 수 없음") - void getMessagesByChatRoomIdWithPaging_Fail_ChatRoomNotFound() { + @DisplayName("성공: 존재하지 않는 사용자 ID로 빈 페이지 조회") + void getMessagesByUserIdWithPaging_EmptyResult() { // given Pageable pageable = PageRequest.of(0, 10); - when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.empty()); + Long nonExistentUserId = 999L; - // when & then - assertThrows(NotFoundException.class, () -> - messageService.getMessagesByChatRoomIdWithPaging(testChatRoomId, pageable)); - verify(chatRoomRepository, times(1)).findById(testChatRoomId); - verify(messageRepository, never()).findByChatRoomIdOrderByCreatedDateAsc(any(), any()); + // when + Page result = messageService.getMessagesByUserIdWithPaging(nonExistentUserId, pageable); + + // then + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); } } @Nested - @DisplayName("getRecentMessagesByChatRoomId 테스트") - class GetRecentMessagesByChatRoomIdTest { + @DisplayName("getMessagesRecentFirst 테스트") + class GetMessagesRecentFirstTest { @Test @DisplayName("성공: 최근 메시지 조회 (내림차순)") - void getRecentMessagesByChatRoomId_Success() { + void getMessagesRecentFirst_Success() { // given Pageable pageable = PageRequest.of(0, 10); - List messages = Arrays.asList(testMessage); - Page messagePage = new PageImpl<>(messages, pageable, 1); - - when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.of(testChatRoom)); - when(messageRepository.findByChatRoomIdOrderByCreatedDateDesc(testChatRoomId, pageable)).thenReturn(messagePage); // when - Page result = messageService.getMessagesRecentFirst(testChatRoomId, pageable); + Page result = messageService.getMessagesRecentFirst(testConversation.getUserId(), pageable); - // then (기존 검증) + // then assertThat(result.getContent()).hasSize(1); - verify(chatRoomRepository, times(1)).findById(testChatRoomId); - verify(messageRepository, times(1)).findByChatRoomIdOrderByCreatedDateDesc(testChatRoomId, pageable); - - // then (개선된 DTO 변환 로직 검증) - assertThat(result.getContent().get(0).id()).isEqualTo(testMessageId); - assertThat(result.getContent().get(0).chatRoomId()).isEqualTo(testChatRoomId); + assertThat(result.getContent().get(0).id()).isEqualTo(testMessage.getId()); + assertThat(result.getContent().get(0).conversationId()).isEqualTo(testConversation.getId()); assertThat(result.getContent().get(0).senderType()).isEqualTo(SenderType.USER); assertThat(result.getContent().get(0).content()).isEqualTo("테스트 메시지"); } @@ -256,22 +242,14 @@ class SearchMessagesTest { void searchMessages_Success() { // given String keyword = "테스트"; - List messages = Arrays.asList(testMessage); - when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.of(testChatRoom)); - when(messageRepository.findByChatRoomIdAndContentContaining(testChatRoomId, keyword)) - .thenReturn(messages); // when - List result = messageService.searchMessages(testChatRoomId, keyword); + List result = messageService.searchMessages(testConversation.getUserId(), keyword); - // then (기존 검증) + // then assertThat(result).hasSize(1); - verify(chatRoomRepository, times(1)).findById(testChatRoomId); - verify(messageRepository, times(1)).findByChatRoomIdAndContentContaining(testChatRoomId, keyword); - - // then (개선된 DTO 변환 로직 검증) - assertThat(result.get(0).id()).isEqualTo(testMessageId); - assertThat(result.get(0).chatRoomId()).isEqualTo(testChatRoomId); + assertThat(result.get(0).id()).isEqualTo(testMessage.getId()); + assertThat(result.get(0).conversationId()).isEqualTo(testConversation.getId()); assertThat(result.get(0).senderType()).isEqualTo(SenderType.USER); assertThat(result.get(0).content()).isEqualTo("테스트 메시지"); assertThat(result.get(0).content()).contains(keyword); @@ -279,33 +257,29 @@ void searchMessages_Success() { } @Nested - @DisplayName("deleteMessage 테스트") - class DeleteMessageTest { + @DisplayName("메시지 삭제") + class DeleteMessage { @Test @DisplayName("성공: 메시지 삭제") void deleteMessage_Success() { - // given - when(messageRepository.findById(testMessageId)).thenReturn(Optional.of(testMessage)); - // when - messageService.deleteMessage(testMessageId); + messageService.deleteMessage(testMessage.getId()); // then - verify(messageRepository, times(1)).findById(testMessageId); - verify(messageRepository, times(1)).delete(testMessage); + assertThatThrownBy(() -> messageService.getMessage(testMessage.getId())) + .isInstanceOf(NotFoundException.class); } @Test - @DisplayName("실패: 삭제할 메시지를 찾을 수 없음") - void deleteMessage_Fail_NotFound() { + @DisplayName("실패: 메시지를 찾을 수 없음") + void deleteMessage_MessageNotFound() { // given - when(messageRepository.findById(testMessageId)).thenReturn(Optional.empty()); + Long nonExistentMessageId = 999L; // when & then - assertThrows(NotFoundException.class, () -> messageService.deleteMessage(testMessageId)); - verify(messageRepository, times(1)).findById(testMessageId); - verify(messageRepository, never()).delete(any()); + assertThatThrownBy(() -> messageService.deleteMessage(nonExistentMessageId)) + .isInstanceOf(NotFoundException.class); } } } diff --git a/chat_service/src/test/java/com/synapse/chat_service/testutil/TestObjectFactory.java b/chat_service/src/test/java/com/synapse/chat_service/testutil/TestObjectFactory.java index b87ef18..9a43ed4 100644 --- a/chat_service/src/test/java/com/synapse/chat_service/testutil/TestObjectFactory.java +++ b/chat_service/src/test/java/com/synapse/chat_service/testutil/TestObjectFactory.java @@ -1,9 +1,8 @@ package com.synapse.chat_service.testutil; -import com.synapse.chat_service.domain.entity.ChatRoom; +import com.synapse.chat_service.domain.entity.Conversation; import com.synapse.chat_service.domain.entity.ChatUsage; import com.synapse.chat_service.domain.entity.Message; -import com.synapse.chat_service.domain.entity.User; import com.synapse.chat_service.domain.entity.enums.SenderType; import com.synapse.chat_service.domain.entity.enums.SubscriptionType; @@ -17,69 +16,63 @@ */ public class TestObjectFactory { - // ChatRoom 생성 메서드들 - public static ChatRoom createChatRoom(Long userId, String title) { - return ChatRoom.builder() + // Conversation 생성 메서드들 + public static Conversation createConversation(Long userId) { + return Conversation.builder() .userId(userId) - .title(title) .build(); } - public static ChatRoom createDefaultChatRoom() { - return createChatRoom(1L, "테스트 채팅방"); + public static Conversation createDefaultConversation() { + return createConversation(1L); } - public static ChatRoom createChatRoomWithUserId(Long userId) { - return createChatRoom(userId, "테스트 채팅방"); + public static Conversation createConversationWithUserId(Long userId) { + return createConversation(userId); } - public static ChatRoom createChatRoomWithTitle(String title) { - return createChatRoom(1L, title); - } - - public static ChatRoom createChatRoomWithId(UUID id, Long userId, String title) { - ChatRoom chatRoom = ChatRoom.builder() + public static Conversation createConversationWithId(UUID id, Long userId) { + Conversation conversation = Conversation.builder() .userId(userId) - .title(title) .build(); - setId(chatRoom, id); - return chatRoom; + setId(conversation, id); + return conversation; } - public static ChatRoom createChatRoomWithCreatedDate(Long userId, String title, LocalDateTime createdDate) { - ChatRoom chatRoom = createChatRoom(userId, title); - setCreatedDate(chatRoom, createdDate); - return chatRoom; + public static Conversation createConversationWithCreatedDate(Long userId, LocalDateTime createdDate) { + Conversation conversation = createConversation(userId); + setCreatedDate(conversation, createdDate); + return conversation; } // Message 생성 메서드들 - public static Message createMessage(ChatRoom chatRoom, SenderType senderType, String content) { + public static Message createMessage(Conversation conversation, SenderType senderType, String content) { return Message.builder() - .chatRoom(chatRoom) + .conversation(conversation) .senderType(senderType) .content(content) .build(); } - public static Message createUserMessage(ChatRoom chatRoom, String content) { - return createMessage(chatRoom, SenderType.USER, content); + public static Message createUserMessage(Conversation conversation, String content) { + return createMessage(conversation, SenderType.USER, content); } - public static Message createAssistantMessage(ChatRoom chatRoom, String content) { - return createMessage(chatRoom, SenderType.ASSISTANT, content); + public static Message createAssistantMessage(Conversation conversation, String content) { + return createMessage(conversation, SenderType.ASSISTANT, content); } - public static Message createDefaultUserMessage(ChatRoom chatRoom) { - return createUserMessage(chatRoom, "사용자 테스트 메시지"); + public static Message createDefaultUserMessage(Conversation conversation) { + return createUserMessage(conversation, "사용자 테스트 메시지"); } - public static Message createDefaultAssistantMessage(ChatRoom chatRoom) { - return createAssistantMessage(chatRoom, "AI 테스트 응답"); + public static Message createDefaultAssistantMessage(Conversation conversation) { + return createAssistantMessage(conversation, "AI 테스트 응답"); } - public static Message createMessageWithId(Long id, ChatRoom chatRoom, SenderType senderType, String content) { + public static Message createMessageWithId(Long id, Conversation conversation, SenderType senderType, String content) { Message message = Message.builder() - .chatRoom(chatRoom) + .conversation(conversation) .senderType(senderType) .content(content) .build(); @@ -87,16 +80,16 @@ public static Message createMessageWithId(Long id, ChatRoom chatRoom, SenderType return message; } - public static Message createUserMessageWithId(Long id, ChatRoom chatRoom, String content) { - return createMessageWithId(id, chatRoom, SenderType.USER, content); + public static Message createUserMessageWithId(Long id, Conversation conversation, String content) { + return createMessageWithId(id, conversation, SenderType.USER, content); } - public static Message createAssistantMessageWithId(Long id, ChatRoom chatRoom, String content) { - return createMessageWithId(id, chatRoom, SenderType.ASSISTANT, content); + public static Message createAssistantMessageWithId(Long id, Conversation conversation, String content) { + return createMessageWithId(id, conversation, SenderType.ASSISTANT, content); } - public static Message createMessageWithCreatedDate(ChatRoom chatRoom, SenderType senderType, String content, LocalDateTime createdDate) { - Message message = createMessage(chatRoom, senderType, content); + public static Message createMessageWithCreatedDate(Conversation conversation, SenderType senderType, String content, LocalDateTime createdDate) { + Message message = createMessage(conversation, senderType, content); setCreatedDate(message, createdDate); return message; } @@ -126,30 +119,7 @@ public static ChatUsage createDefaultProChatUsage() { return createProChatUsage(1L); } - // User 생성 메서드들 - public static User createUser(Long id, String username, String email) { - return User.builder() - .id(id) - .username(username) - .email(email) - .build(); - } - - public static User createDefaultUser() { - return createUser(1L, "testuser1", "testuser1@example.com"); - } - public static User createUserWithId(Long id) { - return createUser(id, "testuser" + id, "testuser" + id + "@example.com"); - } - - public static User createUserWithUsername(String username) { - return createUser(1L, username, username + "@example.com"); - } - - public static User createUserWithEmail(String email) { - return createUser(1L, "testuser", email); - } // Private 헬퍼 메서드들 private static void setCreatedDate(Object entity, LocalDateTime createdDate) { @@ -176,7 +146,6 @@ private static void setId(Object entity, Object id) { public static class TestConstants { public static final Long DEFAULT_USER_ID = 1L; public static final Long ANOTHER_USER_ID = 2L; - public static final String DEFAULT_CHAT_ROOM_TITLE = "테스트 채팅방"; public static final String DEFAULT_USER_MESSAGE = "사용자 테스트 메시지"; public static final String DEFAULT_ASSISTANT_MESSAGE = "AI 테스트 응답"; public static final Integer FREE_MESSAGE_LIMIT = 100; diff --git a/chat_service/src/test/resources/application-test.yml b/chat_service/src/test/resources/application-test.yml index 94752a0..5d8e519 100644 --- a/chat_service/src/test/resources/application-test.yml +++ b/chat_service/src/test/resources/application-test.yml @@ -23,6 +23,10 @@ spring: open-in-view: false show-sql: true +session: + expiration-hours: 24 + max-sessions-per-user: 5 + logging: level: org: