diff --git a/chat_service/build.gradle b/chat_service/build.gradle index a838f0f..94a81d0 100644 --- a/chat_service/build.gradle +++ b/chat_service/build.gradle @@ -15,9 +15,17 @@ java { repositories { mavenCentral() + maven { url 'https://repo.spring.io/milestone' } } dependencies { + // Spring AI + implementation platform('org.springframework.ai:spring-ai-bom:1.0.0-M4') + implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter' + implementation 'org.springframework.ai:spring-ai-anthropic-spring-boot-starter' + implementation 'org.springframework.ai:spring-ai-vertex-ai-gemini-spring-boot-starter' + // Spring WebFlux + implementation 'org.springframework.boot:spring-boot-starter-webflux' // Spring Web implementation 'org.springframework.boot:spring-boot-starter-web' // WebSocket diff --git a/chat_service/src/main/java/com/synapse/chat_service/config/AsyncConfig.java b/chat_service/src/main/java/com/synapse/chat_service/config/AsyncConfig.java new file mode 100644 index 0000000..da924aa --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/config/AsyncConfig.java @@ -0,0 +1,34 @@ +package com.synapse.chat_service.config; + +import java.util.concurrent.Executor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * 비동기 처리 설정 + * AI 모델 호출의 대용량 트래픽을 고려한 스레드 풀 구성 + */ +@Configuration +@EnableAsync +public class AsyncConfig { + + /** + * AI 서비스 전용 스레드 풀 + * 대규모 동시 요청을 처리할 수 있도록 설정 + */ + @Bean(name = "aiTaskExecutor") + public Executor aiTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(10); // 기본 스레드 수 + executor.setMaxPoolSize(50); // 최대 스레드 수 + executor.setQueueCapacity(100); // 대기 큐 크기 + executor.setThreadNamePrefix("AI-"); // 스레드 이름 접두사 + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.initialize(); + return executor; + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/config/WebSecurityConfig.java b/chat_service/src/main/java/com/synapse/chat_service/config/WebSecurityConfig.java index 73416e3..2afbc3c 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/config/WebSecurityConfig.java +++ b/chat_service/src/main/java/com/synapse/chat_service/config/WebSecurityConfig.java @@ -10,13 +10,12 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler; +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; import com.synapse.chat_service.filter.CustomAuthenticationFilter; @@ -40,19 +39,15 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) - .csrf(csrf -> csrf - .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) - ) - - .addFilterBefore(customAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .csrf(csrf -> csrf.disable()) + .addFilterBefore(customAuthenticationFilter, BearerTokenAuthenticationFilter.class) .authorizeHttpRequests(auth -> auth + .requestMatchers("/chat-test.html").permitAll() // WebSocket 핸드셰이크 경로 ("/ws/**") - 인증된 사용자만 허용 - .requestMatchers("/ws/**").authenticated() + .requestMatchers("/ws/**").permitAll() // 채팅 관련 API ("/api/v1/messages/**", "/api/v1/ai-chat/**") - 인증된 사용자만 허용 .requestMatchers("/api/v1/messages/**", "/api/v1/ai-chat/**").authenticated() - // CSRF 토큰 발급 API ("/api/v1/csrf-token") - 인증된 사용자만 허용 - .requestMatchers("/api/v1/csrf-token").authenticated() .requestMatchers("/api/internal/**").access(AuthorizationManagers.allOf( AuthorityAuthorizationManager.hasAuthority("SCOPE_api.internal"), AuthorityAuthorizationManager.hasAuthority("SCOPE_chat:read") 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 index bee36ea..703554b 100644 --- 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 @@ -31,9 +31,10 @@ public void configureMessageBroker(MessageBrokerRegistry config) { // 클라이언트에서 메시지를 받을 때 사용할 prefix config.setApplicationDestinationPrefixes("/app"); // 클라이언트가 구독할 때 사용할 prefix (AI 응답 수신용) - config.enableSimpleBroker("/assistant") + config.enableSimpleBroker("/topic", "/queue") .setTaskScheduler(heartbeatScheduler()) .setHeartbeatValue(new long[] {10000, 10000}); + config.setUserDestinationPrefix("/user"); } @Override diff --git a/chat_service/src/main/java/com/synapse/chat_service/config/WebSocketSecurityConfig.java b/chat_service/src/main/java/com/synapse/chat_service/config/WebSocketSecurityConfig.java deleted file mode 100644 index 2599c5b..0000000 --- a/chat_service/src/main/java/com/synapse/chat_service/config/WebSocketSecurityConfig.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.synapse.chat_service.config; - -import org.springframework.context.annotation.Configuration; -import org.springframework.messaging.Message; -import org.springframework.security.authorization.AuthorizationManager; -import org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity; -import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager; -import org.springframework.context.annotation.Bean; - -import static org.springframework.messaging.simp.SimpMessageType.CONNECT; -import static org.springframework.messaging.simp.SimpMessageType.CONNECT_ACK; -import static org.springframework.messaging.simp.SimpMessageType.DISCONNECT; -import static org.springframework.messaging.simp.SimpMessageType.HEARTBEAT; -import static org.springframework.messaging.simp.SimpMessageType.UNSUBSCRIBE; -import static org.springframework.messaging.simp.SimpMessageType.DISCONNECT_ACK; - -@Configuration(proxyBeanMethods = false) -@EnableWebSocketSecurity -public class WebSocketSecurityConfig { - private static final String ASSISTANT_SUBSCRIBE_DEST = "/assistant/**"; - private static final String MESSAGE_DEST = "/app/v1/message/**"; - - @Bean - public AuthorizationManager> messageAuthorizationManager() { - MessageMatcherDelegatingAuthorizationManager.Builder builder = - new MessageMatcherDelegatingAuthorizationManager.Builder(); - - return builder - // 연결 요청은 인증된 사용자만 허용 - .simpTypeMatchers( - CONNECT, - CONNECT_ACK, - HEARTBEAT, - UNSUBSCRIBE - ).authenticated() - // 구독 요청 권한 설정 (AI 응답 수신용) - .simpSubscribeDestMatchers(ASSISTANT_SUBSCRIBE_DEST).authenticated() - // 메시지 전송 권한 설정 (AI 채팅 통합) - .simpMessageDestMatchers(MESSAGE_DEST).authenticated() - // 연결 해제는 모든 사용자 허용 - .simpTypeMatchers( - DISCONNECT, - DISCONNECT_ACK - ).permitAll() - .anyMessage().authenticated() - .build(); - } -} 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 index b5de7c1..aea4115 100644 --- 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 @@ -1,13 +1,14 @@ package com.synapse.chat_service.controller; +import com.synapse.chat_service.dto.request.ChatMessageRequest; +import com.synapse.chat_service.dto.response.ChatHistoryResponse; import com.synapse.chat_service.dto.response.MessageResponse; 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.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -20,58 +21,20 @@ 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 + @GetMapping("/conversation/list") + public ResponseEntity> getMyConversationList( + @AuthenticationPrincipal UUID userId ) { - Page response = messageService.getMessagesByUserIdWithPaging(userId, pageable); + List response = messageService.getConversationListByUserId(userId); 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 + public ResponseEntity getMyAiChatHistoryRecentFirst( + @AuthenticationPrincipal UUID userId, + @Valid ChatMessageRequest request ) { - long messageCount = messageService.getMessageCountByUserId(userId); - UUID conversationId = messageService.getConversationId(userId); - - AiChatStatsResponse response = new AiChatStatsResponse( - conversationId, - messageCount - ); - + ChatHistoryResponse response = messageService.getMessagesRecentFirst(userId, request.size(), request.cursor()); return ResponseEntity.ok(response); } - - public record AiChatStatsResponse( - UUID conversationId, - long totalMessageCount - ) {} } 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 9974096..a7c49e4 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 @@ -1,40 +1,26 @@ package com.synapse.chat_service.controller; +import java.util.UUID; + +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Controller; + import com.synapse.chat_service.dto.request.MessageRequest; -import com.synapse.chat_service.dto.response.MessageResponse; import com.synapse.chat_service.service.MessageService; -import jakarta.validation.Valid; + import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -@RestController -@RequestMapping("/api/v1/messages") +@Controller @RequiredArgsConstructor public class MessageController { - + private final MessageService messageService; - - @PostMapping - public ResponseEntity createMessage( - @Valid @RequestBody MessageRequest.Create request - ) { - MessageResponse.Detail response = messageService.createMessage(request); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } - - @GetMapping("/{messageId}") - public ResponseEntity getMessage( - @PathVariable Long messageId - ) { - MessageResponse.Detail response = messageService.getMessage(messageId); - return ResponseEntity.ok(response); - } - - @DeleteMapping("/{messageId}") - public ResponseEntity deleteMessage(@PathVariable Long messageId) { - messageService.deleteMessage(messageId); - return ResponseEntity.noContent().build(); + + @MessageMapping("/chat") + public void handleChatMessage(MessageRequest.Chat chatMessage, @Header("simpUser") Authentication authentication) { + UUID userId = UUID.fromString(authentication.getName()); + messageService.processAndRespondToMessage(userId, chatMessage); } } 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 20e5563..826558c 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 @@ -1,11 +1,12 @@ package com.synapse.chat_service.domain.entity; +import java.util.UUID; + import com.synapse.chat_service.domain.common.BaseTimeEntity; import com.synapse.chat_service.domain.entity.enums.SubscriptionType; import jakarta.persistence.*; import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotNull; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -25,25 +26,23 @@ public class ChatUsage extends BaseTimeEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "user_id", nullable = false, unique = true) - @NotNull - private Long userId; + @Column(name = "user_id", nullable = false, unique = true, columnDefinition = "uuid") + private UUID userId; @Enumerated(EnumType.STRING) @Column(name = "subscription_type", nullable = false) - @NotNull private SubscriptionType subscriptionType; - @Column(name = "message_count", nullable = false) @Min(0) + @Column(name = "message_count", nullable = false) private Integer messageCount = 0; - @Column(name = "message_limit", nullable = false) @Min(0) + @Column(name = "message_limit", nullable = false) private Integer messageLimit; @Builder - public ChatUsage(Long userId, SubscriptionType subscriptionType, Integer messageLimit) { + public ChatUsage(UUID userId, SubscriptionType subscriptionType, Integer messageLimit) { this.userId = userId; this.subscriptionType = subscriptionType; this.messageLimit = messageLimit; 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 index aa2c559..3c2bda1 100644 --- 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 @@ -1,7 +1,6 @@ package com.synapse.chat_service.domain.entity; import jakarta.persistence.*; -import jakarta.validation.constraints.NotNull; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -29,15 +28,14 @@ public class Conversation extends BaseTimeEntity { @Column(name = "conversation_id", columnDefinition = "UUID") private UUID id; - @NotNull - @Column(name = "user_id", nullable = false, unique = true) - private Long userId; + @Column(name = "user_id", nullable = false, unique = true, columnDefinition = "uuid") + private UUID userId; @OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true) private List messages = new ArrayList<>(); @Builder - public Conversation(Long userId) { + public Conversation(UUID 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 4f3b008..5717b13 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 @@ -2,11 +2,8 @@ import com.synapse.chat_service.domain.common.BaseTimeEntity; import com.synapse.chat_service.domain.entity.enums.SenderType; -import com.synapse.chat_service.exception.commonexception.ValidException; -import com.synapse.chat_service.exception.domain.ExceptionType; import jakarta.persistence.*; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.AccessLevel; import lombok.Builder; @@ -33,34 +30,13 @@ public class Message extends BaseTimeEntity { @NotNull private SenderType senderType; - @NotBlank @Column(name = "content", nullable = false, columnDefinition = "TEXT") private String content; @Builder public Message(Conversation conversation, SenderType senderType, String content) { - validateContent(content); this.conversation = conversation; this.senderType = senderType; this.content = content; } - - /** - * 메시지 내용 업데이트 (도메인 로직) - * @param newContent 새로운 메시지 내용 - */ - public void updateContent(String newContent) { - validateContent(newContent); - this.content = newContent; - } - - private void validateContent(String content) { - if (content == null || content.trim().isEmpty()) { - throw new ValidException(ExceptionType.INVALID_INPUT_VALUE, "메시지 내용은 비어있을 수 없습니다."); - } - - if (content.length() > 1000) { - throw new ValidException(ExceptionType.INVALID_INPUT_VALUE, "메시지 내용은 1000자를 초과할 수 없습니다."); - } - } } 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 index aecc977..724b9ab 100644 --- 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 @@ -5,19 +5,12 @@ import com.synapse.chat_service.domain.entity.Conversation; +import java.util.List; import java.util.Optional; import java.util.UUID; @Repository public interface ConversationRepository extends JpaRepository { - - /** - * 사용자 ID로 대화 조회 (각 사용자는 하나의 대화만 가짐) - */ - Optional findByUserId(Long userId); - - /** - * 사용자 ID로 대화 존재 여부 확인 - */ - boolean existsByUserId(Long userId); + Optional findByUserId(UUID userId); + Optional> findByUserIdOrderByCreatedDateDesc(UUID 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 5a0dec3..abc0135 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 @@ -1,28 +1,19 @@ 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.Message; +import com.synapse.chat_service.dto.response.MessageResponse; import java.util.List; import java.util.UUID; @Repository public interface MessageRepository extends JpaRepository { - - List findByConversationIdOrderByCreatedDateAsc(UUID conversationId); - - Page findByConversationIdOrderByCreatedDateAsc(UUID conversationId, Pageable pageable); - - Page findByConversationIdOrderByCreatedDateDesc(UUID conversationId, Pageable pageable); - - @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 countByConversationId(UUID conversationId); + // 커서 기반 페이지네이션 - 최신순 (limit 직접 지정) + @Query(value = "SELECT * FROM message WHERE user_id = :userId AND (:cursor IS NULL OR id < :cursor) ORDER BY id DESC LIMIT :limit", nativeQuery = true) + List findByUserIdWithCursorDesc(@Param("userId") UUID userId, @Param("cursor") Long cursor, @Param("limit") int limit); } diff --git a/chat_service/src/main/java/com/synapse/chat_service/dto/request/ChatMessageRequest.java b/chat_service/src/main/java/com/synapse/chat_service/dto/request/ChatMessageRequest.java new file mode 100644 index 0000000..0c23fc1 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/dto/request/ChatMessageRequest.java @@ -0,0 +1,19 @@ +package com.synapse.chat_service.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +public record ChatMessageRequest( + + @Min(value = 1, message = "조회할 개수는 1 이상이어야 합니다") + @Max(value = 100, message = "조회할 개수는 100 이하여야 합니다") + Integer size, + + String cursor +) { + public ChatMessageRequest { + if (size == null) { + size = 20; + } + } +} 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 066b46c..70e456b 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,24 +1,21 @@ package com.synapse.chat_service.dto.request; -import com.synapse.chat_service.domain.entity.enums.SenderType; +import com.synapse.chat_service.service.ai.AIModelType; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; public class MessageRequest { - - public record Create( - @NotNull(message = "사용자 ID는 필수입니다.") - Long userId, + public record Chat( + @NotNull(message = "AI 모델 타입은 필수입니다.") + AIModelType modelType, - @NotNull(message = "발신자 타입은 필수입니다.") - SenderType senderType, + @NotBlank(message = "프롬프트 메시지는 필수입니다.") + String prompt, - @NotBlank(message = "메시지 내용은 필수입니다.") - String content - ) {} - - public record Update( - @NotBlank(message = "메시지 내용은 필수입니다.") - String content + @NotBlank(message = "세션 ID는 필수입니다.") + String sessionId, + + @NotBlank(message = "메시지 ID는 필수입니다.") + String messageId ) {} } diff --git a/chat_service/src/main/java/com/synapse/chat_service/dto/response/ChatHistoryResponse.java b/chat_service/src/main/java/com/synapse/chat_service/dto/response/ChatHistoryResponse.java new file mode 100644 index 0000000..5f8557e --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/dto/response/ChatHistoryResponse.java @@ -0,0 +1,12 @@ +package com.synapse.chat_service.dto.response; + +import java.util.List; + +public record ChatHistoryResponse( + List data, + PaginationDto pagination +) { + public static ChatHistoryResponse of(List data, PaginationDto pagination) { + return new ChatHistoryResponse(data, pagination); + } +} 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 e7dbdbc..23e9f62 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 @@ -1,22 +1,43 @@ package com.synapse.chat_service.dto.response; +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.service.ai.AIModelType; import java.time.LocalDateTime; import java.util.UUID; public class MessageResponse { + public record ConversationInfo( + UUID conversationId, + UUID userId, + LocalDateTime createdDate, + LocalDateTime lastModifiedDate + ) { + public static ConversationInfo from(Conversation conversation) { + return new ConversationInfo( + conversation.getId(), + conversation.getUserId(), + conversation.getCreatedDate(), + conversation.getUpdatedDate() + ); + } + } - public record Simple( + /** + * 대화 히스토리 조회용 응답 DTO + * 페이징 처리된 메시지 목록에서 사용 + */ + public record History( Long id, UUID conversationId, SenderType senderType, String content, LocalDateTime createdDate ) { - public static Simple from(Message message) { - return new Simple( + public static History from(Message message) { + return new History( message.getId(), message.getConversation().getId(), message.getSenderType(), @@ -26,23 +47,28 @@ public static Simple from(Message message) { } } - public record Detail( - Long id, - UUID conversationId, - SenderType senderType, - String content, - LocalDateTime createdDate, - LocalDateTime updatedDate + /** + * WebSocket을 통해 AI 응답을 클라이언트에게 전송하는 DTO + */ + public record Chat( + String response, + AIModelType modelType, + String sessionId, + String messageId, + LocalDateTime timestamp, + ResponseStatus status, + String errorMessage ) { - public static Detail from(Message message) { - return new Detail( - message.getId(), - message.getConversation().getId(), - message.getSenderType(), - message.getContent(), - message.getCreatedDate(), - message.getUpdatedDate() - ); + public static Chat success(String response, AIModelType modelType, String sessionId, String messageId) { + return new Chat(response, modelType, sessionId, messageId, LocalDateTime.now(), ResponseStatus.SUCCESS, null); + } + + public static Chat error(AIModelType modelType, String sessionId, String messageId, String errorMessage) { + return new Chat(null, modelType, sessionId, messageId, LocalDateTime.now(), ResponseStatus.ERROR, errorMessage); } } + + public enum ResponseStatus { + SUCCESS, ERROR + } } diff --git a/chat_service/src/main/java/com/synapse/chat_service/dto/response/PaginationDto.java b/chat_service/src/main/java/com/synapse/chat_service/dto/response/PaginationDto.java new file mode 100644 index 0000000..b089cc0 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/dto/response/PaginationDto.java @@ -0,0 +1,11 @@ +package com.synapse.chat_service.dto.response; + +public record PaginationDto( + String nextCursor, + boolean hasNext, + int limit +) { + public static PaginationDto of(String nextCursor, boolean hasNext, int limit) { + return new PaginationDto(nextCursor, hasNext, limit); + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/filter/CustomAuthenticationFilter.java b/chat_service/src/main/java/com/synapse/chat_service/filter/CustomAuthenticationFilter.java index bdeeffe..203ffb3 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/filter/CustomAuthenticationFilter.java +++ b/chat_service/src/main/java/com/synapse/chat_service/filter/CustomAuthenticationFilter.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.UUID; import java.util.stream.Collectors; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -43,7 +44,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse .collect(Collectors.toList()); } - Long principal = Long.parseLong(memberId); + UUID principal = UUID.fromString(memberId); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(principal, null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); diff --git a/chat_service/src/main/java/com/synapse/chat_service/interceptor/WebSocketChannelInterceptor.java b/chat_service/src/main/java/com/synapse/chat_service/interceptor/WebSocketChannelInterceptor.java index 807a6a5..e2de2cb 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/interceptor/WebSocketChannelInterceptor.java +++ b/chat_service/src/main/java/com/synapse/chat_service/interceptor/WebSocketChannelInterceptor.java @@ -52,15 +52,21 @@ public Message preSend(Message message, MessageChannel channel) { * 사용자 인증 정보를 확인하고 WebSocket 세션을 생성합니다. */ private void handleConnect(StompHeaderAccessor accessor) { - String jwtToken = accessor.getFirstNativeHeader("Authorization"); - - Authentication authentication = jwtTokenProvider.verifyAndDecode(jwtToken); - // STOMP 세션에 인증 정보 저장 - accessor.setUser(authentication); + String header = accessor.getFirstNativeHeader("Authorization"); + + if (header == null || !header.startsWith("Bearer ")) { + log.warn("CONNECT 프레임에서 유효한 Authorization 헤더가 없습니다: sessionId={}", accessor.getSessionId()); + return; + } + + Authentication authentication = jwtTokenProvider.verifyAndDecode(header); String sessionId = accessor.getSessionId(); String userId = authentication.getName(); + // STOMP 세션에 인증 정보 저장 + accessor.setUser(authentication); + if (userId == null) { log.warn("CONNECT 프레임에서 인증되지 않은 사용자 감지: sessionId={}", sessionId); return; diff --git a/chat_service/src/main/java/com/synapse/chat_service/provider/JwtTokenProvider.java b/chat_service/src/main/java/com/synapse/chat_service/provider/JwtTokenProvider.java index 47433e5..13f2d6b 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/provider/JwtTokenProvider.java +++ b/chat_service/src/main/java/com/synapse/chat_service/provider/JwtTokenProvider.java @@ -33,8 +33,10 @@ public JwtTokenProvider(@Value("${secret.key}") String secretKey) { this.verifier = JWT.require(this.algorithm).build(); } - public final Authentication verifyAndDecode(String token) throws JWTVerificationException { - DecodedJWT decodedJWT = verifier.verify(token); + public final Authentication verifyAndDecode(String header) throws JWTVerificationException { + String jwtToken = header.substring(7); + + DecodedJWT decodedJWT = verifier.verify(jwtToken); String userId = decodedJWT.getSubject(); Claim authClaim = decodedJWT.getClaim("role"); 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 ffb97eb..929a23e 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 @@ -4,23 +4,28 @@ import com.synapse.chat_service.domain.entity.Message; 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.domain.entity.enums.SenderType; +import com.synapse.chat_service.dto.response.ChatHistoryResponse; 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.dto.response.PaginationDto; import com.synapse.chat_service.session.RedisAiChatManager; +import com.synapse.chat_service.service.ai.AIModelServiceFactory; +import com.synapse.chat_service.service.ai.AIModelService; +import com.synapse.chat_service.dto.request.MessageRequest; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.List; import java.util.Optional; import java.util.UUID; -import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -29,35 +34,29 @@ public class MessageService { private final MessageRepository messageRepository; private final ConversationRepository conversationRepository; private final RedisAiChatManager redisAiChatManager; + private final AIModelServiceFactory aiModelServiceFactory; + private final SimpMessagingTemplate messagingTemplate; @Transactional - public MessageResponse.Detail createMessage(MessageRequest.Create request) { + public MessageResponse.History createMessage(UUID userId, SenderType senderType, String content) { // 사용자의 대화가 존재하지 않으면 자동으로 생성 - Conversation conversation = getOrCreateConversation(request.userId()); + Conversation conversation = getOrCreateConversation(userId); Message message = Message.builder() .conversation(conversation) - .senderType(request.senderType()) - .content(request.content()) + .senderType(senderType) + .content(content) .build(); Message savedMessage = messageRepository.save(message); - return MessageResponse.Detail.from(savedMessage); - } - - /** - * 사용자의 대화 조회 (공통 메소드) - * 모든 conversation 조회 로직을 통합하여 중복을 제거 - */ - private Optional findConversationByUserId(Long userId) { - return conversationRepository.findByUserId(userId); + return MessageResponse.History.from(savedMessage); } /** * 사용자의 대화를 조회하거나 없으면 새로 생성 * Redis의 AiChatInfo와 DB의 Conversation 간 일관성을 보장 */ - private Conversation getOrCreateConversation(Long userId) { + public Conversation getOrCreateConversation(UUID userId) { return findConversationByUserId(userId) .map(conversation -> { // 기존 대화가 있으면 Redis 정보 동기화 @@ -69,95 +68,143 @@ private Conversation getOrCreateConversation(Long userId) { Conversation newConversation = Conversation.builder() .userId(userId) .build(); + Conversation savedConversation = conversationRepository.save(newConversation); // Redis에 새로운 대화 정보 저장 redisAiChatManager.createOrUpdateAiChatWithConversation( - userId.toString(), + userId.toString(), savedConversation.getId() ); return savedConversation; }); } - - public MessageResponse.Detail getMessage(Long messageId) { - Message message = findMessageById(messageId); - return MessageResponse.Detail.from(message); - } - - 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 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)); + + private Optional findConversationByUserId(UUID userId) { + return conversationRepository.findByUserId(userId); } - - 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)); + + private Optional> findConversationListByUserId(UUID userId) { + return conversationRepository.findByUserIdOrderByCreatedDateDesc(userId); } - 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()); - }) + /** + * 추후 쿼리 최적화 필요 -> 조회시점에 불필요한 쿼리를 날리지 않는지 확인 필요 + * @param userId + * @return + */ + public List getConversationListByUserId(UUID userId) { + // 사용자의 대화방 정보 조회 (없으면 null 반환) + return findConversationListByUserId(userId) + .map(conversationList -> conversationList.stream() + .map(MessageResponse.ConversationInfo::from) + .toList()) .orElse(List.of()); } - @Transactional - public void deleteMessage(Long messageId) { - Message message = findMessageById(messageId); - messageRepository.delete(message); + /** + * 커서 방식으로 쿼리 최적화 필요 -> 많은 대화 중에 메시지가 많을 수 있음 + * @param userId + * @param size + * @param cursor + * @return + */ + public ChatHistoryResponse getMessagesRecentFirst(UUID userId, Integer size, String cursor) { + int queryLimit = size + 1; + + Long cursorId = cursor != null && !cursor.isEmpty() ? parseCursor(cursor) : null; + + List messages = messageRepository.findByUserIdWithCursorDesc( + userId, cursorId, queryLimit + ); + + // 다음 페이지 존재 여부 확인 + boolean hasNext = messages.size() > size; + if (hasNext) { + messages = messages.subList(0, size); // 실제 반환할 데이터만 유지 + } + + // 다음 커서 생성 (마지막 메시지의 ID) + String nextCursor = hasNext && !messages.isEmpty() + ? generateCursor(messages.get(messages.size() - 1).id()) + : null; + + // 페이지네이션 정보 생성 + PaginationDto pagination = PaginationDto.of(nextCursor, hasNext, size); + + return ChatHistoryResponse.of(messages, pagination); } - public long getMessageCount(Long userId) { - // 사용자의 대화 조회 (없으면 0 반환) - return findConversationByUserId(userId) - .map(conversation -> messageRepository.countByConversationId(conversation.getId())) - .orElse(0L); + /** + * ChatController로부터 위임받은 메시지 처리 및 응답 로직 + * 사용자 메시지 저장, AI 호출, 응답 저장, 클라이언트 전송을 통합 처리 + */ + @Transactional + public void processAndRespondToMessage(UUID userId, MessageRequest.Chat chatMessage) { + try { + // 사용자 메시지 저장 + createMessage(userId, SenderType.USER, chatMessage.prompt()); + log.info("사용자 메시지 저장 완료 - 사용자ID: {}, 세션ID: {}", userId, chatMessage.sessionId()); + } catch (Exception e) { + log.error("메시지 처리 중 최상위 오류 발생 - 사용자ID: {}", userId, e); + sendErrorResponse(userId, chatMessage, "메시지 처리 중 서버 오류가 발생했습니다."); + } + + // AI 서비스 호출 + AIModelService modelService = aiModelServiceFactory.getService(chatMessage.modelType()); + modelService.generateResponse(chatMessage.prompt()) + .thenApply(aiResponse -> { + createMessage(userId, SenderType.ASSISTANT, aiResponse); + log.info("AI 응답 저장 완료 - 사용자ID: {}", userId); + return MessageResponse.Chat.success( + aiResponse, chatMessage.modelType(), chatMessage.sessionId(), chatMessage.messageId()); + }) + .thenAccept(aiResponse -> { + sendResponse(userId, aiResponse); + log.info("AI 응답 전송 완료 - 사용자ID: {}", userId); + }) + .exceptionally(throwable -> { + log.error("AI 응답 처리 파이프라인 실패 - 사용자ID: {}, 원인: {}", userId, throwable.getMessage(), throwable); + sendErrorResponse(userId, chatMessage, "AI 응답을 처리하는 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); + return null; + }); } - - public long getMessageCountByUserId(Long userId) { - return getMessageCount(userId); + + /** + * 에러 응답을 클라이언트에게 전송 + */ + public void sendErrorResponse(UUID userId, MessageRequest.Chat chatMessage, String errorMessage) { + MessageResponse.Chat errorResponse = MessageResponse.Chat.error( + chatMessage.modelType(), + chatMessage.sessionId(), + chatMessage.messageId(), + errorMessage + ); + + sendResponse(userId, errorResponse); + + log.warn("에러 응답 전송 - 세션: {}, 메시지ID: {}, 에러: {}", + chatMessage.sessionId(), chatMessage.messageId(), errorMessage); } /** - * 사용자의 대화 ID 조회 (없으면 null 반환) + * 응답을 클라이언트에게 전송 */ - public UUID getConversationId(Long userId) { - return findConversationByUserId(userId) - .map(Conversation::getId) - .orElse(null); + private void sendResponse(UUID userId, MessageResponse.Chat response) { + messagingTemplate.convertAndSendToUser( + userId.toString(), + "/queue/response", + response + ); } - - private Message findMessageById(Long messageId) { - return messageRepository.findById(messageId) - .orElseThrow(() -> new NotFoundException(ExceptionType.MESSAGE_NOT_FOUND, "ID: " + messageId)); + + private Long parseCursor(String cursor) { + String decodedCursor = new String(Base64.getDecoder().decode(cursor), StandardCharsets.UTF_8); + return Long.parseLong(decodedCursor); + } + + private String generateCursor(Long messageId) { + return Base64.getEncoder().encodeToString(messageId.toString().getBytes(StandardCharsets.UTF_8)); } } diff --git a/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelService.java b/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelService.java new file mode 100644 index 0000000..80e294a --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelService.java @@ -0,0 +1,23 @@ +package com.synapse.chat_service.service.ai; + +import java.util.concurrent.CompletableFuture; + +/** + * AI 모델 서비스의 공통 인터페이스 + * 각 AI 모델별 서비스는 이 인터페이스를 구현하여 일관된 API를 제공 + */ +public interface AIModelService { + + /** + * 해당 서비스가 지원하는 AI 모델 타입을 반환 + * @return 지원하는 AIModelType + */ + AIModelType getModelType(); + + /** + * 주어진 프롬프트에 대해 AI 응답을 비동기적으로 생성 + * @param prompt 사용자 입력 프롬프트 + * @return AI 응답을 담은 CompletableFuture + */ + CompletableFuture generateResponse(String prompt); +} \ No newline at end of file diff --git a/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelServiceFactory.java b/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelServiceFactory.java new file mode 100644 index 0000000..ab00f20 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelServiceFactory.java @@ -0,0 +1,36 @@ +package com.synapse.chat_service.service.ai; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class AIModelServiceFactory { + + private final Map services; + + public AIModelServiceFactory(List serviceList) { + this.services = serviceList.stream() + .collect(Collectors.toUnmodifiableMap(AIModelService::getModelType, Function.identity())); + } + + /** + * 지정된 모델 타입에 해당하는 AI 서비스를 반환 + * @param modelType AI 모델 타입 + * @return 해당 모델 타입의 AIModelService 구현체 + * @throws IllegalArgumentException 지원하지 않는 모델 타입인 경우 + */ + public AIModelService getService(AIModelType modelType) { + AIModelService service = services.get(modelType); + if (service == null) { + throw new IllegalArgumentException("지원하지 않는 AI 모델 타입입니다: " + modelType); + } + return service; + } +} \ No newline at end of file diff --git a/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelType.java b/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelType.java new file mode 100644 index 0000000..80c37a0 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelType.java @@ -0,0 +1,11 @@ +package com.synapse.chat_service.service.ai; + +/** + * 지원하는 AI 모델 타입을 정의하는 Enum + * 새로운 AI 모델 추가 시 이 Enum에 추가하여 일관성 있게 관리 + */ +public enum AIModelType { + GPT, + CLAUDE, + GEMINI +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/service/infra/ClaudeService.java b/chat_service/src/main/java/com/synapse/chat_service/service/infra/ClaudeService.java new file mode 100644 index 0000000..578b7a4 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/service/infra/ClaudeService.java @@ -0,0 +1,40 @@ +package com.synapse.chat_service.service.infra; + +import java.util.concurrent.CompletableFuture; + +import org.springframework.ai.anthropic.AnthropicChatModel; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import com.synapse.chat_service.service.ai.AIModelService; +import com.synapse.chat_service.service.ai.AIModelType; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ClaudeService implements AIModelService { + + private final AnthropicChatModel anthropicChatModel; + + @Override + public AIModelType getModelType() { + return AIModelType.CLAUDE; + } + + @Override + @Async("aiTaskExecutor") + public CompletableFuture generateResponse(String prompt) { + log.debug("Claude 모델로 응답 생성 시작 - prompt length: {}", prompt.length()); + try { + String response = anthropicChatModel.call(prompt); + log.debug("Claude 응답 생성 완료 - response length: {}", response != null ? response.length() : "null"); + return CompletableFuture.completedFuture(response); + } catch (Exception e) { + log.error("Claude 응답 생성 실패", e); + return CompletableFuture.failedFuture(new RuntimeException("Claude 모델 응답 생성 중 오류가 발생했습니다.", e)); + } + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/service/infra/GPTService.java b/chat_service/src/main/java/com/synapse/chat_service/service/infra/GPTService.java new file mode 100644 index 0000000..365942e --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/service/infra/GPTService.java @@ -0,0 +1,43 @@ +package com.synapse.chat_service.service.infra; + +import java.util.concurrent.CompletableFuture; + +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import com.synapse.chat_service.service.ai.AIModelService; +import com.synapse.chat_service.service.ai.AIModelType; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * OpenAI GPT 모델을 사용하는 AI 서비스 구현체 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GPTService implements AIModelService { + + private final OpenAiChatModel openAiChatModel; + + @Override + public AIModelType getModelType() { + return AIModelType.GPT; + } + + @Override + @Async("aiTaskExecutor") + public CompletableFuture generateResponse(String prompt) { + log.debug("GPT 모델로 응답 생성 시작 - prompt length: {}", prompt.length()); + try { + String response = openAiChatModel.call(prompt); + log.debug("GPT 응답 생성 완료 - response length: {}", response != null ? response.length() : "null"); + return CompletableFuture.completedFuture(response); + } catch (Exception e) { + log.error("GPT 응답 생성 실패", e); + return CompletableFuture.failedFuture(new RuntimeException("GPT 모델 응답 생성 중 오류가 발생했습니다.", e)); + } + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/service/infra/GeminiService.java b/chat_service/src/main/java/com/synapse/chat_service/service/infra/GeminiService.java new file mode 100644 index 0000000..a2c8ff2 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/service/infra/GeminiService.java @@ -0,0 +1,43 @@ +package com.synapse.chat_service.service.infra; + +import java.util.concurrent.CompletableFuture; + +import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import com.synapse.chat_service.service.ai.AIModelService; +import com.synapse.chat_service.service.ai.AIModelType; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Google Gemini 모델을 사용하는 AI 서비스 구현체 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GeminiService implements AIModelService { + + private final VertexAiGeminiChatModel vertexAiGeminiChatModel; + + @Override + public AIModelType getModelType() { + return AIModelType.GEMINI; + } + + @Override + @Async("aiTaskExecutor") + public CompletableFuture generateResponse(String prompt) { + log.debug("Gemini 모델로 응답 생성 시작 - prompt length: {}", prompt.length()); + try { + String response = vertexAiGeminiChatModel.call(prompt); + log.debug("Gemini 응답 생성 완료 - response length: {}", response != null ? response.length() : "null"); + return CompletableFuture.completedFuture(response); + } catch (Exception e) { + log.error("Gemini 응답 생성 실패", e); + return CompletableFuture.failedFuture(new RuntimeException("Gemini 모델 응답 생성 중 오류가 발생했습니다.", e)); + } + } +} diff --git a/chat_service/src/main/resources/application-local.yml b/chat_service/src/main/resources/application-local.yml index c4694e7..ffb5c85 100644 --- a/chat_service/src/main/resources/application-local.yml +++ b/chat_service/src/main/resources/application-local.yml @@ -28,6 +28,33 @@ spring: max-active: ${local-db.redis.max-active} max-idle: ${local-db.redis.max-idle} min-idle: ${local-db.redis.min-idle} + + ai: + openai: + api-key: ${local-ai.openai.api-key} + chat: + options: + model: ${local-ai.openai.chat.options.model} + temperature: ${local-ai.openai.chat.options.temperature} + + + anthropic: + claude: + api-key: ${local-ai.anthropic.claude.api-key} + chat: + options: + model: ${local-ai.anthropic.claude.chat.options.model} + temperature: ${local-ai.anthropic.claude.chat.options.temperature} + + vertex: + ai: + gemini: + project-id: ${local-ai.vertex.ai.gemini.project-id} + location: ${local-ai.vertex.ai.gemini.location} + chat: + options: + model: ${local-ai.vertex.ai.gemini.chat.options.model} + temperature: ${local-ai.vertex.ai.gemini.chat.options.temperature} session: expiration-hours: 24 diff --git a/chat_service/src/main/resources/application.yml b/chat_service/src/main/resources/application.yml index 8138393..442d272 100644 --- a/chat_service/src/main/resources/application.yml +++ b/chat_service/src/main/resources/application.yml @@ -14,3 +14,6 @@ spring: config: import: - security/application-db.yml + - security/application-api.yml + - security/application-jwt.yml + - security/application-oauth2.yml diff --git a/chat_service/src/main/resources/static/chat-test.html b/chat_service/src/main/resources/static/chat-test.html new file mode 100644 index 0000000..0e86309 --- /dev/null +++ b/chat_service/src/main/resources/static/chat-test.html @@ -0,0 +1,274 @@ + + + + + + Advanced STOMP WebSocket Tester + + + + +
+

Advanced STOMP WebSocket Tester (Native WebSocket)

+ +
연결 끊김
+ +
+
+ + +
+ + +
+ + +
+
+ + +
+ + +
+ + +
+ +
+ + +
+
+ +
+ 메시지 전송 +
+ + +
+
+ + +
+
+ + + +
+
+ +

STOMP Command Logs

+
+
+ + + + diff --git a/chat_service/src/test/java/com/synapse/chat_service/ChatServiceApplicationTests.java b/chat_service/src/test/java/com/synapse/chat_service/ChatServiceApplicationTests.java deleted file mode 100644 index 458c866..0000000 --- a/chat_service/src/test/java/com/synapse/chat_service/ChatServiceApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.synapse.chat_service; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class ChatServiceApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/chat_service/src/test/java/com/synapse/chat_service/controller/AiChatControllerTest.java b/chat_service/src/test/java/com/synapse/chat_service/controller/AiChatControllerTest.java new file mode 100644 index 0000000..c8bbeba --- /dev/null +++ b/chat_service/src/test/java/com/synapse/chat_service/controller/AiChatControllerTest.java @@ -0,0 +1,65 @@ +package com.synapse.chat_service.controller; + +import com.synapse.chat_service.dto.response.MessageResponse; +import com.synapse.chat_service.service.MessageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AiChatController 테스트") +class AiChatControllerTest { + + @Mock + private MessageService messageService; + + @InjectMocks + private AiChatController aiChatController; + + private UUID userId; + private List mockConversationList; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + MessageResponse.ConversationInfo conversationInfo = new MessageResponse.ConversationInfo( + UUID.randomUUID(), + userId, + java.time.LocalDateTime.now(), + java.time.LocalDateTime.now() + ); + mockConversationList = List.of(conversationInfo); + } + + @Test + @DisplayName("시나리오 1: 대화 목록 조회 - getMyConversationList() 호출 시 messageService.getConversationListByUserId()가 올바른 userId로 호출되는지 검증") + void getMyConversationList_ShouldCallMessageServiceWithCorrectUserId() { + // Given + when(messageService.getConversationListByUserId(userId)).thenReturn(mockConversationList); + + // When + ResponseEntity> response = aiChatController.getMyConversationList(userId); + + // Then + // messageService.getConversationListByUserId()가 컨트롤러에 전달된 userId로 호출되는지 검증 + verify(messageService).getConversationListByUserId(userId); + + // 응답 검증 + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody()).isEqualTo(mockConversationList); + assertThat(response.getBody()).hasSize(1); + assertThat(response.getBody().get(0).userId()).isEqualTo(userId); + } +} 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 new file mode 100644 index 0000000..a5c9a96 --- /dev/null +++ b/chat_service/src/test/java/com/synapse/chat_service/controller/MessageControllerTest.java @@ -0,0 +1,63 @@ +package com.synapse.chat_service.controller; + +import com.synapse.chat_service.dto.request.MessageRequest; +import com.synapse.chat_service.service.MessageService; +import com.synapse.chat_service.service.ai.AIModelType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; + +import java.util.UUID; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("MessageController 테스트") +class MessageControllerTest { + + @Mock + private MessageService messageService; + + @Mock + private Authentication authentication; + + @InjectMocks + private MessageController messageController; + + private UUID userId; + private MessageRequest.Chat chatMessage; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + chatMessage = new MessageRequest.Chat( + AIModelType.GPT, + "안녕하세요", + "test-session-id", + "test-message-id" + ); + } + + @Test + @DisplayName("시나리오 1: 채팅 메시지 핸들링 - @MessageMapping(\"/chat\")으로 메시지 수신 시 messageService.processAndRespondToMessage()가 올바른 파라미터로 호출되는지 검증") + void handleChatMessage_ShouldCallMessageServiceWithCorrectParameters() { + // Given + when(authentication.getName()).thenReturn(userId.toString()); + + // When + messageController.handleChatMessage(chatMessage, authentication); + + // Then + // messageService.processAndRespondToMessage()가 메시지 페이로드와 인증 정보(userId)로 호출되는지 검증 + verify(messageService).processAndRespondToMessage(userId, chatMessage); + + // Authentication에서 userId가 올바르게 추출되는지 검증 + verify(authentication).getName(); + } +} 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 deleted file mode 100644 index d51546a..0000000 --- a/chat_service/src/test/java/com/synapse/chat_service/domain/entity/MessageTest.java +++ /dev/null @@ -1,321 +0,0 @@ -package com.synapse.chat_service.domain.entity; - -import com.synapse.chat_service.domain.entity.enums.SenderType; -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("Message 도메인 엔티티 테스트") -class MessageTest { - - private Conversation conversation; - private Message message; - private final String initialContent = "초기 메시지 내용"; - - @BeforeEach - void setUp() { - conversation = TestObjectFactory.createConversation(1L); - - message = TestObjectFactory.createUserMessage(conversation, initialContent); - } - - @Nested - @DisplayName("updateContent 메소드 테스트") - class UpdateContentTest { - - @Test - @DisplayName("성공: 유효한 새 내용으로 업데이트") - void updateContent_Success() { - // given - String newContent = "새로운 메시지 내용입니다."; - - // when - message.updateContent(newContent); - - // then - assertThat(message.getContent()).isEqualTo(newContent); - } - - @Test - @DisplayName("성공: 최대 길이(1000자) 내용으로 업데이트") - void updateContent_Success_MaxLength() { - // given - String maxLengthContent = "a".repeat(1000); - - // when - message.updateContent(maxLengthContent); - - // then - assertThat(message.getContent()).isEqualTo(maxLengthContent); - assertThat(message.getContent().length()).isEqualTo(1000); - } - - @Test - @DisplayName("성공: 한글 내용으로 업데이트") - void updateContent_Success_Korean() { - // given - String koreanContent = "안녕하세요! 한글 메시지 내용입니다."; - - // when - message.updateContent(koreanContent); - - // then - assertThat(message.getContent()).isEqualTo(koreanContent); - } - - @Test - @DisplayName("성공: 특수문자가 포함된 내용으로 업데이트") - void updateContent_Success_SpecialCharacters() { - // given - String contentWithSpecialChars = "메시지 내용! @#$%^&*()_+-=[]{}|;':\",./<>?"; - - // when - message.updateContent(contentWithSpecialChars); - - // then - assertThat(message.getContent()).isEqualTo(contentWithSpecialChars); - } - - @Test - @DisplayName("성공: 줄바꿈이 포함된 내용으로 업데이트") - void updateContent_Success_WithNewlines() { - // given - String contentWithNewlines = "첫 번째 줄\n두 번째 줄\n세 번째 줄"; - - // when - message.updateContent(contentWithNewlines); - - // then - assertThat(message.getContent()).isEqualTo(contentWithNewlines); - } - - @Test - @DisplayName("실패: null 내용으로 업데이트 시 ValidException 발생") - void updateContent_Fail_NullContent() { - // given - String nullContent = null; - - // when & then - ValidException exception = assertThrows(ValidException.class, () -> { - message.updateContent(nullContent); - }); - - assertThat(exception.getMessage()).contains("메시지 내용은 비어있을 수 없습니다"); - assertThat(message.getContent()).isEqualTo(initialContent); // 기존 내용 유지 - } - - @Test - @DisplayName("실패: 빈 문자열 내용으로 업데이트 시 ValidException 발생") - void updateContent_Fail_EmptyContent() { - // given - String emptyContent = ""; - - // when & then - ValidException exception = assertThrows(ValidException.class, () -> { - message.updateContent(emptyContent); - }); - - assertThat(exception.getMessage()).contains("메시지 내용은 비어있을 수 없습니다"); - assertThat(message.getContent()).isEqualTo(initialContent); // 기존 내용 유지 - } - - @Test - @DisplayName("실패: 공백만 있는 내용으로 업데이트 시 ValidException 발생") - void updateContent_Fail_WhitespaceOnlyContent() { - // given - String whitespaceOnlyContent = " "; - - // when & then - ValidException exception = assertThrows(ValidException.class, () -> { - message.updateContent(whitespaceOnlyContent); - }); - - assertThat(exception.getMessage()).contains("메시지 내용은 비어있을 수 없습니다"); - assertThat(message.getContent()).isEqualTo(initialContent); // 기존 내용 유지 - } - - @Test - @DisplayName("실패: 1000자를 초과하는 내용으로 업데이트 시 ValidException 발생") - void updateContent_Fail_ExceedsMaxLength() { - // given - String tooLongContent = "a".repeat(1001); // 1001자 - - // when & then - ValidException exception = assertThrows(ValidException.class, () -> { - message.updateContent(tooLongContent); - }); - - assertThat(exception.getMessage()).contains("메시지 내용은 1000자를 초과할 수 없습니다"); - assertThat(message.getContent()).isEqualTo(initialContent); // 기존 내용 유지 - } - - @Test - @DisplayName("경계값 테스트: 정확히 1000자인 내용으로 업데이트") - void updateContent_BoundaryTest_ExactlyMaxLength() { - // given - String exactMaxLengthContent = "a".repeat(1000); - - // when - message.updateContent(exactMaxLengthContent); - - // then - assertThat(message.getContent()).isEqualTo(exactMaxLengthContent); - assertThat(message.getContent().length()).isEqualTo(1000); - } - } - - @Nested - @DisplayName("Message 생성자(Builder) 테스트") - class ConstructorTest { - - @Test - @DisplayName("성공: 유효한 파라미터로 Message 생성") - void constructor_Success() { - // given - String testContent = "테스트 메시지 내용"; - - // when - Message newMessage = TestObjectFactory.createAssistantMessage(conversation, testContent); - - // then - assertThat(newMessage.getConversation()).isEqualTo(conversation); - assertThat(newMessage.getSenderType()).isEqualTo(SenderType.ASSISTANT); - assertThat(newMessage.getContent()).isEqualTo(testContent); - } - - @Test - @DisplayName("성공: USER 타입으로 Message 생성") - void constructor_Success_UserType() { - // given - String testContent = "사용자 메시지"; - - // when - Message userMessage = TestObjectFactory.createUserMessage(conversation, testContent); - - // then - assertThat(userMessage.getSenderType()).isEqualTo(SenderType.USER); - assertThat(userMessage.getContent()).isEqualTo(testContent); - } - - @Test - @DisplayName("성공: AI 타입으로 Message 생성") - void constructor_Success_AIType() { - // given - String testContent = "AI 응답 메시지"; - - // when - Message aiMessage = TestObjectFactory.createAssistantMessage(conversation, testContent); - - // then - assertThat(aiMessage.getSenderType()).isEqualTo(SenderType.ASSISTANT); - assertThat(aiMessage.getContent()).isEqualTo(testContent); - } - - @Test - @DisplayName("실패: null 내용으로 Message 생성 시 ValidException 발생") - void constructor_Fail_NullContent() { - // given - String nullContent = null; - - // when & then - ValidException exception = assertThrows(ValidException.class, () -> { - Message.builder() - .conversation(conversation) - .senderType(SenderType.USER) - .content(nullContent) - .build(); - }); - - assertThat(exception.getMessage()).contains("메시지 내용은 비어있을 수 없습니다"); - } - - @Test - @DisplayName("실패: 빈 문자열 내용으로 Message 생성 시 ValidException 발생") - void constructor_Fail_EmptyContent() { - // given - String emptyContent = ""; - - // when & then - ValidException exception = assertThrows(ValidException.class, () -> { - Message.builder() - .conversation(conversation) - .senderType(SenderType.USER) - .content(emptyContent) - .build(); - }); - - assertThat(exception.getMessage()).contains("메시지 내용은 비어있을 수 없습니다"); - } - - @Test - @DisplayName("실패: 공백만 있는 내용으로 Message 생성 시 ValidException 발생") - void constructor_Fail_WhitespaceOnlyContent() { - // given - String whitespaceOnlyContent = " "; - - // when & then - ValidException exception = assertThrows(ValidException.class, () -> { - Message.builder() - .conversation(conversation) - .senderType(SenderType.USER) - .content(whitespaceOnlyContent) - .build(); - }); - - assertThat(exception.getMessage()).contains("메시지 내용은 비어있을 수 없습니다"); - } - - @Test - @DisplayName("실패: 1000자를 초과하는 내용으로 Message 생성 시 ValidException 발생") - void constructor_Fail_ExceedsMaxLength() { - // given - String tooLongContent = "a".repeat(1001); // 1001자 - - // when & then - ValidException exception = assertThrows(ValidException.class, () -> { - Message.builder() - .conversation(conversation) - .senderType(SenderType.USER) - .content(tooLongContent) - .build(); - }); - - assertThat(exception.getMessage()).contains("메시지 내용은 1000자를 초과할 수 없습니다"); - } - - @Test - @DisplayName("성공: 최대 길이(1000자) 내용으로 Message 생성") - void constructor_Success_MaxLength() { - // given - String maxLengthContent = "a".repeat(1000); - - // when - Message newMessage = TestObjectFactory.createUserMessage(conversation, maxLengthContent); - - // then - assertThat(newMessage.getContent()).isEqualTo(maxLengthContent); - assertThat(newMessage.getContent().length()).isEqualTo(1000); - } - - @Test - @DisplayName("경계값 테스트: 정확히 1000자인 내용으로 Message 생성") - void constructor_BoundaryTest_ExactlyMaxLength() { - // given - String exactMaxLengthContent = "b".repeat(1000); - - // when - Message newMessage = TestObjectFactory.createAssistantMessage(conversation, exactMaxLengthContent); - - // then - assertThat(newMessage.getContent()).isEqualTo(exactMaxLengthContent); - assertThat(newMessage.getContent().length()).isEqualTo(1000); - assertThat(newMessage.getSenderType()).isEqualTo(SenderType.ASSISTANT); - } - } -} diff --git a/chat_service/src/test/java/com/synapse/chat_service/repository/ChatUsageRepositoryTest.java b/chat_service/src/test/java/com/synapse/chat_service/repository/ChatUsageRepositoryTest.java deleted file mode 100644 index 3d72d3b..0000000 --- a/chat_service/src/test/java/com/synapse/chat_service/repository/ChatUsageRepositoryTest.java +++ /dev/null @@ -1,267 +0,0 @@ -package com.synapse.chat_service.repository; - -import com.synapse.chat_service.domain.entity.ChatUsage; -import com.synapse.chat_service.domain.entity.enums.SubscriptionType; -import com.synapse.chat_service.domain.repository.ChatUsageRepository; -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("ChatUsageRepository 단위 테스트") -class ChatUsageRepositoryTest { - - @Autowired - private TestEntityManager entityManager; - - @Autowired - private ChatUsageRepository chatUsageRepository; - - private ChatUsage chatUsage1; - private ChatUsage chatUsage2; - - @BeforeEach - void setUp() { - // 테스트용 ChatUsage 데이터 생성 - chatUsage1 = TestObjectFactory.createChatUsage(1L, SubscriptionType.FREE, 100); - chatUsage2 = TestObjectFactory.createChatUsage(2L, SubscriptionType.PRO, 1000); - } - - @Nested - @DisplayName("save 테스트") - class SaveTest { - - @Test - @DisplayName("성공: ChatUsage 저장") - void save_Success() { - // when - ChatUsage savedChatUsage = chatUsageRepository.save(chatUsage1); - - // then - assertThat(savedChatUsage).isNotNull(); - assertThat(savedChatUsage.getId()).isNotNull(); - assertThat(savedChatUsage.getUserId()).isEqualTo(1L); - assertThat(savedChatUsage.getSubscriptionType()).isEqualTo(SubscriptionType.FREE); - assertThat(savedChatUsage.getMessageLimit()).isEqualTo(100); - assertThat(savedChatUsage.getMessageCount()).isEqualTo(0); - } - - @Test - @DisplayName("성공: PRO 구독 타입으로 ChatUsage 저장") - void save_Success_ProSubscription() { - // when - ChatUsage savedChatUsage = chatUsageRepository.save(chatUsage2); - - // then - assertThat(savedChatUsage).isNotNull(); - assertThat(savedChatUsage.getId()).isNotNull(); - assertThat(savedChatUsage.getUserId()).isEqualTo(2L); - assertThat(savedChatUsage.getSubscriptionType()).isEqualTo(SubscriptionType.PRO); - assertThat(savedChatUsage.getMessageLimit()).isEqualTo(1000); - assertThat(savedChatUsage.getMessageCount()).isEqualTo(0); - } - } - - @Nested - @DisplayName("findById 테스트") - class FindByIdTest { - - @Test - @DisplayName("성공: ID로 ChatUsage 조회") - void findById_Success() { - // given - ChatUsage savedChatUsage = entityManager.persistAndFlush(chatUsage1); - - // when - Optional foundChatUsage = chatUsageRepository.findById(savedChatUsage.getId()); - - // then - assertThat(foundChatUsage).isPresent(); - assertThat(foundChatUsage.get().getUserId()).isEqualTo(1L); - assertThat(foundChatUsage.get().getSubscriptionType()).isEqualTo(SubscriptionType.FREE); - assertThat(foundChatUsage.get().getMessageLimit()).isEqualTo(100); - } - - @Test - @DisplayName("실패: 존재하지 않는 ID로 조회") - void findById_NotFound() { - // when - Optional foundChatUsage = chatUsageRepository.findById(999L); - - // then - assertThat(foundChatUsage).isEmpty(); - } - } - - @Nested - @DisplayName("findAll 테스트") - class FindAllTest { - - @Test - @DisplayName("성공: 모든 ChatUsage 조회") - void findAll_Success() { - // given - entityManager.persistAndFlush(chatUsage1); - entityManager.persistAndFlush(chatUsage2); - - // when - var allChatUsages = chatUsageRepository.findAll(); - - // then - assertThat(allChatUsages).hasSize(2); - assertThat(allChatUsages) - .extracting(ChatUsage::getUserId) - .containsExactlyInAnyOrder(1L, 2L); - } - - @Test - @DisplayName("성공: 빈 결과 반환") - void findAll_EmptyResult() { - // when - var allChatUsages = chatUsageRepository.findAll(); - - // then - assertThat(allChatUsages).isEmpty(); - } - } - - @Nested - @DisplayName("delete 테스트") - class DeleteTest { - - @Test - @DisplayName("성공: ChatUsage 삭제") - void delete_Success() { - // given - ChatUsage savedChatUsage = entityManager.persistAndFlush(chatUsage1); - Long chatUsageId = savedChatUsage.getId(); - - // when - chatUsageRepository.delete(savedChatUsage); - entityManager.flush(); - - // then - Optional deletedChatUsage = chatUsageRepository.findById(chatUsageId); - assertThat(deletedChatUsage).isEmpty(); - } - - @Test - @DisplayName("성공: deleteById로 ChatUsage 삭제") - void deleteById_Success() { - // given - ChatUsage savedChatUsage = entityManager.persistAndFlush(chatUsage1); - Long chatUsageId = savedChatUsage.getId(); - - // when - chatUsageRepository.deleteById(chatUsageId); - entityManager.flush(); - - // then - Optional deletedChatUsage = chatUsageRepository.findById(chatUsageId); - assertThat(deletedChatUsage).isEmpty(); - } - } - - @Nested - @DisplayName("existsById 테스트") - class ExistsByIdTest { - - @Test - @DisplayName("성공: 존재하는 ChatUsage 확인") - void existsById_Success() { - // given - ChatUsage savedChatUsage = entityManager.persistAndFlush(chatUsage1); - - // when - boolean exists = chatUsageRepository.existsById(savedChatUsage.getId()); - - // then - assertThat(exists).isTrue(); - } - - @Test - @DisplayName("실패: 존재하지 않는 ChatUsage 확인") - void existsById_NotFound() { - // when - boolean exists = chatUsageRepository.existsById(999L); - - // then - assertThat(exists).isFalse(); - } - } - - @Nested - @DisplayName("count 테스트") - class CountTest { - - @Test - @DisplayName("성공: ChatUsage 개수 조회") - void count_Success() { - // given - entityManager.persistAndFlush(chatUsage1); - entityManager.persistAndFlush(chatUsage2); - - // when - long count = chatUsageRepository.count(); - - // then - assertThat(count).isEqualTo(2); - } - - @Test - @DisplayName("성공: 빈 테이블의 개수 조회") - void count_EmptyTable() { - // when - long count = chatUsageRepository.count(); - - // then - assertThat(count).isEqualTo(0); - } - } - - @Nested - @DisplayName("JPA 매핑 검증 테스트") - class JpaMappingTest { - - @Test - @DisplayName("성공: userId unique 제약 조건 검증") - void uniqueUserId_Validation() { - // given - entityManager.persistAndFlush(chatUsage1); - - ChatUsage duplicateUserIdChatUsage = TestObjectFactory.createChatUsage(1L, SubscriptionType.PRO, 500); - - // when & then - try { - entityManager.persistAndFlush(duplicateUserIdChatUsage); - entityManager.flush(); - // 예외가 발생하지 않으면 테스트 실패 - assertThat(false).as("Unique constraint violation should occur").isTrue(); - } catch (Exception e) { - // unique 제약 조건 위반으로 예외 발생 예상 - assertThat(e).isNotNull(); - } - } - - @Test - @DisplayName("성공: 기본값 검증") - void defaultValues_Validation() { - // when - ChatUsage savedChatUsage = entityManager.persistAndFlush(chatUsage1); - - // then - assertThat(savedChatUsage.getMessageCount()).isEqualTo(0); - } - } -} \ No newline at end of file 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 deleted file mode 100644 index 7bfc489..0000000 --- a/chat_service/src/test/java/com/synapse/chat_service/repository/MessageRepositoryTest.java +++ /dev/null @@ -1,368 +0,0 @@ -package com.synapse.chat_service.repository; - -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; -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.UUID; - -import static org.assertj.core.api.Assertions.assertThat; - -@DataJpaTest -@ActiveProfiles("test") -@DisplayName("MessageRepository 단위 테스트") -class MessageRepositoryTest { - - @Autowired - private TestEntityManager entityManager; - - @Autowired - private MessageRepository messageRepository; - - private Conversation conversation1; - private Conversation conversation2; - private Message message1; - private Message message2; - private Message message3; - private Message message4; - private Message message5; - - @BeforeEach - void setUp() { - // 테스트용 Conversation 데이터 생성 - conversation1 = TestObjectFactory.createConversation(1L); - conversation2 = TestObjectFactory.createConversation(2L); - - entityManager.persistAndFlush(conversation1); - entityManager.persistAndFlush(conversation2); - - // 고정된 기준 시간 사용 (CI 환경에서의 안정성을 위해) - LocalDateTime baseTime = LocalDateTime.of(2024, 1, 1, 12, 0, 0); - - // 테스트용 Message 데이터 생성 - 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); - entityManager.persistAndFlush(message2); - entityManager.persistAndFlush(message3); - entityManager.persistAndFlush(message4); - entityManager.persistAndFlush(message5); - } - - @Nested - @DisplayName("findByConversationIdAndContentContaining 테스트") - class FindByConversationIdAndContentContainingTest { - - @Test - @DisplayName("성공: 특정 대화에서 키워드가 포함된 메시지 조회") - void findByConversationIdAndContentContaining_Success() { - // given - String keyword = "자바"; - - // when - List result = messageRepository.findByConversationIdAndContentContaining(conversation1.getId(), keyword); - - // then - assertThat(result).hasSize(2); - assertThat(result).extracting(Message::getContent) - .containsExactlyInAnyOrder( - "안녕하세요! 자바 공부를 시작해봅시다.", - "자바의 기본 문법에 대해 알아보겠습니다." - ); - assertThat(result).allMatch(message -> message.getConversation().getId().equals(conversation1.getId())); - } - - @Test - @DisplayName("성공: 키워드가 정확히 일치하는 경우") - void findByConversationIdAndContentContaining_ExactMatch() { - // given - String keyword = "객체지향"; - - // when - List result = messageRepository.findByConversationIdAndContentContaining(conversation1.getId(), keyword); - - // then - assertThat(result).hasSize(1); - assertThat(result.get(0).getContent()).isEqualTo("객체지향 프로그래밍의 핵심 개념을 설명해주세요."); - assertThat(result.get(0).getConversation().getId()).isEqualTo(conversation1.getId()); - } - - @Test - @DisplayName("성공: 검색 결과가 없는 경우 빈 리스트 반환") - void findByConversationIdAndContentContaining_EmptyResult() { - // given - String keyword = "존재하지않는키워드"; - - // when - List result = messageRepository.findByConversationIdAndContentContaining(conversation1.getId(), keyword); - - // then - assertThat(result).isEmpty(); - } - - @Test - @DisplayName("성공: 다른 대화의 메시지는 검색되지 않음") - void findByConversationIdAndContentContaining_DifferentConversation() { - // given - String keyword = "스프링"; - - // when - List result = messageRepository.findByConversationIdAndContentContaining(conversation1.getId(), keyword); - - // then - assertThat(result).isEmpty(); // conversation1에는 스프링 관련 메시지가 없음 - } - - @Test - @DisplayName("성공: 부분 문자열 검색") - void findByConversationIdAndContentContaining_PartialMatch() { - // given - String keyword = "프로그래밍"; - - // when - List result = messageRepository.findByConversationIdAndContentContaining(conversation1.getId(), keyword); - - // then - assertThat(result).hasSize(1); - assertThat(result.get(0).getContent()).contains("프로그래밍"); - } - - @Test - @DisplayName("성공: 여러 대화에서 같은 키워드 검색") - void findByConversationIdAndContentContaining_MultipleKeywords() { - // given - String keyword = "프로젝트"; - - // when - List conversation1Result = messageRepository.findByConversationIdAndContentContaining(conversation1.getId(), keyword); - List conversation2Result = messageRepository.findByConversationIdAndContentContaining(conversation2.getId(), keyword); - - // then - assertThat(conversation1Result).isEmpty(); // conversation1에는 "프로젝트" 키워드가 없음 - assertThat(conversation2Result).hasSize(2); // conversation2에는 "프로젝트" 키워드가 2개 메시지에 있음 - assertThat(conversation2Result).extracting(Message::getContent) - .allMatch(content -> content.contains("프로젝트")); - } - } - - @Nested - @DisplayName("countByConversationId 테스트") - class CountByConversationIdTest { - - @Test - @DisplayName("성공: 특정 대화의 메시지 개수 조회") - void countByConversationId_Success() { - // when - long conversation1Count = messageRepository.countByConversationId(conversation1.getId()); - long conversation2Count = messageRepository.countByConversationId(conversation2.getId()); - - // then - assertThat(conversation1Count).isEqualTo(3); // conversation1에 3개의 메시지 - assertThat(conversation2Count).isEqualTo(2); // conversation2에 2개의 메시지 - } - - @Test - @DisplayName("성공: 메시지가 없는 대화의 경우 0 반환") - void countByConversationId_EmptyResult() { - // given - Conversation emptyConversation = Conversation.builder() - .userId(3L) - .build(); - entityManager.persistAndFlush(emptyConversation); - - // when - long count = messageRepository.countByConversationId(emptyConversation.getId()); - - // then - assertThat(count).isEqualTo(0); - } - - @Test - @DisplayName("성공: 존재하지 않는 대화 ID의 경우 0 반환") - void countByConversationId_NonExistentConversation() { - // given - UUID nonExistentConversationId = UUID.randomUUID(); - - // when - long count = messageRepository.countByConversationId(nonExistentConversationId); - - // then - assertThat(count).isEqualTo(0); - } - - @Test - @DisplayName("성공: 메시지 추가 후 개수 증가 확인") - void countByConversationId_AfterAddingMessage() { - // given - long initialCount = messageRepository.countByConversationId(conversation1.getId()); - - Message newMessage = Message.builder() - .content("새로운 메시지입니다.") - .senderType(SenderType.USER) - .conversation(conversation1) - .build(); - entityManager.persistAndFlush(newMessage); - - // when - long updatedCount = messageRepository.countByConversationId(conversation1.getId()); - - // then - assertThat(updatedCount).isEqualTo(initialCount + 1); - assertThat(updatedCount).isEqualTo(4); // 기존 3개 + 새로 추가된 1개 - } - - @Test - @DisplayName("성공: 다른 대화의 메시지는 카운트에 포함되지 않음") - void countByConversationId_IsolatedCount() { - // given - long conversation1InitialCount = messageRepository.countByConversationId(conversation1.getId()); - long conversation2InitialCount = messageRepository.countByConversationId(conversation2.getId()); - - // conversation2에 새 메시지 추가 - Message newMessage = Message.builder() - .content("conversation2에 추가된 메시지") - .senderType(SenderType.ASSISTANT) - .conversation(conversation2) - .build(); - entityManager.persistAndFlush(newMessage); - - // when - long conversation1FinalCount = messageRepository.countByConversationId(conversation1.getId()); - long conversation2FinalCount = messageRepository.countByConversationId(conversation2.getId()); - - // then - assertThat(conversation1FinalCount).isEqualTo(conversation1InitialCount); // conversation1 개수는 변화 없음 - assertThat(conversation2FinalCount).isEqualTo(conversation2InitialCount + 1); // conversation2 개수만 증가 - } - } - - @Nested - @DisplayName("findByConversationIdOrderByCreatedDateAsc 페이징 테스트") - class FindByConversationIdOrderByCreatedDateAscTest { - - @Test - @DisplayName("성공: 시간순(ASC) 정렬이 올바르게 동작") - void findByConversationIdOrderByCreatedDateAsc_Success() { - // given - Pageable pageable = PageRequest.of(0, 10); - - // when - Page result = messageRepository.findByConversationIdOrderByCreatedDateAsc(conversation1.getId(), pageable); - - // then - 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("객체지향 프로그래밍의 핵심 개념을 설명해주세요."); // 가장 최근 - } - - @Test - @DisplayName("성공: 페이징이 올바르게 동작") - void findByConversationIdOrderByCreatedDateAsc_Paging() { - // given - Pageable pageable = PageRequest.of(0, 2); // 페이지 크기 2 - - // when - Page result = messageRepository.findByConversationIdOrderByCreatedDateAsc(conversation1.getId(), pageable); - - // then - 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("성공: 시간순(DESC) 정렬이 올바르게 동작") - void findByConversationIdOrderByCreatedDateDesc_Success() { - // given - Pageable pageable = PageRequest.of(0, 10); - - // when - Page result = messageRepository.findByConversationIdOrderByCreatedDateDesc(conversation1.getId(), pageable); - - // then - 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("안녕하세요! 자바 공부를 시작해봅시다."); // 가장 오래된 - } - - @Test - @DisplayName("성공: 페이징이 올바르게 동작") - void findByConversationIdOrderByCreatedDateDesc_Paging() { - // given - Pageable pageable = PageRequest.of(0, 2); // 페이지 크기 2 - - // when - Page result = messageRepository.findByConversationIdOrderByCreatedDateDesc(conversation1.getId(), pageable); - - // then - 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("findByConversationIdOrderByCreatedDateAsc 리스트 테스트") - class FindByConversationIdOrderByCreatedDateAscListTest { - - @Test - @DisplayName("성공: 시간순(ASC) 정렬된 전체 메시지 조회") - void findByConversationIdOrderByCreatedDateAsc_List_Success() { - // when - List result = messageRepository.findByConversationIdOrderByCreatedDateAsc(conversation1.getId()); - - // then - 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 findByConversationIdOrderByCreatedDateAsc_EmptyResult() { - // given - Conversation emptyConversation = Conversation.builder() - .userId(3L) - .build(); - entityManager.persistAndFlush(emptyConversation); - - // when - List result = messageRepository.findByConversationIdOrderByCreatedDateAsc(emptyConversation.getId()); - - // then - assertThat(result).isEmpty(); - } - } -} 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 bb3bb13..d9e0554 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 @@ -3,283 +3,258 @@ 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.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.service.ai.AIModelService; +import com.synapse.chat_service.service.ai.AIModelServiceFactory; +import com.synapse.chat_service.service.ai.AIModelType; 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.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.PageRequest; -import org.springframework.data.domain.Pageable; - -import java.util.List; - -import static org.assertj.core.api.Assertions.*; - -@SpringBootTest -@ActiveProfiles("test") -@Transactional -@DisplayName("MessageService 통합 테스트") +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("MessageService 테스트") class MessageServiceTest { - @Autowired - private MessageService messageService; - - @Autowired - private ConversationRepository conversationRepository; - - @Autowired + @Mock private MessageRepository messageRepository; - - @MockitoBean - private RedisTemplate redisTemplate; - - @MockitoBean + + @Mock + private ConversationRepository conversationRepository; + + @Mock private RedisAiChatManager redisAiChatManager; - - @MockitoBean - private RedisSessionManager redisSessionManager; - - private Conversation testConversation; - private Message testMessage; - + + @Mock + private AIModelServiceFactory aiModelServiceFactory; + + @Mock + private SimpMessagingTemplate messagingTemplate; + + @Mock + private AIModelService aiModelService; + + @InjectMocks + private MessageService messageService; + + private UUID userId; + private String userMessage; + private String aiResponse; + private Conversation conversation; + private Message savedUserMessage; + private Message savedAiMessage; + private MessageRequest.Chat chatRequest; + @BeforeEach void setUp() { - // 테스트용 대화 생성 - testConversation = TestObjectFactory.createConversation(1L); - testConversation = conversationRepository.save(testConversation); - - // 테스트용 메시지 생성 - testMessage = TestObjectFactory.createUserMessage(testConversation, "테스트 메시지"); - testMessage = messageRepository.save(testMessage); + userId = UUID.randomUUID(); + userMessage = TestObjectFactory.TestConstants.DEFAULT_USER_MESSAGE; + aiResponse = TestObjectFactory.TestConstants.DEFAULT_ASSISTANT_MESSAGE; + + conversation = TestObjectFactory.createConversation(userId); + savedUserMessage = TestObjectFactory.createMessage(conversation, SenderType.USER, userMessage); + savedAiMessage = TestObjectFactory.createMessage(conversation, SenderType.ASSISTANT, aiResponse); + + chatRequest = new MessageRequest.Chat( + AIModelType.GPT, + userMessage, + "test-session-id", + "test-message-id" + ); } - - @Nested - @DisplayName("메시지 생성") - class CreateMessage { - - @Test - @DisplayName("성공: 유효한 메시지 생성") - void createMessage_Success() { - // given - MessageRequest.Create request = new MessageRequest.Create( - testConversation.getUserId(), - SenderType.USER, - "새로운 메시지" - ); - - // when - MessageResponse.Detail response = messageService.createMessage(request); - - // then - assertThat(response).isNotNull(); - assertThat(response.content()).isEqualTo("새로운 메시지"); - assertThat(response.senderType()).isEqualTo(SenderType.USER); - assertThat(response.conversationId()).isEqualTo(testConversation.getId()); - } - - @Test - @DisplayName("성공: 새로운 사용자로 대화 생성") - void createMessage_NewUser() { - // given - Long newUserId = 999L; - MessageRequest.Create request = new MessageRequest.Create( - newUserId, - SenderType.USER, - "새 사용자의 첫 메시지" - ); - - // when - MessageResponse.Detail response = messageService.createMessage(request); - - // then - assertThat(response).isNotNull(); - assertThat(response.content()).isEqualTo("새 사용자의 첫 메시지"); - assertThat(response.senderType()).isEqualTo(SenderType.USER); - } + + @Test + @DisplayName("시나리오 1: 신규 사용자 메시지 생성") + void createMessage_NewUser_ShouldCreateConversationAndMessage() { + // Given: 특정 userId로 처음 메시지를 보내는 상황 + when(conversationRepository.findByUserId(userId)).thenReturn(Optional.empty()); + when(conversationRepository.save(any(Conversation.class))).thenReturn(conversation); + when(messageRepository.save(any(Message.class))).thenReturn(savedUserMessage); + + // When: createMessage() 호출 + MessageResponse.History result = messageService.createMessage(userId, SenderType.USER, userMessage); + + // Then: 검증 + // conversationRepository.findByUserId()가 Optional.empty()를 반환하는지 검증 + verify(conversationRepository).findByUserId(userId); + + // 새로운 Conversation 객체가 생성되어 conversationRepository.save()로 저장되는지 검증 + ArgumentCaptor conversationCaptor = ArgumentCaptor.forClass(Conversation.class); + verify(conversationRepository).save(conversationCaptor.capture()); + Conversation capturedConversation = conversationCaptor.getValue(); + assertThat(capturedConversation.getUserId()).isEqualTo(userId); + + // messageRepository.save()가 올바른 Message 객체로 호출되는지 확인 + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class); + verify(messageRepository).save(messageCaptor.capture()); + Message capturedMessage = messageCaptor.getValue(); + assertThat(capturedMessage.getSenderType()).isEqualTo(SenderType.USER); + assertThat(capturedMessage.getContent()).isEqualTo(userMessage); + + // redisAiChatManager.createOrUpdateAiChatWithConversation()이 호출되는지 검증 + verify(redisAiChatManager).createOrUpdateAiChatWithConversation( + userId.toString(), + conversation.getId() + ); + + // 결과 검증 + assertThat(result).isNotNull(); + assertThat(result.senderType()).isEqualTo(SenderType.USER); + assertThat(result.content()).isEqualTo(userMessage); } - - @Nested - @DisplayName("메시지 조회") - class GetMessage { - - @Test - @DisplayName("성공: 메시지 조회") - void getMessage_Success() { - // when - MessageResponse.Detail result = messageService.getMessage(testMessage.getId()); - - // then - 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("테스트 메시지"); - } - - @Test - @DisplayName("실패: 메시지를 찾을 수 없음") - void getMessage_MessageNotFound() { - // given - Long nonExistentMessageId = 999L; - - // when & then - assertThatThrownBy(() -> messageService.getMessage(nonExistentMessageId)) - .isInstanceOf(NotFoundException.class); - } + + @Test + @DisplayName("시나리오 2: 기존 사용자 메시지 생성") + void createMessage_ExistingUser_ShouldNotCreateConversation() { + // Given: 기존에 대화가 있는 userId + when(conversationRepository.findByUserId(userId)).thenReturn(Optional.of(conversation)); + when(messageRepository.save(any(Message.class))).thenReturn(savedUserMessage); + + // When: createMessage() 호출 + MessageResponse.History result = messageService.createMessage(userId, SenderType.USER, userMessage); + + // Then: 검증 + // conversationRepository.findByUserId()가 Optional을 반환하는지 검증 + verify(conversationRepository).findByUserId(userId); + + // conversationRepository.save()가 호출되지 않는지 검증 + verify(conversationRepository, never()).save(any(Conversation.class)); + + // messageRepository.save()가 호출되는지 확인 + verify(messageRepository).save(any(Message.class)); + + // Redis 동기화가 호출되는지 검증 + verify(redisAiChatManager).syncConversationId(userId.toString(), conversation.getId()); + + // 결과 검증 + assertThat(result).isNotNull(); + assertThat(result.senderType()).isEqualTo(SenderType.USER); + assertThat(result.content()).isEqualTo(userMessage); } - - @Nested - @DisplayName("getMessagesByConversationId 테스트") - class GetMessagesByConversationIdTest { - - @Test - @DisplayName("성공: 사용자 ID로 메시지 목록 조회") - void getMessagesByUserId_Success() { - // when - List result = messageService.getMessagesByUserId(testConversation.getUserId()); - - // then - assertThat(result).hasSize(1); - 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("성공: 존재하지 않는 사용자 ID로 빈 목록 조회") - void getMessagesByUserId_EmptyResult() { - // given - Long nonExistentUserId = 999L; - - // when - List result = messageService.getMessagesByUserId(nonExistentUserId); - - // then - assertThat(result).isEmpty(); - } - } - - @Nested - @DisplayName("getMessagesByUserIdWithPaging 테스트") - class GetMessagesByUserIdWithPagingTest { - - @Test - @DisplayName("성공: 페이징된 메시지 조회 (오름차순)") - void getMessagesByUserIdWithPaging_Success_Ascending() { - // given - Pageable pageable = PageRequest.of(0, 10); - - // when - Page result = messageService.getMessagesByUserIdWithPaging(testConversation.getUserId(), pageable); - - // then - assertThat(result.getContent()).hasSize(1); - 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("성공: 존재하지 않는 사용자 ID로 빈 페이지 조회") - void getMessagesByUserIdWithPaging_EmptyResult() { - // given - Pageable pageable = PageRequest.of(0, 10); - Long nonExistentUserId = 999L; - - // when - Page result = messageService.getMessagesByUserIdWithPaging(nonExistentUserId, pageable); - - // then - assertThat(result.getContent()).isEmpty(); - assertThat(result.getTotalElements()).isEqualTo(0); + + @Test + @DisplayName("시나리오 3: AI 응답 처리 성공 (Happy Path)") + void processAndRespondToMessage_Success_ShouldProcessCorrectly() { + // Given: 유효한 MessageRequest.Chat 객체 + when(conversationRepository.findByUserId(userId)).thenReturn(Optional.of(conversation)); + when(messageRepository.save(any(Message.class))) + .thenReturn(savedUserMessage) + .thenReturn(savedAiMessage); + when(aiModelServiceFactory.getService(AIModelType.GPT)).thenReturn(aiModelService); + when(aiModelService.generateResponse(userMessage)) + .thenReturn(CompletableFuture.completedFuture(aiResponse)); + + // When: processAndRespondToMessage() 호출 + messageService.processAndRespondToMessage(userId, chatRequest); + + // 비동기 처리 완료를 위한 대기 + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } + + // Then: 검증 + // 사용자 메시지 저장을 위해 createMessage(userId, SenderType.USER, ...)가 호출되는지 검증 + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class); + verify(messageRepository, times(2)).save(messageCaptor.capture()); + + // 첫 번째 저장은 사용자 메시지 + Message firstSavedMessage = messageCaptor.getAllValues().get(0); + assertThat(firstSavedMessage.getSenderType()).isEqualTo(SenderType.USER); + assertThat(firstSavedMessage.getContent()).isEqualTo(userMessage); + + // aiModelServiceFactory.getService()가 올바른 AI 모델 타입으로 호출되는지 검증 + verify(aiModelServiceFactory).getService(AIModelType.GPT); + + // AIModelService의 generateResponse()가 호출되는지 검증 + verify(aiModelService).generateResponse(userMessage); + + // AI 응답 저장을 위해 createMessage(userId, SenderType.ASSISTANT, ...)가 호출되는지 검증 + Message secondSavedMessage = messageCaptor.getAllValues().get(1); + assertThat(secondSavedMessage.getSenderType()).isEqualTo(SenderType.ASSISTANT); + assertThat(secondSavedMessage.getContent()).isEqualTo(aiResponse); + + // messagingTemplate.convertAndSendToUser()가 성공(SUCCESS) 상태의 MessageResponse.Chat 객체와 함께 호출되는지 검증 + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(MessageResponse.Chat.class); + verify(messagingTemplate).convertAndSendToUser( + eq(userId.toString()), + eq("/queue/response"), + responseCaptor.capture() + ); + + MessageResponse.Chat capturedResponse = responseCaptor.getValue(); + assertThat(capturedResponse.status()).isEqualTo(MessageResponse.ResponseStatus.SUCCESS); + assertThat(capturedResponse.response()).isEqualTo(aiResponse); + assertThat(capturedResponse.modelType()).isEqualTo(AIModelType.GPT); + assertThat(capturedResponse.sessionId()).isEqualTo("test-session-id"); + assertThat(capturedResponse.messageId()).isEqualTo("test-message-id"); } - - @Nested - @DisplayName("getMessagesRecentFirst 테스트") - class GetMessagesRecentFirstTest { - - @Test - @DisplayName("성공: 최근 메시지 조회 (내림차순)") - void getMessagesRecentFirst_Success() { - // given - Pageable pageable = PageRequest.of(0, 10); - - // when - Page result = messageService.getMessagesRecentFirst(testConversation.getUserId(), pageable); - - // then - assertThat(result.getContent()).hasSize(1); - 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("테스트 메시지"); - } - } - - @Nested - @DisplayName("searchMessages 테스트") - class SearchMessagesTest { - - @Test - @DisplayName("성공: 키워드로 메시지 검색") - void searchMessages_Success() { - // given - String keyword = "테스트"; - - // when - List result = messageService.searchMessages(testConversation.getUserId(), keyword); - - // then - assertThat(result).hasSize(1); - 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); - } - } - - @Nested - @DisplayName("메시지 삭제") - class DeleteMessage { - - @Test - @DisplayName("성공: 메시지 삭제") - void deleteMessage_Success() { - // when - messageService.deleteMessage(testMessage.getId()); - - // then - assertThatThrownBy(() -> messageService.getMessage(testMessage.getId())) - .isInstanceOf(NotFoundException.class); - } - - @Test - @DisplayName("실패: 메시지를 찾을 수 없음") - void deleteMessage_MessageNotFound() { - // given - Long nonExistentMessageId = 999L; - - // when & then - assertThatThrownBy(() -> messageService.deleteMessage(nonExistentMessageId)) - .isInstanceOf(NotFoundException.class); + + @Test + @DisplayName("시나리오 4: AI 응답 처리 실패 (AI 모델 오류)") + void processAndRespondToMessage_AIModelFailure_ShouldSendErrorResponse() { + // Given: 유효한 MessageRequest.Chat 객체, AI 모델 서비스가 실패하도록 설정 + when(conversationRepository.findByUserId(userId)).thenReturn(Optional.of(conversation)); + when(messageRepository.save(any(Message.class))).thenReturn(savedUserMessage); + when(aiModelServiceFactory.getService(AIModelType.GPT)).thenReturn(aiModelService); + + // AI 모델 서비스가 실패하도록 설정 + CompletableFuture failedFuture = CompletableFuture.failedFuture( + new RuntimeException("AI 모델 응답 생성 중 오류가 발생했습니다.") + ); + when(aiModelService.generateResponse(userMessage)).thenReturn(failedFuture); + + // When: processAndRespondToMessage() 호출 + messageService.processAndRespondToMessage(userId, chatRequest); + + // 비동기 처리 완료를 위한 대기 + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } + + // Then: 검증 + // 사용자 메시지는 저장되어야 함 + verify(messageRepository, times(1)).save(any(Message.class)); + + // messagingTemplate.convertAndSendToUser()가 에러(ERROR) 상태의 MessageResponse.Chat 객체와 함께 호출되는지 검증 + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(MessageResponse.Chat.class); + verify(messagingTemplate).convertAndSendToUser( + eq(userId.toString()), + eq("/queue/response"), + responseCaptor.capture() + ); + + MessageResponse.Chat capturedResponse = responseCaptor.getValue(); + assertThat(capturedResponse.status()).isEqualTo(MessageResponse.ResponseStatus.ERROR); + assertThat(capturedResponse.response()).isNull(); + assertThat(capturedResponse.errorMessage()).isNotNull(); + assertThat(capturedResponse.modelType()).isEqualTo(AIModelType.GPT); + assertThat(capturedResponse.sessionId()).isEqualTo("test-session-id"); + assertThat(capturedResponse.messageId()).isEqualTo("test-message-id"); + + // AI 응답 메시지가 DB에 저장되지 않는지 확인 (사용자 메시지만 1번 저장됨) + verify(messageRepository, times(1)).save(any(Message.class)); } } diff --git a/chat_service/src/test/java/com/synapse/chat_service/service/infra/ClaudeServiceTest.java b/chat_service/src/test/java/com/synapse/chat_service/service/infra/ClaudeServiceTest.java new file mode 100644 index 0000000..fd4d1c4 --- /dev/null +++ b/chat_service/src/test/java/com/synapse/chat_service/service/infra/ClaudeServiceTest.java @@ -0,0 +1,153 @@ +package com.synapse.chat_service.service.infra; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.anthropic.AnthropicChatModel; + +import com.synapse.chat_service.service.ai.AIModelType; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ClaudeService 테스트") +class ClaudeServiceTest { + + @Mock + private AnthropicChatModel anthropicChatModel; + + @InjectMocks + private ClaudeService claudeService; + + private String testPrompt; + private String expectedResponse; + + @BeforeEach + void setUp() { + testPrompt = "안녕하세요, 테스트 프롬프트입니다."; + expectedResponse = "안녕하세요! Claude 테스트 응답입니다."; + } + + @Test + @DisplayName("getModelType() 호출 시 CLAUDE 타입을 반환한다") + void getModelType_ShouldReturnClaudeType() { + // When + AIModelType result = claudeService.getModelType(); + + // Then + assertThat(result).isEqualTo(AIModelType.CLAUDE); + } + + @Test + @DisplayName("시나리오 1: AI 응답 생성 성공 - AnthropicChatModel의 call() 메서드가 호출되고 결과가 CompletableFuture로 래핑되어 반환된다") + void generateResponse_Success_ShouldReturnCompletedFuture() throws ExecutionException, InterruptedException { + // Given + when(anthropicChatModel.call(testPrompt)).thenReturn(expectedResponse); + + // When + CompletableFuture result = claudeService.generateResponse(testPrompt); + + // Then + assertThat(result).isNotNull(); + assertThat(result.isDone()).isTrue(); + assertThat(result.isCompletedExceptionally()).isFalse(); + assertThat(result.get()).isEqualTo(expectedResponse); + + // AnthropicChatModel의 call() 메서드가 올바른 프롬프트로 호출되었는지 검증 + verify(anthropicChatModel).call(testPrompt); + } + + @Test + @DisplayName("시나리오 2: AI 응답 생성 실패 - AI 클라이언트가 예외를 던질 때 CompletableFuture가 예외로 완료된다") + void generateResponse_Failure_ShouldReturnFailedFuture() { + // Given + RuntimeException expectedException = new RuntimeException("Anthropic API 호출 실패"); + when(anthropicChatModel.call(anyString())).thenThrow(expectedException); + + // When + CompletableFuture result = claudeService.generateResponse(testPrompt); + + // Then + assertThat(result).isNotNull(); + assertThat(result.isDone()).isTrue(); + assertThat(result.isCompletedExceptionally()).isTrue(); + + // 예외가 올바르게 래핑되어 있는지 검증 + assertThat(result) + .failsWithin(java.time.Duration.ofSeconds(1)) + .withThrowableOfType(ExecutionException.class) + .withCauseInstanceOf(RuntimeException.class) + .withMessageContaining("Claude 모델 응답 생성 중 오류가 발생했습니다."); + + // AnthropicChatModel의 call() 메서드가 호출되었는지 검증 + verify(anthropicChatModel).call(testPrompt); + } + + @Test + @DisplayName("빈 프롬프트로 응답 생성 요청 시 정상 처리된다") + void generateResponse_WithEmptyPrompt_ShouldHandleGracefully() throws ExecutionException, InterruptedException { + // Given + String emptyPrompt = ""; + String emptyResponse = ""; + when(anthropicChatModel.call(emptyPrompt)).thenReturn(emptyResponse); + + // When + CompletableFuture result = claudeService.generateResponse(emptyPrompt); + + // Then + assertThat(result).isNotNull(); + assertThat(result.isDone()).isTrue(); + assertThat(result.isCompletedExceptionally()).isFalse(); + assertThat(result.get()).isEqualTo(emptyResponse); + + verify(anthropicChatModel).call(emptyPrompt); + } + + @Test + @DisplayName("긴 프롬프트로 응답 생성 요청 시 정상 처리된다") + void generateResponse_WithLongPrompt_ShouldHandleGracefully() throws ExecutionException, InterruptedException { + // Given + String longPrompt = "a".repeat(10000); // 10,000자 프롬프트 + String longResponse = "b".repeat(5000); // 5,000자 응답 + when(anthropicChatModel.call(longPrompt)).thenReturn(longResponse); + + // When + CompletableFuture result = claudeService.generateResponse(longPrompt); + + // Then + assertThat(result).isNotNull(); + assertThat(result.isDone()).isTrue(); + assertThat(result.isCompletedExceptionally()).isFalse(); + assertThat(result.get()).isEqualTo(longResponse); + + verify(anthropicChatModel).call(longPrompt); + } + + @Test + @DisplayName("null 응답을 받을 때 정상 처리된다") + void generateResponse_WithNullResponse_ShouldHandleGracefully() throws ExecutionException, InterruptedException { + // Given + when(anthropicChatModel.call(testPrompt)).thenReturn(null); + + // When + CompletableFuture result = claudeService.generateResponse(testPrompt); + + // Then + assertThat(result).isNotNull(); + assertThat(result.isDone()).isTrue(); + assertThat(result.isCompletedExceptionally()).isFalse(); + assertThat(result.get()).isNull(); + + verify(anthropicChatModel).call(testPrompt); + } +} diff --git a/chat_service/src/test/java/com/synapse/chat_service/service/infra/GPTServiceTest.java b/chat_service/src/test/java/com/synapse/chat_service/service/infra/GPTServiceTest.java new file mode 100644 index 0000000..e641373 --- /dev/null +++ b/chat_service/src/test/java/com/synapse/chat_service/service/infra/GPTServiceTest.java @@ -0,0 +1,135 @@ +package com.synapse.chat_service.service.infra; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.openai.OpenAiChatModel; + +import com.synapse.chat_service.service.ai.AIModelType; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GPTService 테스트") +class GPTServiceTest { + + @Mock + private OpenAiChatModel openAiChatModel; + + @InjectMocks + private GPTService gptService; + + private String testPrompt; + private String expectedResponse; + + @BeforeEach + void setUp() { + testPrompt = "안녕하세요, 테스트 프롬프트입니다."; + expectedResponse = "안녕하세요! 테스트 응답입니다."; + } + + @Test + @DisplayName("getModelType() 호출 시 GPT 타입을 반환한다") + void getModelType_ShouldReturnGPTType() { + // When + AIModelType result = gptService.getModelType(); + + // Then + assertThat(result).isEqualTo(AIModelType.GPT); + } + + @Test + @DisplayName("시나리오 1: AI 응답 생성 성공 - OpenAiChatModel의 call() 메서드가 호출되고 결과가 CompletableFuture로 래핑되어 반환된다") + void generateResponse_Success_ShouldReturnCompletedFuture() throws ExecutionException, InterruptedException { + // Given + when(openAiChatModel.call(testPrompt)).thenReturn(expectedResponse); + + // When + CompletableFuture result = gptService.generateResponse(testPrompt); + + // Then + assertThat(result).isNotNull(); + assertThat(result.isDone()).isTrue(); + assertThat(result.isCompletedExceptionally()).isFalse(); + assertThat(result.get()).isEqualTo(expectedResponse); + + // OpenAiChatModel의 call() 메서드가 올바른 프롬프트로 호출되었는지 검증 + verify(openAiChatModel).call(testPrompt); + } + + @Test + @DisplayName("시나리오 2: AI 응답 생성 실패 - AI 클라이언트가 예외를 던질 때 CompletableFuture가 예외로 완료된다") + void generateResponse_Failure_ShouldReturnFailedFuture() { + // Given + RuntimeException expectedException = new RuntimeException("OpenAI API 호출 실패"); + when(openAiChatModel.call(anyString())).thenThrow(expectedException); + + // When + CompletableFuture result = gptService.generateResponse(testPrompt); + + // Then + assertThat(result).isNotNull(); + assertThat(result.isDone()).isTrue(); + assertThat(result.isCompletedExceptionally()).isTrue(); + + // 예외가 올바르게 래핑되어 있는지 검증 + assertThat(result) + .failsWithin(java.time.Duration.ofSeconds(1)) + .withThrowableOfType(ExecutionException.class) + .withCauseInstanceOf(RuntimeException.class) + .withMessageContaining("GPT 모델 응답 생성 중 오류가 발생했습니다."); + + // OpenAiChatModel의 call() 메서드가 호출되었는지 검증 + verify(openAiChatModel).call(testPrompt); + } + + @Test + @DisplayName("빈 프롬프트로 응답 생성 요청 시 정상 처리된다") + void generateResponse_WithEmptyPrompt_ShouldHandleGracefully() throws ExecutionException, InterruptedException { + // Given + String emptyPrompt = ""; + String emptyResponse = ""; + when(openAiChatModel.call(emptyPrompt)).thenReturn(emptyResponse); + + // When + CompletableFuture result = gptService.generateResponse(emptyPrompt); + + // Then + assertThat(result).isNotNull(); + assertThat(result.isDone()).isTrue(); + assertThat(result.isCompletedExceptionally()).isFalse(); + assertThat(result.get()).isEqualTo(emptyResponse); + + verify(openAiChatModel).call(emptyPrompt); + } + + @Test + @DisplayName("긴 프롬프트로 응답 생성 요청 시 정상 처리된다") + void generateResponse_WithLongPrompt_ShouldHandleGracefully() throws ExecutionException, InterruptedException { + // Given + String longPrompt = "a".repeat(10000); // 10,000자 프롬프트 + String longResponse = "b".repeat(5000); // 5,000자 응답 + when(openAiChatModel.call(longPrompt)).thenReturn(longResponse); + + // When + CompletableFuture result = gptService.generateResponse(longPrompt); + + // Then + assertThat(result).isNotNull(); + assertThat(result.isDone()).isTrue(); + assertThat(result.isCompletedExceptionally()).isFalse(); + assertThat(result.get()).isEqualTo(longResponse); + + verify(openAiChatModel).call(longPrompt); + } +} diff --git a/chat_service/src/test/java/com/synapse/chat_service/service/infra/GeminiServiceTest.java b/chat_service/src/test/java/com/synapse/chat_service/service/infra/GeminiServiceTest.java new file mode 100644 index 0000000..6901baf --- /dev/null +++ b/chat_service/src/test/java/com/synapse/chat_service/service/infra/GeminiServiceTest.java @@ -0,0 +1,173 @@ +package com.synapse.chat_service.service.infra; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel; + +import com.synapse.chat_service.service.ai.AIModelType; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GeminiService 테스트") +class GeminiServiceTest { + + @Mock + private VertexAiGeminiChatModel vertexAiGeminiChatModel; + + @InjectMocks + private GeminiService geminiService; + + private String testPrompt; + private String expectedResponse; + + @BeforeEach + void setUp() { + testPrompt = "안녕하세요, 테스트 프롬프트입니다."; + expectedResponse = "안녕하세요! Gemini 테스트 응답입니다."; + } + + @Test + @DisplayName("getModelType() 호출 시 GEMINI 타입을 반환한다") + void getModelType_ShouldReturnGeminiType() { + // When + AIModelType result = geminiService.getModelType(); + + // Then + assertThat(result).isEqualTo(AIModelType.GEMINI); + } + + @Test + @DisplayName("시나리오 1: AI 응답 생성 성공 - VertexAiGeminiChatModel의 call() 메서드가 호출되고 결과가 CompletableFuture로 래핑되어 반환된다") + void generateResponse_Success_ShouldReturnCompletedFuture() throws ExecutionException, InterruptedException { + // Given + when(vertexAiGeminiChatModel.call(testPrompt)).thenReturn(expectedResponse); + + // When + CompletableFuture result = geminiService.generateResponse(testPrompt); + + // Then + assertThat(result).isNotNull(); + assertThat(result.isDone()).isTrue(); + assertThat(result.isCompletedExceptionally()).isFalse(); + assertThat(result.get()).isEqualTo(expectedResponse); + + // VertexAiGeminiChatModel의 call() 메서드가 올바른 프롬프트로 호출되었는지 검증 + verify(vertexAiGeminiChatModel).call(testPrompt); + } + + @Test + @DisplayName("시나리오 2: AI 응답 생성 실패 - AI 클라이언트가 예외를 던질 때 CompletableFuture가 예외로 완료된다") + void generateResponse_Failure_ShouldReturnFailedFuture() { + // Given + RuntimeException expectedException = new RuntimeException("Vertex AI Gemini API 호출 실패"); + when(vertexAiGeminiChatModel.call(anyString())).thenThrow(expectedException); + + // When + CompletableFuture result = geminiService.generateResponse(testPrompt); + + // Then + assertThat(result).isNotNull(); + assertThat(result.isDone()).isTrue(); + assertThat(result.isCompletedExceptionally()).isTrue(); + + // 예외가 올바르게 래핑되어 있는지 검증 + assertThat(result) + .failsWithin(java.time.Duration.ofSeconds(1)) + .withThrowableOfType(ExecutionException.class) + .withCauseInstanceOf(RuntimeException.class) + .withMessageContaining("Gemini 모델 응답 생성 중 오류가 발생했습니다."); + + // VertexAiGeminiChatModel의 call() 메서드가 호출되었는지 검증 + verify(vertexAiGeminiChatModel).call(testPrompt); + } + + @Test + @DisplayName("빈 프롬프트로 응답 생성 요청 시 정상 처리된다") + void generateResponse_WithEmptyPrompt_ShouldHandleGracefully() throws ExecutionException, InterruptedException { + // Given + String emptyPrompt = ""; + String emptyResponse = ""; + when(vertexAiGeminiChatModel.call(emptyPrompt)).thenReturn(emptyResponse); + + // When + CompletableFuture result = geminiService.generateResponse(emptyPrompt); + + // Then + assertThat(result).isNotNull(); + assertThat(result.isDone()).isTrue(); + assertThat(result.isCompletedExceptionally()).isFalse(); + assertThat(result.get()).isEqualTo(emptyResponse); + + verify(vertexAiGeminiChatModel).call(emptyPrompt); + } + + @Test + @DisplayName("긴 프롬프트로 응답 생성 요청 시 정상 처리된다") + void generateResponse_WithLongPrompt_ShouldHandleGracefully() throws ExecutionException, InterruptedException { + // Given + String longPrompt = "a".repeat(10000); // 10,000자 프롬프트 + String longResponse = "b".repeat(5000); // 5,000자 응답 + when(vertexAiGeminiChatModel.call(longPrompt)).thenReturn(longResponse); + + // When + CompletableFuture result = geminiService.generateResponse(longPrompt); + + // Then + assertThat(result).isNotNull(); + assertThat(result.isDone()).isTrue(); + assertThat(result.isCompletedExceptionally()).isFalse(); + assertThat(result.get()).isEqualTo(longResponse); + + verify(vertexAiGeminiChatModel).call(longPrompt); + } + + @Test + @DisplayName("null 응답을 받을 때 정상 처리된다") + void generateResponse_WithNullResponse_ShouldHandleGracefully() throws ExecutionException, InterruptedException { + // Given + when(vertexAiGeminiChatModel.call(testPrompt)).thenReturn(null); + + // When + CompletableFuture result = geminiService.generateResponse(testPrompt); + + // Then + assertThat(result).isNotNull(); + assertThat(result.isDone()).isTrue(); + assertThat(result.isCompletedExceptionally()).isFalse(); + assertThat(result.get()).isNull(); + + verify(vertexAiGeminiChatModel).call(testPrompt); + } + + @Test + @DisplayName("특수 문자가 포함된 프롬프트로 응답 생성 요청 시 정상 처리된다") + void generateResponse_WithSpecialCharacters_ShouldHandleGracefully() throws ExecutionException, InterruptedException { + // Given + String specialPrompt = "특수문자 테스트: !@#$%^&*()_+{}|:<>?[]\\;'\",./ 한글 English 123"; + String specialResponse = "특수문자가 포함된 응답입니다."; + when(vertexAiGeminiChatModel.call(specialPrompt)).thenReturn(specialResponse); + + // When + CompletableFuture result = geminiService.generateResponse(specialPrompt); + + // Then + assertThat(result).isNotNull(); + assertThat(result.isDone()).isTrue(); + assertThat(result.isCompletedExceptionally()).isFalse(); + assertThat(result.get()).isEqualTo(specialResponse); + + verify(vertexAiGeminiChatModel).call(specialPrompt); + } +} diff --git a/chat_service/src/test/java/com/synapse/chat_service/session/RedisAiChatManagerTest.java b/chat_service/src/test/java/com/synapse/chat_service/session/RedisAiChatManagerTest.java new file mode 100644 index 0000000..1ad9576 --- /dev/null +++ b/chat_service/src/test/java/com/synapse/chat_service/session/RedisAiChatManagerTest.java @@ -0,0 +1,329 @@ +package com.synapse.chat_service.session; + +import com.synapse.chat_service.common.util.RedisTypeConverter; +import com.synapse.chat_service.session.dto.AiChatInfo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RedisAiChatManager 테스트") +class RedisAiChatManagerTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private RedisKeyGenerator keyGenerator; + + @Mock + private RedisTypeConverter typeConverter; + + @Mock + private ValueOperations valueOperations; + + @InjectMocks + private RedisAiChatManager redisAiChatManager; + + private AiChatInfo testAiChatInfo; + private final String testUserId = "test-user-123"; + private final UUID testConversationId = UUID.randomUUID(); + private final String testAiChatKey = "ai:conversation:test-user-123"; + private final Duration AI_CHAT_EXPIRATION = Duration.ofDays(30); + + @BeforeEach + void setUp() { + testAiChatInfo = AiChatInfo.create(testUserId, testConversationId); + + // RedisTemplate operations mocking + lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); + } + + @Test + @DisplayName("AI 채팅 정보 조회 시 올바른 키 생성 및 Redis 호출 검증") + void getAiChat_ShouldGenerateCorrectKey_AndCallRedisTemplate() { + // Given + when(keyGenerator.generateAIConversationKey(testUserId)).thenReturn(testAiChatKey); + when(valueOperations.get(testAiChatKey)).thenReturn(testAiChatInfo); + when(typeConverter.convertValue(testAiChatInfo, AiChatInfo.class)).thenReturn(testAiChatInfo); + + // When + Optional result = redisAiChatManager.getAiChat(testUserId); + + // Then + assertThat(result).isPresent(); + assertThat(result.get().userId()).isEqualTo(testUserId); + assertThat(result.get().conversationId()).isEqualTo(testConversationId); + + // 키 생성 검증 + verify(keyGenerator).generateAIConversationKey(testUserId); + + // Redis 호출 검증 + verify(redisTemplate).opsForValue(); + verify(valueOperations).get(testAiChatKey); + verify(typeConverter).convertValue(testAiChatInfo, AiChatInfo.class); + } + + @Test + @DisplayName("AI 채팅 정보 조회 시 데이터가 없는 경우 빈 Optional 반환") + void getAiChat_WhenNoData_ShouldReturnEmptyOptional() { + // Given + when(keyGenerator.generateAIConversationKey(testUserId)).thenReturn(testAiChatKey); + when(valueOperations.get(testAiChatKey)).thenReturn(null); + when(typeConverter.convertValue(null, AiChatInfo.class)).thenReturn(null); + + // When + Optional result = redisAiChatManager.getAiChat(testUserId); + + // Then + assertThat(result).isEmpty(); + + // 키 생성 검증 + verify(keyGenerator).generateAIConversationKey(testUserId); + + // Redis 호출 검증 + verify(redisTemplate).opsForValue(); + verify(valueOperations).get(testAiChatKey); + verify(typeConverter).convertValue(null, AiChatInfo.class); + } + + @Test + @DisplayName("AI 채팅 활동 시간 업데이트 시 올바른 키 생성 및 Redis 호출 검증") + void updateAiChatActivity_ShouldGenerateCorrectKey_AndCallRedisTemplate() { + // Given + when(keyGenerator.generateAIConversationKey(testUserId)).thenReturn(testAiChatKey); + when(valueOperations.get(testAiChatKey)).thenReturn(testAiChatInfo); + when(typeConverter.convertValue(testAiChatInfo, AiChatInfo.class)).thenReturn(testAiChatInfo); + + // When + redisAiChatManager.updateAiChatActivity(testUserId); + + // Then + // 키 생성 검증 + verify(keyGenerator).generateAIConversationKey(testUserId); + + // Redis 호출 검증 - get과 set 모두 호출되어야 함 + verify(redisTemplate, times(2)).opsForValue(); // get용 1번, set용 1번 + verify(valueOperations).get(testAiChatKey); + verify(valueOperations).set(eq(testAiChatKey), any(AiChatInfo.class), eq(AI_CHAT_EXPIRATION)); + verify(typeConverter).convertValue(testAiChatInfo, AiChatInfo.class); + } + + @Test + @DisplayName("AI 채팅 활동 시간 업데이트 시 데이터가 없는 경우 Redis 호출하지 않음") + void updateAiChatActivity_WhenNoData_ShouldNotCallRedisSet() { + // Given + when(keyGenerator.generateAIConversationKey(testUserId)).thenReturn(testAiChatKey); + when(valueOperations.get(testAiChatKey)).thenReturn(null); + when(typeConverter.convertValue(null, AiChatInfo.class)).thenReturn(null); + + // When + redisAiChatManager.updateAiChatActivity(testUserId); + + // Then + // 키 생성 검증 + verify(keyGenerator).generateAIConversationKey(testUserId); + + // Redis 호출 검증 - get만 호출되고 set은 호출되지 않아야 함 + verify(redisTemplate).opsForValue(); + verify(valueOperations).get(testAiChatKey); + verify(valueOperations, never()).set(anyString(), any(), any(Duration.class)); + verify(typeConverter).convertValue(null, AiChatInfo.class); + } + + @Test + @DisplayName("AI 채팅 메시지 수 증가 시 올바른 키 생성 및 Redis 호출 검증") + void incrementMessageCount_ShouldGenerateCorrectKey_AndCallRedisTemplate() { + // Given + when(keyGenerator.generateAIConversationKey(testUserId)).thenReturn(testAiChatKey); + when(valueOperations.get(testAiChatKey)).thenReturn(testAiChatInfo); + when(typeConverter.convertValue(testAiChatInfo, AiChatInfo.class)).thenReturn(testAiChatInfo); + + // When + redisAiChatManager.incrementMessageCount(testUserId); + + // Then + // 키 생성 검증 + verify(keyGenerator).generateAIConversationKey(testUserId); + + // Redis 호출 검증 - get과 set 모두 호출되어야 함 + verify(redisTemplate, times(2)).opsForValue(); // get용 1번, set용 1번 + verify(valueOperations).get(testAiChatKey); + verify(valueOperations).set(eq(testAiChatKey), any(AiChatInfo.class), eq(AI_CHAT_EXPIRATION)); + verify(typeConverter).convertValue(testAiChatInfo, AiChatInfo.class); + } + + @Test + @DisplayName("AI 채팅 메시지 수 증가 시 데이터가 없는 경우 Redis 호출하지 않음") + void incrementMessageCount_WhenNoData_ShouldNotCallRedisSet() { + // Given + when(keyGenerator.generateAIConversationKey(testUserId)).thenReturn(testAiChatKey); + when(valueOperations.get(testAiChatKey)).thenReturn(null); + when(typeConverter.convertValue(null, AiChatInfo.class)).thenReturn(null); + + // When + redisAiChatManager.incrementMessageCount(testUserId); + + // Then + // 키 생성 검증 + verify(keyGenerator).generateAIConversationKey(testUserId); + + // Redis 호출 검증 - get만 호출되고 set은 호출되지 않아야 함 + verify(redisTemplate).opsForValue(); + verify(valueOperations).get(testAiChatKey); + verify(valueOperations, never()).set(anyString(), any(), any(Duration.class)); + verify(typeConverter).convertValue(null, AiChatInfo.class); + } + + @Test + @DisplayName("AI 채팅 정보 삭제 시 올바른 키 생성 및 Redis 호출 검증") + void deleteAiChat_ShouldGenerateCorrectKey_AndCallRedisTemplate() { + // Given + when(keyGenerator.generateAIConversationKey(testUserId)).thenReturn(testAiChatKey); + + // When + redisAiChatManager.deleteAiChat(testUserId); + + // Then + // 키 생성 검증 + verify(keyGenerator).generateAIConversationKey(testUserId); + + // Redis 호출 검증 + verify(redisTemplate).delete(testAiChatKey); + } + + @Test + @DisplayName("UUID 기반 AI 채팅 세션 생성 시 올바른 키 생성 및 Redis 호출 검증") + void createOrUpdateAiChatWithConversation_WhenNewChat_ShouldGenerateCorrectKey_AndCallRedisTemplate() { + // Given + when(keyGenerator.generateAIConversationKey(testUserId)).thenReturn(testAiChatKey); + when(valueOperations.get(testAiChatKey)).thenReturn(null); // 기존 채팅 없음 + when(typeConverter.convertValue(null, AiChatInfo.class)).thenReturn(null); + + // When + AiChatInfo result = redisAiChatManager.createOrUpdateAiChatWithConversation(testUserId, testConversationId); + + // Then + assertThat(result).isNotNull(); + assertThat(result.userId()).isEqualTo(testUserId); + assertThat(result.conversationId()).isEqualTo(testConversationId); + + // 키 생성 검증 + verify(keyGenerator).generateAIConversationKey(testUserId); + + // Redis 호출 검증 + verify(redisTemplate, times(2)).opsForValue(); // get용 1번, set용 1번 + verify(valueOperations).get(testAiChatKey); + verify(valueOperations).set(eq(testAiChatKey), any(AiChatInfo.class), eq(AI_CHAT_EXPIRATION)); + verify(typeConverter).convertValue(null, AiChatInfo.class); + } + + @Test + @DisplayName("UUID 기반 AI 채팅 세션 업데이트 시 올바른 키 생성 및 Redis 호출 검증") + void createOrUpdateAiChatWithConversation_WhenExistingChat_ShouldGenerateCorrectKey_AndCallRedisTemplate() { + // Given + UUID newConversationId = UUID.randomUUID(); + when(keyGenerator.generateAIConversationKey(testUserId)).thenReturn(testAiChatKey); + when(valueOperations.get(testAiChatKey)).thenReturn(testAiChatInfo); // 기존 채팅 있음 + when(typeConverter.convertValue(testAiChatInfo, AiChatInfo.class)).thenReturn(testAiChatInfo); + + // When + AiChatInfo result = redisAiChatManager.createOrUpdateAiChatWithConversation(testUserId, newConversationId); + + // Then + assertThat(result).isNotNull(); + assertThat(result.userId()).isEqualTo(testUserId); + assertThat(result.conversationId()).isEqualTo(newConversationId); + + // 키 생성 검증 + verify(keyGenerator).generateAIConversationKey(testUserId); + + // Redis 호출 검증 + verify(redisTemplate, times(2)).opsForValue(); // get용 1번, set용 1번 + verify(valueOperations).get(testAiChatKey); + verify(valueOperations).set(eq(testAiChatKey), any(AiChatInfo.class), eq(AI_CHAT_EXPIRATION)); + verify(typeConverter).convertValue(testAiChatInfo, AiChatInfo.class); + } + + @Test + @DisplayName("Conversation ID 동기화 시 올바른 키 생성 및 Redis 호출 검증") + void syncConversationId_WhenDifferentId_ShouldGenerateCorrectKey_AndCallRedisTemplate() { + // Given + UUID newConversationId = UUID.randomUUID(); + when(keyGenerator.generateAIConversationKey(testUserId)).thenReturn(testAiChatKey); + when(valueOperations.get(testAiChatKey)).thenReturn(testAiChatInfo); + when(typeConverter.convertValue(testAiChatInfo, AiChatInfo.class)).thenReturn(testAiChatInfo); + + // When + redisAiChatManager.syncConversationId(testUserId, newConversationId); + + // Then + // 키 생성 검증 + verify(keyGenerator).generateAIConversationKey(testUserId); + + // Redis 호출 검증 + verify(redisTemplate, times(2)).opsForValue(); // get용 1번, set용 1번 + verify(valueOperations).get(testAiChatKey); + verify(valueOperations).set(eq(testAiChatKey), any(AiChatInfo.class), eq(AI_CHAT_EXPIRATION)); + verify(typeConverter).convertValue(testAiChatInfo, AiChatInfo.class); + } + + @Test + @DisplayName("Conversation ID 동기화 시 같은 ID인 경우 Redis set 호출하지 않음") + void syncConversationId_WhenSameId_ShouldNotCallRedisSet() { + // Given + when(keyGenerator.generateAIConversationKey(testUserId)).thenReturn(testAiChatKey); + when(valueOperations.get(testAiChatKey)).thenReturn(testAiChatInfo); + when(typeConverter.convertValue(testAiChatInfo, AiChatInfo.class)).thenReturn(testAiChatInfo); + + // When - 같은 conversationId로 동기화 시도 + redisAiChatManager.syncConversationId(testUserId, testConversationId); + + // Then + // 키 생성 검증 + verify(keyGenerator).generateAIConversationKey(testUserId); + + // Redis 호출 검증 - get만 호출되고 set은 호출되지 않아야 함 + verify(redisTemplate).opsForValue(); + verify(valueOperations).get(testAiChatKey); + verify(valueOperations, never()).set(anyString(), any(), any(Duration.class)); + verify(typeConverter).convertValue(testAiChatInfo, AiChatInfo.class); + } + + @Test + @DisplayName("Conversation ID 동기화 시 데이터가 없는 경우 Redis set 호출하지 않음") + void syncConversationId_WhenNoData_ShouldNotCallRedisSet() { + // Given + UUID newConversationId = UUID.randomUUID(); + when(keyGenerator.generateAIConversationKey(testUserId)).thenReturn(testAiChatKey); + when(valueOperations.get(testAiChatKey)).thenReturn(null); + when(typeConverter.convertValue(null, AiChatInfo.class)).thenReturn(null); + + // When + redisAiChatManager.syncConversationId(testUserId, newConversationId); + + // Then + // 키 생성 검증 + verify(keyGenerator).generateAIConversationKey(testUserId); + + // Redis 호출 검증 - get만 호출되고 set은 호출되지 않아야 함 + verify(redisTemplate).opsForValue(); + verify(valueOperations).get(testAiChatKey); + verify(valueOperations, never()).set(anyString(), any(), any(Duration.class)); + verify(typeConverter).convertValue(null, AiChatInfo.class); + } +} diff --git a/chat_service/src/test/java/com/synapse/chat_service/session/RedisSessionManagerTest.java b/chat_service/src/test/java/com/synapse/chat_service/session/RedisSessionManagerTest.java new file mode 100644 index 0000000..2f0fbf2 --- /dev/null +++ b/chat_service/src/test/java/com/synapse/chat_service/session/RedisSessionManagerTest.java @@ -0,0 +1,318 @@ +package com.synapse.chat_service.session; + +import com.synapse.chat_service.common.util.RedisTypeConverter; +import com.synapse.chat_service.session.dto.SessionInfo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.SetOperations; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RedisSessionManager 테스트") +class RedisSessionManagerTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private RedisKeyGenerator keyGenerator; + + @Mock + private RedisTypeConverter typeConverter; + + @Mock + private SessionProperties sessionProperties; + + @Mock + private ValueOperations valueOperations; + + @Mock + private SetOperations setOperations; + + @InjectMocks + private RedisSessionManager redisSessionManager; + + private SessionInfo testSessionInfo; + private final String testSessionId = "test-session-123"; + private final String testUserId = "test-user-456"; + private final String testClientInfo = "Test Client Info"; + private final String testSessionKey = "session:test-session-123"; + private final String testUserSessionKey = "user:session:test-user-456"; + + @BeforeEach + void setUp() { + testSessionInfo = SessionInfo.create( + testSessionId, + testUserId, + testClientInfo + ); + + // RedisTemplate operations mocking + lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); + lenient().when(redisTemplate.opsForSet()).thenReturn(setOperations); + + // SessionProperties mocking + lenient().when(sessionProperties.maxSessionsPerUser()).thenReturn(3); + lenient().when(sessionProperties.expirationHours()).thenReturn(24); + } + + @Test + @DisplayName("세션 조회 시 올바른 키 생성 및 Redis 호출 검증") + void getSession_ShouldGenerateCorrectKey_AndCallRedisTemplate() { + // Given + when(keyGenerator.generateSessionKey(testSessionId)).thenReturn(testSessionKey); + when(valueOperations.get(testSessionKey)).thenReturn(testSessionInfo); + when(typeConverter.convertValue(testSessionInfo, SessionInfo.class)).thenReturn(testSessionInfo); + + // When + SessionInfo result = redisSessionManager.getSession(testSessionId); + + // Then + assertThat(result).isNotNull(); + assertThat(result.sessionId()).isEqualTo(testSessionId); + + // 키 생성 검증 + verify(keyGenerator).generateSessionKey(testSessionId); + + // Redis 호출 검증 + verify(redisTemplate).opsForValue(); + verify(valueOperations).get(testSessionKey); + verify(typeConverter).convertValue(testSessionInfo, SessionInfo.class); + } + + @Test + @DisplayName("세션 업데이트 시 올바른 키 생성 및 Redis 호출 검증") + void updateSession_ShouldGenerateCorrectKey_AndCallRedisTemplate() { + // Given + when(keyGenerator.generateSessionKey(testSessionInfo.sessionId())).thenReturn(testSessionKey); + + // When + redisSessionManager.updateSession(testSessionInfo); + + // Then + // 키 생성 검증 + verify(keyGenerator).generateSessionKey(testSessionInfo.sessionId()); + + // Redis 호출 검증 + verify(redisTemplate).opsForValue(); + verify(valueOperations).set(eq(testSessionKey), eq(testSessionInfo), eq(Duration.ofHours(24))); + } + + @Test + @DisplayName("활성 세션 수 조회 시 올바른 키 생성 및 Redis 호출 검증") + void getActiveSessionCount_ShouldGenerateCorrectKey_AndCallRedisTemplate() { + // Given + when(keyGenerator.generateUserSessionKey(testUserId)).thenReturn(testUserSessionKey); + when(setOperations.size(testUserSessionKey)).thenReturn(2L); + + // When + int result = redisSessionManager.getActiveSessionCount(testUserId); + + // Then + assertThat(result).isEqualTo(2); + + // 키 생성 검증 + verify(keyGenerator).generateUserSessionKey(testUserId); + + // Redis 호출 검증 + verify(redisTemplate).opsForSet(); + verify(setOperations).size(testUserSessionKey); + } + + @Test + @DisplayName("사용자별 세션 조회 시 올바른 키 생성 및 Redis 호출 검증") + void getSessionByUserId_ShouldGenerateCorrectKey_AndCallRedisTemplate() { + // Given + when(keyGenerator.generateUserSessionKey(testUserId)).thenReturn(testUserSessionKey); + when(keyGenerator.generateSessionKey(testSessionId)).thenReturn(testSessionKey); + when(setOperations.members(testUserSessionKey)).thenReturn(Set.of(testSessionId)); + when(typeConverter.convertToString(testSessionId)).thenReturn(testSessionId); + when(valueOperations.get(testSessionKey)).thenReturn(testSessionInfo); + when(typeConverter.convertValue(testSessionInfo, SessionInfo.class)).thenReturn(testSessionInfo); + + // When + SessionInfo result = redisSessionManager.getSessionByUserId(testUserId); + + // Then + assertThat(result).isNotNull(); + assertThat(result.userId()).isEqualTo(testUserId); + + // 키 생성 검증 + verify(keyGenerator).generateUserSessionKey(testUserId); + verify(keyGenerator).generateSessionKey(testSessionId); + + // Redis 호출 검증 + verify(redisTemplate).opsForSet(); // members 호출 + verify(setOperations).members(testUserSessionKey); + verify(redisTemplate).opsForValue(); + verify(valueOperations).get(testSessionKey); + } + + @Test + @DisplayName("세션 존재 확인 시 올바른 키 생성 및 Redis 호출 검증") + void existsSession_ShouldGenerateCorrectKey_AndCallRedisTemplate() { + // Given + when(keyGenerator.generateSessionKey(testSessionId)).thenReturn(testSessionKey); + when(redisTemplate.hasKey(testSessionKey)).thenReturn(true); + + // When + boolean result = redisSessionManager.existsSession(testSessionId); + + // Then + assertThat(result).isTrue(); + + // 키 생성 검증 + verify(keyGenerator).generateSessionKey(testSessionId); + + // Redis 호출 검증 + verify(redisTemplate).hasKey(testSessionKey); + } + + @Test + @DisplayName("최대 세션 수 초과 시 removeOldestSession 호출 검증") + @SuppressWarnings("unchecked") + void createSession_WhenMaxSessionsExceeded_ShouldCallRemoveOldestSession() { + // Given + when(keyGenerator.generateSessionKey(testSessionInfo.sessionId())).thenReturn(testSessionKey); + when(keyGenerator.generateUserSessionKey(testSessionInfo.userId())).thenReturn(testUserSessionKey); + when(sessionProperties.maxSessionsPerUser()).thenReturn(2); // 최대 2개 세션 + when(setOperations.size(testUserSessionKey)).thenReturn(3L); // 현재 3개 세션 (초과) + + // removeOldestSession에서 사용할 기존 세션들 설정 + SessionInfo oldSession1 = SessionInfo.create("old-session-1", testUserId, "Old Client 1"); + SessionInfo oldSession2 = SessionInfo.create("old-session-2", testUserId, "Old Client 2"); + when(setOperations.members(testUserSessionKey)).thenReturn(Set.of("old-session-1", "old-session-2")); + when(typeConverter.convertToString("old-session-1")).thenReturn("old-session-1"); + when(typeConverter.convertToString("old-session-2")).thenReturn("old-session-2"); + when(keyGenerator.generateSessionKey("old-session-1")).thenReturn("session:old-session-1"); + when(keyGenerator.generateSessionKey("old-session-2")).thenReturn("session:old-session-2"); + when(valueOperations.get("session:old-session-1")).thenReturn(oldSession1); + when(valueOperations.get("session:old-session-2")).thenReturn(oldSession2); + when(typeConverter.convertValue(oldSession1, SessionInfo.class)).thenReturn(oldSession1); + when(typeConverter.convertValue(oldSession2, SessionInfo.class)).thenReturn(oldSession2); + + // RedisCallback 실행을 위한 Mock 설정 + when(redisTemplate.execute(any(RedisCallback.class))).thenReturn(null); + + // When + redisSessionManager.createSession(testSessionInfo); + + // Then + // 키 생성 검증 - generateUserSessionKey는 총 4번 호출됨: + // 1. createSession에서 직접 호출 + // 2. getActiveSessionCount 호출 시 + // 3. removeOldestSession -> getSessionsByUserId 호출 시 + // 4. removeOldestSession -> deleteSession 호출 시 + verify(keyGenerator).generateSessionKey(testSessionInfo.sessionId()); + verify(keyGenerator, times(4)).generateUserSessionKey(testSessionInfo.userId()); + + // 활성 세션 수 조회 검증 (removeOldestSession 호출 여부 판단용) + verify(redisTemplate, atLeastOnce()).opsForSet(); + verify(setOperations, atLeastOnce()).size(testUserSessionKey); + + // removeOldestSession 내부에서 호출되는 메서드들 검증 + verify(setOperations, atLeastOnce()).members(testUserSessionKey); + + // 세션 생성을 위한 Redis 트랜잭션 실행 검증 + verify(redisTemplate, atLeastOnce()).execute(any(RedisCallback.class)); + } + + @Test + @DisplayName("최대 세션 수 미만일 때 removeOldestSession 호출되지 않음 검증") + @SuppressWarnings("unchecked") + void createSession_WhenMaxSessionsNotExceeded_ShouldNotCallRemoveOldestSession() { + // Given + when(keyGenerator.generateSessionKey(testSessionInfo.sessionId())).thenReturn(testSessionKey); + when(keyGenerator.generateUserSessionKey(testSessionInfo.userId())).thenReturn(testUserSessionKey); + when(sessionProperties.maxSessionsPerUser()).thenReturn(5); // 최대 5개 세션 + when(setOperations.size(testUserSessionKey)).thenReturn(2L); // 현재 2개 세션 (미만) + + // RedisCallback 실행을 위한 Mock 설정 + when(redisTemplate.execute(any(RedisCallback.class))).thenReturn(null); + + // When + redisSessionManager.createSession(testSessionInfo); + + // Then + // 키 생성 검증 - generateUserSessionKey는 총 2번 호출됨: + // 1. createSession에서 직접 호출 + // 2. getActiveSessionCount 호출 시 + verify(keyGenerator).generateSessionKey(testSessionInfo.sessionId()); + verify(keyGenerator, times(2)).generateUserSessionKey(testSessionInfo.userId()); + + // 활성 세션 수 조회 검증 + verify(redisTemplate).opsForSet(); + verify(setOperations).size(testUserSessionKey); + + // removeOldestSession이 호출되지 않았음을 검증 (members 호출이 없음) + verify(setOperations, never()).members(testUserSessionKey); + + // 세션 생성을 위한 Redis 트랜잭션 실행 검증 + verify(redisTemplate).execute(any(RedisCallback.class)); + } + + @Test + @DisplayName("세션 삭제 시 올바른 키 생성 및 Redis 호출 검증") + @SuppressWarnings("unchecked") + void deleteSession_ShouldGenerateCorrectKeys_AndCallRedisTemplate() { + // Given + when(keyGenerator.generateSessionKey(testSessionId)).thenReturn(testSessionKey); + when(keyGenerator.generateUserSessionKey(testUserId)).thenReturn(testUserSessionKey); + when(valueOperations.get(testSessionKey)).thenReturn(testSessionInfo); + when(typeConverter.convertValue(testSessionInfo, SessionInfo.class)).thenReturn(testSessionInfo); + when(redisTemplate.execute(any(RedisCallback.class))).thenReturn(null); + + // When + redisSessionManager.deleteSession(testSessionId); + + // Then + // 키 생성 검증 + verify(keyGenerator, times(2)).generateSessionKey(testSessionId); // getSession + deleteSession + verify(keyGenerator).generateUserSessionKey(testUserId); + + // Redis 호출 검증 + verify(redisTemplate).opsForValue(); + verify(valueOperations).get(testSessionKey); + verify(redisTemplate).execute(any(RedisCallback.class)); + } + + @Test + @DisplayName("모든 사용자 세션 삭제 시 올바른 키 생성 및 Redis 호출 검증") + void deleteAllUserSessions_ShouldGenerateCorrectKeys_AndCallRedisTemplate() { + // Given + when(keyGenerator.generateUserSessionKey(testUserId)).thenReturn(testUserSessionKey); + when(keyGenerator.generateSessionKey("session1")).thenReturn("session:session1"); + when(keyGenerator.generateSessionKey("session2")).thenReturn("session:session2"); + when(setOperations.members(testUserSessionKey)).thenReturn(Set.of("session1", "session2")); + when(typeConverter.convertToString("session1")).thenReturn("session1"); + when(typeConverter.convertToString("session2")).thenReturn("session2"); + + // When + redisSessionManager.deleteAllUserSessions(testUserId); + + // Then + // 키 생성 검증 + verify(keyGenerator).generateUserSessionKey(testUserId); + verify(keyGenerator).generateSessionKey("session1"); + verify(keyGenerator).generateSessionKey("session2"); + + // Redis 호출 검증 + verify(redisTemplate).opsForSet(); + verify(setOperations).members(testUserSessionKey); + verify(redisTemplate, times(3)).delete(anyString()); // 개별 세션 2개 + 사용자 세션 Set 1개 + } +} diff --git a/chat_service/src/test/java/com/synapse/chat_service/session/WebSocketSessionFacadeTest.java b/chat_service/src/test/java/com/synapse/chat_service/session/WebSocketSessionFacadeTest.java new file mode 100644 index 0000000..d389a1e --- /dev/null +++ b/chat_service/src/test/java/com/synapse/chat_service/session/WebSocketSessionFacadeTest.java @@ -0,0 +1,205 @@ +package com.synapse.chat_service.session; + +import com.synapse.chat_service.session.dto.SessionInfo; +import com.synapse.chat_service.session.dto.SessionStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * WebSocketSessionFacade 테스트 클래스 + * 세션 관리 퍼사드의 사용자 연결 및 연결 해제 처리를 검증합니다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("WebSocketSessionFacade 테스트") +class WebSocketSessionFacadeTest { + + @Mock + private RedisSessionManager sessionManager; + + @Mock + private RedisAiChatManager aiChatManager; + + @InjectMocks + private WebSocketSessionFacade webSocketSessionFacade; + + private String testSessionId; + private String testUserId; + private String testClientInfo; + private SessionInfo testSessionInfo; + + @BeforeEach + void setUp() { + testSessionId = "test-session-123"; + testUserId = "test-user-456"; + testClientInfo = "Chrome/120.0 Windows"; + + testSessionInfo = new SessionInfo( + testSessionId, + testUserId, + LocalDateTime.now(), + LocalDateTime.now(), + SessionStatus.CONNECTED, + testClientInfo + ); + } + + @Test + @DisplayName("시나리오 1: 사용자 연결 처리 - handleUserConnection() 호출 시 올바른 인자로 메서드들이 호출되는지 검증") + void handleUserConnection_ShouldCallCorrectMethods_WithCorrectArguments() { + // When + SessionInfo result = webSocketSessionFacade.handleUserConnection(testSessionId, testUserId, testClientInfo); + + // Then + // 1. sessionManager.createSession()이 올바른 SessionInfo로 호출되는지 검증 + verify(sessionManager, times(1)).createSession(any(SessionInfo.class)); + + // createSession에 전달된 SessionInfo의 내용 검증 + verify(sessionManager).createSession(argThat(sessionInfo -> + sessionInfo.sessionId().equals(testSessionId) && + sessionInfo.userId().equals(testUserId) && + sessionInfo.clientInfo().equals(testClientInfo) && + sessionInfo.status() == SessionStatus.CONNECTED + )); + + // 2. aiChatManager.updateAiChatActivity()가 올바른 userId로 호출되는지 검증 + verify(aiChatManager, times(1)).updateAiChatActivity(eq(testUserId)); + + // 3. 반환된 SessionInfo 검증 + assertThat(result).isNotNull(); + assertThat(result.sessionId()).isEqualTo(testSessionId); + assertThat(result.userId()).isEqualTo(testUserId); + assertThat(result.clientInfo()).isEqualTo(testClientInfo); + assertThat(result.status()).isEqualTo(SessionStatus.CONNECTED); + } + + @Test + @DisplayName("시나리오 2: 사용자 연결 해제 처리 - 세션 정보가 있을 경우 올바른 순서로 메서드들이 호출되는지 검증") + void handleUserDisconnection_WithExistingSession_ShouldCallCorrectMethods() { + // Given + when(sessionManager.getSession(testSessionId)).thenReturn(testSessionInfo); + + // When + webSocketSessionFacade.handleUserDisconnection(testSessionId); + + // Then + // 1. sessionManager.getSession()이 올바른 sessionId로 호출되는지 검증 + verify(sessionManager, times(1)).getSession(eq(testSessionId)); + + // 2. aiChatManager.updateAiChatActivity()가 올바른 userId로 호출되는지 검증 + verify(aiChatManager, times(1)).updateAiChatActivity(eq(testUserId)); + + // 3. sessionManager.deleteSession()이 올바른 sessionId로 호출되는지 검증 + verify(sessionManager, times(1)).deleteSession(eq(testSessionId)); + + // 4. 메서드 호출 순서 검증 + var inOrder = inOrder(sessionManager, aiChatManager); + inOrder.verify(sessionManager).getSession(testSessionId); + inOrder.verify(aiChatManager).updateAiChatActivity(testUserId); + inOrder.verify(sessionManager).deleteSession(testSessionId); + } + + @Test + @DisplayName("시나리오 2-1: 사용자 연결 해제 처리 - 세션 정보가 없을 경우 deleteSession이 호출되지 않는지 검증") + void handleUserDisconnection_WithNoSession_ShouldNotCallDeleteSession() { + // Given + when(sessionManager.getSession(testSessionId)).thenReturn(null); + + // When + webSocketSessionFacade.handleUserDisconnection(testSessionId); + + // Then + // 1. sessionManager.getSession()이 호출되는지 검증 + verify(sessionManager, times(1)).getSession(eq(testSessionId)); + + // 2. aiChatManager.updateAiChatActivity()가 호출되지 않는지 검증 + verify(aiChatManager, never()).updateAiChatActivity(any()); + + // 3. sessionManager.deleteSession()이 호출되지 않는지 검증 + verify(sessionManager, never()).deleteSession(any()); + } + + @Test + @DisplayName("메시지 활동 처리 - handleMessageActivity() 호출 시 올바른 메서드들이 호출되는지 검증") + void handleMessageActivity_ShouldCallCorrectMethods() { + // When + webSocketSessionFacade.handleMessageActivity(testUserId); + + // Then + // 1. aiChatManager.incrementMessageCount()가 올바른 userId로 호출되는지 검증 + verify(aiChatManager, times(1)).incrementMessageCount(eq(testUserId)); + + // 2. aiChatManager.updateAiChatActivity()가 올바른 userId로 호출되는지 검증 + verify(aiChatManager, times(1)).updateAiChatActivity(eq(testUserId)); + + // 3. 메서드 호출 순서 검증 + var inOrder = inOrder(aiChatManager); + inOrder.verify(aiChatManager).incrementMessageCount(testUserId); + inOrder.verify(aiChatManager).updateAiChatActivity(testUserId); + } + + @Test + @DisplayName("세션 활동 업데이트 - updateSessionActivity() 호출 시 올바른 메서드들이 호출되는지 검증") + void updateSessionActivity_WithExistingSession_ShouldCallCorrectMethods() { + // Given + when(sessionManager.getSession(testSessionId)).thenReturn(testSessionInfo); + + // When + webSocketSessionFacade.updateSessionActivity(testSessionId); + + // Then + // 1. sessionManager.getSession()이 올바른 sessionId로 호출되는지 검증 + verify(sessionManager, times(1)).getSession(eq(testSessionId)); + + // 2. sessionManager.updateSession()이 호출되는지 검증 + verify(sessionManager, times(1)).updateSession(any(SessionInfo.class)); + + // 3. updateSession에 전달된 SessionInfo가 올바른 필드를 가지는지 검증 + verify(sessionManager).updateSession(argThat(sessionInfo -> + sessionInfo.sessionId().equals(testSessionId) && + sessionInfo.userId().equals(testUserId) && + sessionInfo.clientInfo().equals(testClientInfo) && + sessionInfo.status() == SessionStatus.CONNECTED && + !sessionInfo.lastActivityAt().isBefore(testSessionInfo.lastActivityAt()) + )); + } + + @Test + @DisplayName("세션 활동 업데이트 - 세션이 없을 경우 updateSession이 호출되지 않는지 검증") + void updateSessionActivity_WithNoSession_ShouldNotCallUpdateSession() { + // Given + when(sessionManager.getSession(testSessionId)).thenReturn(null); + + // When + webSocketSessionFacade.updateSessionActivity(testSessionId); + + // Then + // 1. sessionManager.getSession()이 호출되는지 검증 + verify(sessionManager, times(1)).getSession(eq(testSessionId)); + + // 2. sessionManager.updateSession()이 호출되지 않는지 검증 + verify(sessionManager, never()).updateSession(any()); + } + + @Test + @DisplayName("사용자 모든 세션 강제 삭제 - forceDeleteAllUserSessions() 호출 시 올바른 메서드가 호출되는지 검증") + void forceDeleteAllUserSessions_ShouldCallCorrectMethod() { + // When + webSocketSessionFacade.forceDeleteAllUserSessions(testUserId); + + // Then + // sessionManager.deleteAllUserSessions()가 올바른 userId로 호출되는지 검증 + verify(sessionManager, times(1)).deleteAllUserSessions(eq(testUserId)); + } +} 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 9a43ed4..debf52d 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 @@ -17,21 +17,22 @@ public class TestObjectFactory { // Conversation 생성 메서드들 - public static Conversation createConversation(Long userId) { + public static Conversation createConversation(UUID userId) { return Conversation.builder() .userId(userId) .build(); } public static Conversation createDefaultConversation() { - return createConversation(1L); + return createConversation(UUID.randomUUID()); + } - public static Conversation createConversationWithUserId(Long userId) { + public static Conversation createConversationWithUserId(UUID userId) { return createConversation(userId); } - public static Conversation createConversationWithId(UUID id, Long userId) { + public static Conversation createConversationWithId(UUID id, UUID userId) { Conversation conversation = Conversation.builder() .userId(userId) .build(); @@ -39,7 +40,7 @@ public static Conversation createConversationWithId(UUID id, Long userId) { return conversation; } - public static Conversation createConversationWithCreatedDate(Long userId, LocalDateTime createdDate) { + public static Conversation createConversationWithCreatedDate(UUID userId, LocalDateTime createdDate) { Conversation conversation = createConversation(userId); setCreatedDate(conversation, createdDate); return conversation; @@ -95,7 +96,7 @@ public static Message createMessageWithCreatedDate(Conversation conversation, Se } // ChatUsage 생성 메서드들 - public static ChatUsage createChatUsage(Long userId, SubscriptionType subscriptionType, Integer messageLimit) { + public static ChatUsage createChatUsage(UUID userId, SubscriptionType subscriptionType, Integer messageLimit) { return ChatUsage.builder() .userId(userId) .subscriptionType(subscriptionType) @@ -103,20 +104,20 @@ public static ChatUsage createChatUsage(Long userId, SubscriptionType subscripti .build(); } - public static ChatUsage createFreeChatUsage(Long userId) { + public static ChatUsage createFreeChatUsage(UUID userId) { return createChatUsage(userId, SubscriptionType.FREE, 100); } - public static ChatUsage createProChatUsage(Long userId) { + public static ChatUsage createProChatUsage(UUID userId) { return createChatUsage(userId, SubscriptionType.PRO, 1000); } public static ChatUsage createDefaultFreeChatUsage() { - return createFreeChatUsage(1L); + return createFreeChatUsage(UUID.randomUUID()); } public static ChatUsage createDefaultProChatUsage() { - return createProChatUsage(1L); + return createProChatUsage(UUID.randomUUID()); } @@ -151,4 +152,4 @@ public static class TestConstants { public static final Integer FREE_MESSAGE_LIMIT = 100; public static final Integer PRO_MESSAGE_LIMIT = 1000; } -} \ No newline at end of file +}