diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 76fe85e..a079a2b 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -37,26 +37,26 @@ jobs: path: | ~/.gradle/caches ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('chat-service/**/*.gradle*', 'chat-service/**/gradle-wrapper.properties') }} + key: ${{ runner.os }}-gradle-${{ hashFiles('chat_service/**/*.gradle*', 'chat_service/**/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle- - name: gradlew 실행 권한 부여 - working-directory: chat-service + working-directory: chat_service run: chmod +x ./gradlew - name: Gradle로 테스트 실행 - working-directory: chat-service + working-directory: chat_service run: ./gradlew --info test - name: 테스트 결과 리포트 uses: EnricoMi/publish-unit-test-result-action@v2 if: always() with: - files: 'chat-service/build/test-results/test/TEST-*.xml' + files: 'chat_service/build/test-results/test/TEST-*.xml' - name: 테스트 결과 게시 uses: mikepenz/action-junit-report@v4 if: always() with: - report_paths: 'chat-service/build/test-results/test/TEST-*.xml' + report_paths: 'chat_service/build/test-results/test/TEST-*.xml' diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c5f3f6b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/chat_service/build.gradle b/chat_service/build.gradle index 009d91c..19b26ef 100644 --- a/chat_service/build.gradle +++ b/chat_service/build.gradle @@ -18,9 +18,25 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter' + // Spring Web + implementation 'org.springframework.boot:spring-boot-starter-web' + // JPA + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + // Validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + // H2 + runtimeOnly 'com.h2database:h2' + // PostgreSQL + runtimeOnly 'org.postgresql:postgresql' + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + //Test testImplementation 'org.springframework.boot:spring-boot-starter-test' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testRuntimeOnly 'com.h2database:h2' } tasks.named('test') { diff --git a/chat_service/src/main/java/com/synapse/chat_service/config/JpaConfig.java b/chat_service/src/main/java/com/synapse/chat_service/config/JpaConfig.java new file mode 100644 index 0000000..b105ab4 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/config/JpaConfig.java @@ -0,0 +1,10 @@ +package com.synapse.chat_service.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfig { + +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/controller/ChatRoomController.java b/chat_service/src/main/java/com/synapse/chat_service/controller/ChatRoomController.java new file mode 100644 index 0000000..cb17044 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/controller/ChatRoomController.java @@ -0,0 +1,92 @@ +package com.synapse.chat_service.controller; + +import com.synapse.chat_service.dto.request.ChatRoomRequest; +import com.synapse.chat_service.dto.response.ChatRoomResponse; +import com.synapse.chat_service.service.ChatRoomService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/chat-rooms") +@RequiredArgsConstructor +public class ChatRoomController { + + private final ChatRoomService chatRoomService; + + /** + * 채팅방 생성 + */ + @PostMapping + public ResponseEntity createChatRoom( + @Valid @RequestBody ChatRoomRequest.Create request + ) { + ChatRoomResponse.Detail response = chatRoomService.createChatRoom(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + /** + * 채팅방 단건 조회 + */ + @GetMapping("/{chatRoomId}") + public ResponseEntity getChatRoom( + @PathVariable UUID chatRoomId + ) { + ChatRoomResponse.Detail response = chatRoomService.getChatRoom(chatRoomId); + return ResponseEntity.ok(response); + } + + /** + * 사용자별 채팅방 목록 조회 (페이징) + */ + @GetMapping + public ResponseEntity> getChatRoomsByUserId( + @RequestParam Long userId, + @PageableDefault(size = 20, sort = "createdDate", direction = Sort.Direction.DESC) Pageable pageable + ) { + Page response = chatRoomService.getChatRoomsByUserId(userId, pageable); + return ResponseEntity.ok(response); + } + + /** + * 채팅방 제목 검색 + */ + @GetMapping("/search") + public ResponseEntity> searchChatRooms( + @RequestParam Long userId, + @RequestParam String keyword + ) { + List response = chatRoomService.searchChatRooms(userId, keyword); + return ResponseEntity.ok(response); + } + + /** + * 채팅방 수정 + */ + @PutMapping("/{chatRoomId}") + public ResponseEntity updateChatRoom( + @PathVariable UUID chatRoomId, + @Valid @RequestBody ChatRoomRequest.Update request + ) { + ChatRoomResponse.Detail response = chatRoomService.updateChatRoom(chatRoomId, request); + return ResponseEntity.ok(response); + } + + /** + * 채팅방 삭제 + */ + @DeleteMapping("/{chatRoomId}") + public ResponseEntity deleteChatRoom(@PathVariable UUID chatRoomId) { + chatRoomService.deleteChatRoom(chatRoomId); + return ResponseEntity.noContent().build(); + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/controller/MessageController.java b/chat_service/src/main/java/com/synapse/chat_service/controller/MessageController.java new file mode 100644 index 0000000..c0ed50c --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/controller/MessageController.java @@ -0,0 +1,103 @@ +package com.synapse.chat_service.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.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/messages") +@RequiredArgsConstructor +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); + } + + /** + * 채팅방별 메시지 목록 조회 (시간순 정렬) + */ + @GetMapping("/chat-room/{chatRoomId}") + public ResponseEntity> getMessagesByChatRoomId( + @PathVariable UUID chatRoomId + ) { + List response = messageService.getMessagesByChatRoomId(chatRoomId); + return ResponseEntity.ok(response); + } + + /** + * 채팅방별 메시지 목록 조회 (페이징, 시간순 정렬) + */ + @GetMapping("/chat-room/{chatRoomId}/paging") + public ResponseEntity> getMessagesByChatRoomIdWithPaging( + @PathVariable UUID chatRoomId, + @PageableDefault(size = 50, sort = "createdDate", direction = Sort.Direction.ASC) Pageable pageable + ) { + Page response = messageService.getMessagesByChatRoomIdWithPaging(chatRoomId, pageable); + return ResponseEntity.ok(response); + } + + /** + * 채팅방별 메시지 목록 조회 (페이징, 최신순 정렬) + */ + @GetMapping("/chat-room/{chatRoomId}/recent") + public ResponseEntity> getMessagesRecentFirst( + @PathVariable UUID chatRoomId, + @PageableDefault(size = 50, sort = "createdDate", direction = Sort.Direction.DESC) Pageable pageable + ) { + Page response = messageService.getMessagesRecentFirst(chatRoomId, pageable); + return ResponseEntity.ok(response); + } + + /** + * 메시지 내용 검색 + */ + @GetMapping("/chat-room/{chatRoomId}/search") + public ResponseEntity> searchMessages( + @PathVariable UUID chatRoomId, + @RequestParam String keyword + ) { + List response = messageService.searchMessages(chatRoomId, keyword); + return ResponseEntity.ok(response); + } + + /** + * 메시지 삭제 + */ + @DeleteMapping("/{messageId}") + public ResponseEntity deleteMessage(@PathVariable Long messageId) { + messageService.deleteMessage(messageId); + return ResponseEntity.noContent().build(); + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/common/BaseEntity.java b/chat_service/src/main/java/com/synapse/chat_service/domain/common/BaseEntity.java new file mode 100644 index 0000000..904e824 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/domain/common/BaseEntity.java @@ -0,0 +1,23 @@ +package com.synapse.chat_service.domain.common; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity extends BaseTimeEntity { + + @CreatedBy + @Column(updatable = false) + private String createdBy; + + @LastModifiedBy + private String lastModifiedBy; +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/common/BaseTimeEntity.java b/chat_service/src/main/java/com/synapse/chat_service/domain/common/BaseTimeEntity.java new file mode 100644 index 0000000..63a4da7 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/domain/common/BaseTimeEntity.java @@ -0,0 +1,24 @@ +package com.synapse.chat_service.domain.common; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdDate; + + @LastModifiedDate + private LocalDateTime updatedDate; +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/ChatRoom.java b/chat_service/src/main/java/com/synapse/chat_service/domain/entity/ChatRoom.java new file mode 100644 index 0000000..c9dcd69 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/domain/entity/ChatRoom.java @@ -0,0 +1,61 @@ +package com.synapse.chat_service.domain.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import com.synapse.chat_service.exception.commonexception.ValidException; +import com.synapse.chat_service.exception.domain.ExceptionType; + +import com.synapse.chat_service.domain.common.BaseTimeEntity; + +@Entity +@Table(name = "chat_rooms") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatRoom extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "chat_room_id", columnDefinition = "UUID") + private UUID id; + + @NotNull + @Column(name = "user_id", nullable = false) + private Long userId; + + @NotBlank + @Column(name = "title", nullable = false, length = 255) + private String title; + + @OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL, orphanRemoval = true) + private List messages = new ArrayList<>(); + + @Builder + public ChatRoom(Long userId, String title) { + this.userId = userId; + this.title = title; + } + + public void updateTitle(String newTitle) { + validateTitle(newTitle); + this.title = newTitle.trim(); + } + + private void validateTitle(String title) { + if (title == null || title.trim().isEmpty()) { + throw new ValidException(ExceptionType.INVALID_INPUT_VALUE, "채팅방 제목은 비어있을 수 없습니다."); + } + if (title.trim().length() > 255) { + throw new ValidException(ExceptionType.INVALID_INPUT_VALUE, "채팅방 제목은 255자를 초과할 수 없습니다."); + } + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/ChatUsage.java b/chat_service/src/main/java/com/synapse/chat_service/domain/entity/ChatUsage.java new file mode 100644 index 0000000..4a78234 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/domain/entity/ChatUsage.java @@ -0,0 +1,48 @@ +package com.synapse.chat_service.domain.entity; + +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; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "chat_usages") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatUsage extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false, unique = true) + @NotNull + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "subscription_type", nullable = false) + @NotNull + private SubscriptionType subscriptionType; + + @Column(name = "message_count", nullable = false) + @Min(0) + private Integer messageCount = 0; + + @Column(name = "message_limit", nullable = false) + @Min(0) + private Integer messageLimit; + + @Builder + public ChatUsage(Long userId, SubscriptionType subscriptionType, Integer messageLimit) { + this.userId = userId; + this.subscriptionType = subscriptionType; + this.messageLimit = messageLimit; + this.messageCount = 0; + } +} 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 new file mode 100644 index 0000000..ccf80f9 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/domain/entity/Message.java @@ -0,0 +1,66 @@ +package com.synapse.chat_service.domain.entity; + +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; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "messages") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Message extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id", nullable = false) + @NotNull + private ChatRoom chatRoom; + + @Enumerated(EnumType.STRING) + @Column(name = "sender_type", nullable = false) + @NotNull + private SenderType senderType; + + @NotBlank + @Column(name = "content", nullable = false, columnDefinition = "TEXT") + private String content; + + @Builder + public Message(ChatRoom chatRoom, SenderType senderType, String content) { + validateContent(content); + this.chatRoom = chatRoom; + 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/entity/User.java b/chat_service/src/main/java/com/synapse/chat_service/domain/entity/User.java new file mode 100644 index 0000000..c7a7c89 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/domain/entity/User.java @@ -0,0 +1,42 @@ +package com.synapse.chat_service.domain.entity; + +import com.synapse.chat_service.domain.common.BaseTimeEntity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseTimeEntity { + + @Id + @Column(name = "id", columnDefinition = "BIGINT") + private Long id; + + @NotBlank + @Column(name = "username", nullable = false, unique = true, length = 50) + private String username; + + @NotBlank + @Column(name = "email", nullable = false, unique = true, length = 100) + private String email; + + @OneToMany(mappedBy = "userId", cascade = CascadeType.ALL, orphanRemoval = true) + private List chatRooms = new ArrayList<>(); + + @Builder + public User(Long id, String username, String email) { + this.id = id; + this.username = username; + this.email = email; + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/enums/SenderType.java b/chat_service/src/main/java/com/synapse/chat_service/domain/entity/enums/SenderType.java new file mode 100644 index 0000000..5483993 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/domain/entity/enums/SenderType.java @@ -0,0 +1,6 @@ +package com.synapse.chat_service.domain.entity.enums; + +public enum SenderType { + USER, + ASSISTANT +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/enums/SubscriptionType.java b/chat_service/src/main/java/com/synapse/chat_service/domain/entity/enums/SubscriptionType.java new file mode 100644 index 0000000..44aa239 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/domain/entity/enums/SubscriptionType.java @@ -0,0 +1,6 @@ +package com.synapse.chat_service.domain.entity.enums; + +public enum SubscriptionType { + FREE, + PRO +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ChatRoomRepository.java b/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ChatRoomRepository.java new file mode 100644 index 0000000..7bd1108 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ChatRoomRepository.java @@ -0,0 +1,22 @@ +package com.synapse.chat_service.domain.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.synapse.chat_service.domain.entity.ChatRoom; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface ChatRoomRepository extends JpaRepository { + + Page findByUserIdOrderByCreatedDateDesc(Long userId, Pageable pageable); + + @Query("SELECT cr FROM ChatRoom cr WHERE cr.userId = :userId AND cr.title LIKE %:keyword%") + List findByUserIdAndTitleContaining(@Param("userId") Long userId, @Param("keyword") String keyword); +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ChatUsageRepository.java b/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ChatUsageRepository.java new file mode 100644 index 0000000..e06b404 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ChatUsageRepository.java @@ -0,0 +1,11 @@ +package com.synapse.chat_service.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.synapse.chat_service.domain.entity.ChatUsage; + +@Repository +public interface ChatUsageRepository extends JpaRepository { + +} 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 new file mode 100644 index 0000000..ca139e4 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/domain/repository/MessageRepository.java @@ -0,0 +1,28 @@ +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 java.util.List; +import java.util.UUID; + +@Repository +public interface MessageRepository extends JpaRepository { + + List findByChatRoomIdOrderByCreatedDateAsc(UUID chatRoomId); + + Page findByChatRoomIdOrderByCreatedDateAsc(UUID chatRoomId, Pageable pageable); + + Page findByChatRoomIdOrderByCreatedDateDesc(UUID chatRoomId, Pageable pageable); + + @Query("SELECT m FROM Message m WHERE m.chatRoom.id = :chatRoomId AND m.content LIKE %:keyword%") + List findByChatRoomIdAndContentContaining(@Param("chatRoomId") UUID chatRoomId, @Param("keyword") String keyword); + + long countByChatRoomId(UUID chatRoomId); +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/repository/UserRepository.java b/chat_service/src/main/java/com/synapse/chat_service/domain/repository/UserRepository.java new file mode 100644 index 0000000..45626f5 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/domain/repository/UserRepository.java @@ -0,0 +1,19 @@ +package com.synapse.chat_service.domain.repository; + +import com.synapse.chat_service.domain.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + + Optional findByUsername(String username); + + Optional findByEmail(String email); + + boolean existsByUsername(String username); + + boolean existsByEmail(String email); +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/dto/request/ChatRoomRequest.java b/chat_service/src/main/java/com/synapse/chat_service/dto/request/ChatRoomRequest.java new file mode 100644 index 0000000..d40449a --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/dto/request/ChatRoomRequest.java @@ -0,0 +1,23 @@ +package com.synapse.chat_service.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public class ChatRoomRequest { + + public record Create( + @NotNull(message = "사용자 ID는 필수입니다.") + Long userId, + + @NotBlank(message = "채팅방 제목은 필수입니다.") + @Size(min = 1, max = 255, message = "채팅방 제목은 1자 이상 255자 이하여야 합니다.") + String title + ) {} + + public record Update( + @NotBlank(message = "채팅방 제목은 필수입니다.") + @Size(min = 1, max = 255, message = "채팅방 제목은 1자 이상 255자 이하여야 합니다.") + String title + ) {} +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/dto/request/MessageRequest.java b/chat_service/src/main/java/com/synapse/chat_service/dto/request/MessageRequest.java new file mode 100644 index 0000000..a2d8c0e --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/dto/request/MessageRequest.java @@ -0,0 +1,26 @@ +package com.synapse.chat_service.dto.request; + +import java.util.UUID; + +import com.synapse.chat_service.domain.entity.enums.SenderType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public class MessageRequest { + + public record Create( + @NotNull(message = "채팅방 ID는 필수입니다.") + UUID chatRoomId, + + @NotNull(message = "발신자 타입은 필수입니다.") + SenderType senderType, + + @NotBlank(message = "메시지 내용은 필수입니다.") + String content + ) {} + + public record Update( + @NotBlank(message = "메시지 내용은 필수입니다.") + String content + ) {} +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/dto/response/ChatRoomResponse.java b/chat_service/src/main/java/com/synapse/chat_service/dto/response/ChatRoomResponse.java new file mode 100644 index 0000000..cdc57a6 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/dto/response/ChatRoomResponse.java @@ -0,0 +1,62 @@ +package com.synapse.chat_service.dto.response; + +import com.synapse.chat_service.domain.entity.ChatRoom; + +import java.time.LocalDateTime; +import java.util.UUID; + +public class ChatRoomResponse { + + /** + * 채팅방 목록 조회용 간단한 정보 + * 목록에서 필요한 최소한의 정보만 포함 + */ + public record Simple( + UUID id, + String title, + LocalDateTime createdDate, + long messageCount + ) { + public static Simple from(ChatRoom chatRoom, long messageCount) { + return new Simple( + chatRoom.getId(), + chatRoom.getTitle(), + chatRoom.getCreatedDate(), + messageCount + ); + } + + public static Simple from(ChatRoom chatRoom) { + return new Simple( + chatRoom.getId(), + chatRoom.getTitle(), + chatRoom.getCreatedDate(), + chatRoom.getMessages().size() + ); + } + } + + /** + * 채팅방 상세 조회용 완전한 정보 + * 단일 채팅방 조회 시 필요한 모든 정보 포함 + */ + public record Detail( + UUID id, + Long userId, + String title, + LocalDateTime createdDate, + LocalDateTime updatedDate, + long messageCount + ) { + public static Detail from(ChatRoom chatRoom, long messageCount) { + return new Detail( + chatRoom.getId(), + chatRoom.getUserId(), + chatRoom.getTitle(), + chatRoom.getCreatedDate(), + chatRoom.getUpdatedDate(), + messageCount + ); + } + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/dto/response/MessageResponse.java b/chat_service/src/main/java/com/synapse/chat_service/dto/response/MessageResponse.java new file mode 100644 index 0000000..63fb3cb --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/dto/response/MessageResponse.java @@ -0,0 +1,48 @@ +package com.synapse.chat_service.dto.response; + +import com.synapse.chat_service.domain.entity.Message; +import com.synapse.chat_service.domain.entity.enums.SenderType; + +import java.time.LocalDateTime; +import java.util.UUID; + +public class MessageResponse { + + public record Simple( + Long id, + UUID chatRoomId, + SenderType senderType, + String content, + LocalDateTime createdDate + ) { + public static Simple from(Message message) { + return new Simple( + message.getId(), + message.getChatRoom().getId(), + message.getSenderType(), + message.getContent(), + message.getCreatedDate() + ); + } + } + + public record Detail( + Long id, + UUID chatRoomId, + SenderType senderType, + String content, + LocalDateTime createdDate, + LocalDateTime updatedDate + ) { + public static Detail from(Message message) { + return new Detail( + message.getId(), + message.getChatRoom().getId(), + message.getSenderType(), + message.getContent(), + message.getCreatedDate(), + message.getUpdatedDate() + ); + } + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/BadRequestException.java b/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/BadRequestException.java new file mode 100644 index 0000000..da7a8ee --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/BadRequestException.java @@ -0,0 +1,22 @@ +package com.synapse.chat_service.exception.commonexception; + +import com.synapse.chat_service.exception.domain.ExceptionType; + +public class BadRequestException extends BusinessException { + + public BadRequestException(ExceptionType exceptionType) { + super(exceptionType); + } + + public BadRequestException(ExceptionType exceptionType, String customMessage) { + super(exceptionType, customMessage); + } + + public BadRequestException(ExceptionType exceptionType, Throwable cause) { + super(exceptionType, cause); + } + + public BadRequestException(ExceptionType exceptionType, String customMessage, Throwable cause) { + super(exceptionType, customMessage, cause); + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/BusinessException.java b/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/BusinessException.java new file mode 100644 index 0000000..cbd9b74 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/BusinessException.java @@ -0,0 +1,31 @@ +package com.synapse.chat_service.exception.commonexception; + +import com.synapse.chat_service.exception.domain.ExceptionType; + +import lombok.Getter; + +@Getter +public abstract class BusinessException extends RuntimeException { + + private final ExceptionType exceptionType; + + public BusinessException(ExceptionType exceptionType) { + super(exceptionType.getMessage()); + this.exceptionType = exceptionType; + } + + public BusinessException(ExceptionType exceptionType, String customMessage) { + super(customMessage); + this.exceptionType = exceptionType; + } + + public BusinessException(ExceptionType exceptionType, Throwable cause) { + super(exceptionType.getMessage(), cause); + this.exceptionType = exceptionType; + } + + public BusinessException(ExceptionType exceptionType, String customMessage, Throwable cause) { + super(customMessage, cause); + this.exceptionType = exceptionType; + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/NotFoundException.java b/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/NotFoundException.java new file mode 100644 index 0000000..4063f2c --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/NotFoundException.java @@ -0,0 +1,14 @@ +package com.synapse.chat_service.exception.commonexception; + +import com.synapse.chat_service.exception.domain.ExceptionType; + +public class NotFoundException extends BusinessException { + + public NotFoundException(ExceptionType exceptionType) { + super(exceptionType); + } + + public NotFoundException(ExceptionType exceptionType, String customMessage) { + super(exceptionType, customMessage); + } +} \ No newline at end of file diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/ValidException.java b/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/ValidException.java new file mode 100644 index 0000000..4f14fb4 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/ValidException.java @@ -0,0 +1,13 @@ +package com.synapse.chat_service.exception.commonexception; + +import com.synapse.chat_service.exception.domain.ExceptionType; + +public class ValidException extends BusinessException { + public ValidException(ExceptionType exceptionType) { + super(exceptionType); + } + + public ValidException(ExceptionType exceptionType, String customMessage) { + super(exceptionType, customMessage); + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/domain/ExceptionType.java b/chat_service/src/main/java/com/synapse/chat_service/exception/domain/ExceptionType.java new file mode 100644 index 0000000..b709aa4 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/exception/domain/ExceptionType.java @@ -0,0 +1,57 @@ +package com.synapse.chat_service.exception.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ExceptionType { + + // 400 Bad Request + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "E001", "잘못된 입력값입니다."), + MISSING_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST, "E002", "필수 요청 파라미터가 누락되었습니다."), + INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "E003", "잘못된 타입의 값입니다."), + + // 401 Unauthorized + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "E101", "인증이 필요합니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "E102", "유효하지 않은 토큰입니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "E103", "만료된 토큰입니다."), + + // 403 Forbidden + ACCESS_DENIED(HttpStatus.FORBIDDEN, "E201", "접근이 거부되었습니다."), + INSUFFICIENT_PERMISSION(HttpStatus.FORBIDDEN, "E202", "권한이 부족합니다."), + + // 404 Not Found + CHAT_ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "E301", "채팅방을 찾을 수 없습니다."), + MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "E302", "메시지를 찾을 수 없습니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "E303", "사용자를 찾을 수 없습니다."), + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "E304", "요청한 리소스를 찾을 수 없습니다."), + + // 409 Conflict + DUPLICATE_RESOURCE(HttpStatus.CONFLICT, "E401", "이미 존재하는 리소스입니다."), + DUPLICATE_USERNAME(HttpStatus.CONFLICT, "E402", "이미 사용 중인 사용자명입니다."), + DUPLICATE_EMAIL(HttpStatus.CONFLICT, "E403", "이미 사용 중인 이메일입니다."), + + // 422 Unprocessable Entity + BUSINESS_LOGIC_ERROR(HttpStatus.UNPROCESSABLE_ENTITY, "E501", "비즈니스 로직 오류가 발생했습니다."), + INVALID_STATE(HttpStatus.UNPROCESSABLE_ENTITY, "E502", "유효하지 않은 상태입니다."), + + // 429 Too Many Requests + TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "E601", "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), + + // 500 Internal Server Error + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E901", "서버 내부 오류가 발생했습니다."), + DATABASE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E902", "데이터베이스 오류가 발생했습니다."), + EXTERNAL_SERVICE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E903", "외부 서비스 연동 중 오류가 발생했습니다."), + + // 502 Bad Gateway + BAD_GATEWAY(HttpStatus.BAD_GATEWAY, "E951", "게이트웨이 오류가 발생했습니다."), + + // 503 Service Unavailable + SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "E961", "서비스를 사용할 수 없습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/dto/ExceptionResponse.java b/chat_service/src/main/java/com/synapse/chat_service/exception/dto/ExceptionResponse.java new file mode 100644 index 0000000..d2452ae --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/exception/dto/ExceptionResponse.java @@ -0,0 +1,45 @@ +package com.synapse.chat_service.exception.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.synapse.chat_service.exception.commonexception.BusinessException; +import com.synapse.chat_service.exception.domain.ExceptionType; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class ExceptionResponse { + + private final String code; + private final String message; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private final LocalDateTime timestamp; + + public static ExceptionResponse from(BusinessException exception) { + return ExceptionResponse.builder() + .code(exception.getExceptionType().getCode()) + .message(exception.getMessage()) + .timestamp(LocalDateTime.now()) + .build(); + } + + public static ExceptionResponse of(ExceptionType exceptionType) { + return ExceptionResponse.builder() + .code(exceptionType.getCode()) + .message(exceptionType.getMessage()) + .timestamp(LocalDateTime.now()) + .build(); + } + + public static ExceptionResponse of(ExceptionType exceptionType, String customMessage) { + return ExceptionResponse.builder() + .code(exceptionType.getCode()) + .message(customMessage) + .timestamp(LocalDateTime.now()) + .build(); + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/service/GlobalExceptionHandler.java b/chat_service/src/main/java/com/synapse/chat_service/exception/service/GlobalExceptionHandler.java new file mode 100644 index 0000000..2519b56 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/exception/service/GlobalExceptionHandler.java @@ -0,0 +1,154 @@ +package com.synapse.chat_service.exception.service; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +import com.synapse.chat_service.exception.commonexception.BadRequestException; +import com.synapse.chat_service.exception.commonexception.BusinessException; +import com.synapse.chat_service.exception.domain.ExceptionType; +import com.synapse.chat_service.exception.dto.ExceptionResponse; + +import java.util.stream.Collectors; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + // 로그 포맷 상수 + private static final String INFO_LOG_FORMAT = "INFO - {} {} - Status: {} - Exception: {} - Message: {}"; + private static final String WARN_LOG_FORMAT = "WARN - {} {} - Status: {} - Exception: {} - Message: {}"; + private static final String ERROR_LOG_FORMAT = "ERROR - {} {} - Status: {} - Exception: {} - Message: {}"; + + /** + * 비즈니스 예외 처리 + */ + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException e, HttpServletRequest request) { + logWarn(request, e, e.getExceptionType().getStatus()); + return ResponseEntity.status(e.getExceptionType().getStatus()).body(ExceptionResponse.from(e)); + } + + /** + * BadRequest 예외 처리 + */ + @ExceptionHandler(BadRequestException.class) + public ResponseEntity handleBadRequestException(BadRequestException e, HttpServletRequest request) { + logWarn(request, e, e.getExceptionType().getStatus()); + return ResponseEntity.status(e.getExceptionType().getStatus()).body(ExceptionResponse.from(e)); + } + + /** + * Validation 예외 처리 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException e, HttpServletRequest request) { + String errorMessage = e.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + + logInfo(request, e, HttpStatus.BAD_REQUEST); + + ExceptionResponse response = ExceptionResponse.of(ExceptionType.INVALID_INPUT_VALUE, errorMessage); + return ResponseEntity.badRequest().body(response); + } + + /** + * 필수 파라미터 누락 예외 처리 + */ + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingParameterException(MissingServletRequestParameterException e, HttpServletRequest request) { + // ExceptionType에 정의된 기본 메시지에 구체적인 파라미터 정보 추가 + String detailedMessage = String.format("%s (파라미터: %s)", + ExceptionType.MISSING_REQUEST_PARAMETER.getMessage(), + e.getParameterName()); + + logInfo(request, e, HttpStatus.BAD_REQUEST); + return ResponseEntity.badRequest() + .body(ExceptionResponse.of(ExceptionType.MISSING_REQUEST_PARAMETER, detailedMessage)); + } + + /** + * 타입 불일치 예외 처리 + */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleTypeMismatchException(MethodArgumentTypeMismatchException e, HttpServletRequest request) { + // ExceptionType에 정의된 기본 메시지에 구체적인 파라미터 정보 추가 + String detailedMessage = String.format("%s (파라미터: %s)", + ExceptionType.INVALID_TYPE_VALUE.getMessage(), + e.getName()); + + logInfo(request, e, HttpStatus.BAD_REQUEST); + return ResponseEntity.badRequest() + .body(ExceptionResponse.of(ExceptionType.INVALID_TYPE_VALUE, detailedMessage)); + } + + /** + * IllegalArgumentException 처리 + */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e, HttpServletRequest request) { + logWarn(request, e, HttpStatus.BAD_REQUEST); + return ResponseEntity.badRequest() + .body(ExceptionResponse.of(ExceptionType.INVALID_INPUT_VALUE, e.getMessage())); + } + + /** + * 정적 리소스 없음 예외 처리 (INFO 레벨로 처리) + */ + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFoundException(NoResourceFoundException e, HttpServletRequest request) { + logInfo(request, e, HttpStatus.NOT_FOUND); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ExceptionResponse.of(ExceptionType.RESOURCE_NOT_FOUND)); + } + + /** + * 일반적인 예외 처리 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneralException(Exception e, HttpServletRequest request) { + logError(request, e, HttpStatus.INTERNAL_SERVER_ERROR); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ExceptionResponse.of(ExceptionType.INTERNAL_SERVER_ERROR)); + } + + // 로깅 메서드들 + private void logInfo(HttpServletRequest request, Exception e, HttpStatus status) { + log.info(INFO_LOG_FORMAT, + request.getMethod(), + request.getRequestURI(), + status.value(), + e.getClass().getSimpleName(), + e.getMessage()); + } + + + + private void logWarn(HttpServletRequest request, Exception e, HttpStatus status) { + log.warn(WARN_LOG_FORMAT, + request.getMethod(), + request.getRequestURI(), + status.value(), + e.getClass().getSimpleName(), + e.getMessage()); + } + + private void logError(HttpServletRequest request, Exception e, HttpStatus status) { + log.error(ERROR_LOG_FORMAT, + request.getMethod(), + request.getRequestURI(), + status.value(), + e.getClass().getSimpleName(), + e.getMessage(), + e); + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/service/ChatRoomService.java b/chat_service/src/main/java/com/synapse/chat_service/service/ChatRoomService.java new file mode 100644 index 0000000..cee1ab7 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/service/ChatRoomService.java @@ -0,0 +1,87 @@ +package com.synapse.chat_service.service; + +import com.synapse.chat_service.domain.entity.ChatRoom; +import com.synapse.chat_service.domain.repository.ChatRoomRepository; +import com.synapse.chat_service.domain.repository.MessageRepository; +import com.synapse.chat_service.dto.request.ChatRoomRequest; +import com.synapse.chat_service.dto.response.ChatRoomResponse; +import com.synapse.chat_service.exception.commonexception.NotFoundException; +import com.synapse.chat_service.exception.domain.ExceptionType; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatRoomService { + + private final ChatRoomRepository chatRoomRepository; + private final MessageRepository messageRepository; + + @Transactional + public ChatRoomResponse.Detail createChatRoom(ChatRoomRequest.Create request) { + ChatRoom chatRoom = ChatRoom.builder() + .userId(request.userId()) + .title(request.title()) + .build(); + + ChatRoom savedChatRoom = chatRoomRepository.save(chatRoom); + long messageCount = messageRepository.countByChatRoomId(savedChatRoom.getId()); + return ChatRoomResponse.Detail.from(savedChatRoom, messageCount); + } + + public ChatRoomResponse.Detail getChatRoom(UUID chatRoomId) { + ChatRoom chatRoom = findChatRoomById(chatRoomId); + long messageCount = messageRepository.countByChatRoomId(chatRoomId); + return ChatRoomResponse.Detail.from(chatRoom, messageCount); + } + + public Page getChatRoomsByUserId(Long userId, Pageable pageable) { + Page chatRooms = chatRoomRepository.findByUserIdOrderByCreatedDateDesc(userId, pageable); + return chatRooms.map(chatRoom -> { + long messageCount = messageRepository.countByChatRoomId(chatRoom.getId()); + return ChatRoomResponse.Simple.from(chatRoom, messageCount); + }); + } + + @Transactional + public ChatRoomResponse.Detail updateChatRoom(UUID chatRoomId, ChatRoomRequest.Update request) { + ChatRoom chatRoom = findChatRoomById(chatRoomId); + + // 명확한 의도를 가진 메소드를 통한 상태 변경 (도메인 로직에서 유효성 검증 포함) + chatRoom.updateTitle(request.title()); + + ChatRoom updatedChatRoom = chatRoomRepository.save(chatRoom); + long messageCount = messageRepository.countByChatRoomId(chatRoomId); + return ChatRoomResponse.Detail.from(updatedChatRoom, messageCount); + } + + @Transactional + public void deleteChatRoom(UUID chatRoomId) { + ChatRoom chatRoom = findChatRoomById(chatRoomId); + chatRoomRepository.delete(chatRoom); + } + + public List searchChatRooms(Long userId, String keyword) { + List chatRooms = chatRoomRepository.findByUserIdAndTitleContaining(userId, keyword); + return chatRooms.stream() + .map(chatRoom -> { + long messageCount = messageRepository.countByChatRoomId(chatRoom.getId()); + return ChatRoomResponse.Simple.from(chatRoom, messageCount); + }) + .collect(Collectors.toList()); + } + + private ChatRoom findChatRoomById(UUID chatRoomId) { + return chatRoomRepository.findById(chatRoomId) + .orElseThrow(() -> new NotFoundException(ExceptionType.CHAT_ROOM_NOT_FOUND, "ID: " + chatRoomId)); + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/service/MessageService.java b/chat_service/src/main/java/com/synapse/chat_service/service/MessageService.java new file mode 100644 index 0000000..dee192b --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/service/MessageService.java @@ -0,0 +1,107 @@ +package com.synapse.chat_service.service; + +import com.synapse.chat_service.domain.entity.ChatRoom; +import com.synapse.chat_service.domain.entity.Message; +import com.synapse.chat_service.domain.repository.ChatRoomRepository; +import com.synapse.chat_service.domain.repository.MessageRepository; +import com.synapse.chat_service.dto.request.MessageRequest; +import com.synapse.chat_service.dto.response.MessageResponse; +import com.synapse.chat_service.exception.commonexception.NotFoundException; +import com.synapse.chat_service.exception.domain.ExceptionType; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MessageService { + + private final MessageRepository messageRepository; + private final ChatRoomRepository chatRoomRepository; + + @Transactional + public MessageResponse.Detail createMessage(MessageRequest.Create request) { + ChatRoom chatRoom = findChatRoomById(request.chatRoomId()); + + Message message = Message.builder() + .chatRoom(chatRoom) + .senderType(request.senderType()) + .content(request.content()) + .build(); + + Message savedMessage = messageRepository.save(message); + return MessageResponse.Detail.from(savedMessage); + } + + public MessageResponse.Detail getMessage(Long messageId) { + Message message = findMessageById(messageId); + return MessageResponse.Detail.from(message); + } + + public List getMessagesByChatRoomId(UUID chatRoomId) { + // 채팅방 존재 여부 확인 + findChatRoomById(chatRoomId); + + List messages = messageRepository.findByChatRoomIdOrderByCreatedDateAsc(chatRoomId); + return messages.stream() + .map(MessageResponse.Simple::from) + .collect(Collectors.toList()); + } + + public Page getMessagesByChatRoomIdWithPaging(UUID chatRoomId, Pageable pageable) { + // 채팅방 존재 여부 확인 + findChatRoomById(chatRoomId); + + Page messages = messageRepository.findByChatRoomIdOrderByCreatedDateAsc(chatRoomId, pageable); + return messages.map(MessageResponse.Simple::from); + } + + public Page getMessagesRecentFirst(UUID chatRoomId, Pageable pageable) { + // 채팅방 존재 여부 확인 + findChatRoomById(chatRoomId); + + Page messages = messageRepository.findByChatRoomIdOrderByCreatedDateDesc(chatRoomId, pageable); + return messages.map(MessageResponse.Simple::from); + } + + public List searchMessages(UUID chatRoomId, String keyword) { + // 채팅방 존재 여부 확인 + findChatRoomById(chatRoomId); + + List messages = messageRepository.findByChatRoomIdAndContentContaining(chatRoomId, keyword); + return messages.stream() + .map(MessageResponse.Simple::from) + .collect(Collectors.toList()); + } + + @Transactional + public void deleteMessage(Long messageId) { + Message message = findMessageById(messageId); + messageRepository.delete(message); + } + + public long getMessageCount(UUID chatRoomId) { + // 채팅방 존재 여부 확인 + findChatRoomById(chatRoomId); + + return messageRepository.countByChatRoomId(chatRoomId); + } + + private ChatRoom findChatRoomById(UUID chatRoomId) { + return chatRoomRepository.findById(chatRoomId) + .orElseThrow(() -> new NotFoundException(ExceptionType.CHAT_ROOM_NOT_FOUND, "ID: " + chatRoomId)); + } + + private Message findMessageById(Long messageId) { + return messageRepository.findById(messageId) + .orElseThrow(() -> new NotFoundException(ExceptionType.MESSAGE_NOT_FOUND, "ID: " + messageId)); + } +} diff --git a/chat_service/src/main/resources/application-local.yml b/chat_service/src/main/resources/application-local.yml new file mode 100644 index 0000000..0622468 --- /dev/null +++ b/chat_service/src/main/resources/application-local.yml @@ -0,0 +1,26 @@ +spring: + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://${local-db.postgres.host}:${local-db.postgres.port}/${local-db.postgres.name} + username: ${local-db.postgres.username} + password: ${local-db.postgres.password} + + jpa: + properties: + hibernate: + format: + sql: true + highlight: + sql: true + hbm2ddl: + auto: create + dialect: org.hibernate.dialect.PostgreSQLDialect + open-in-view: false + show-sql: true + +logging: + level: + org: + hibernate: + type: info + level: info diff --git a/chat_service/src/main/resources/application.properties b/chat_service/src/main/resources/application.properties deleted file mode 100644 index 9c5582d..0000000 --- a/chat_service/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=chat_service diff --git a/chat_service/src/main/resources/application.yml b/chat_service/src/main/resources/application.yml new file mode 100644 index 0000000..8138393 --- /dev/null +++ b/chat_service/src/main/resources/application.yml @@ -0,0 +1,16 @@ +server: + port: 1003 + +spring: + main: + web-application-type: servlet + + profiles: + default: local + + application: + name: chat_service + + config: + import: + - security/application-db.yml diff --git a/chat_service/src/main/resources/security/application-db.yml b/chat_service/src/main/resources/security/application-db.yml new file mode 100644 index 0000000..a4294ff --- /dev/null +++ b/chat_service/src/main/resources/security/application-db.yml @@ -0,0 +1,7 @@ +local-db: + postgres: + host: localhost + port: 5436 + name: chat-service + username: donghyeon + password: adzc1973 diff --git a/chat_service/src/test/java/com/synapse/chat_service/controller/ChatRoomControllerTest.java b/chat_service/src/test/java/com/synapse/chat_service/controller/ChatRoomControllerTest.java new file mode 100644 index 0000000..4f2394e --- /dev/null +++ b/chat_service/src/test/java/com/synapse/chat_service/controller/ChatRoomControllerTest.java @@ -0,0 +1,402 @@ +package com.synapse.chat_service.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.synapse.chat_service.dto.request.ChatRoomRequest; +import com.synapse.chat_service.dto.response.ChatRoomResponse; +import com.synapse.chat_service.service.ChatRoomService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@DisplayName("ChatRoomController 통합 테스트") +class ChatRoomControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private ChatRoomService chatRoomService; + + @Nested + @DisplayName("POST /api/v1/chat-rooms - 채팅방 생성") + class CreateChatRoom { + + @Test + @DisplayName("성공: 유효한 요청으로 채팅방 생성") + void createChatRoom_Success() throws Exception { + // given + ChatRoomRequest.Create request = new ChatRoomRequest.Create(1L, "테스트 채팅방"); + + // when & then + mockMvc.perform(post("/api/v1/chat-rooms") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.userId").value(1L)) + .andExpect(jsonPath("$.title").value("테스트 채팅방")) + .andExpect(jsonPath("$.createdDate").exists()) + .andExpect(jsonPath("$.updatedDate").exists()) + .andExpect(jsonPath("$.messageCount").value(0)); + } + + @Test + @DisplayName("실패: userId가 null인 경우") + void createChatRoom_Fail_UserIdNull() throws Exception { + // given + ChatRoomRequest.Create request = new ChatRoomRequest.Create(null, "테스트 채팅방"); + + // when & then + mockMvc.perform(post("/api/v1/chat-rooms") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("실패: title이 비어있는 경우") + void createChatRoom_Fail_TitleBlank() throws Exception { + // given + ChatRoomRequest.Create request = new ChatRoomRequest.Create(1L, ""); + + // when & then + mockMvc.perform(post("/api/v1/chat-rooms") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("실패: title이 255자를 초과하는 경우") + void createChatRoom_Fail_TitleTooLong() throws Exception { + // given + String longTitle = "a".repeat(256); + ChatRoomRequest.Create request = new ChatRoomRequest.Create(1L, longTitle); + + // when & then + mockMvc.perform(post("/api/v1/chat-rooms") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("GET /api/v1/chat-rooms/{chatRoomId} - 채팅방 단건 조회") + class GetChatRoom { + + @Test + @DisplayName("성공: 존재하는 채팅방 조회") + void getChatRoom_Success() throws Exception { + // given + ChatRoomRequest.Create createRequest = new ChatRoomRequest.Create(1L, "테스트 채팅방"); + ChatRoomResponse.Detail createdChatRoom = chatRoomService.createChatRoom(createRequest); + + // when & then + mockMvc.perform(get("/api/v1/chat-rooms/{chatRoomId}", createdChatRoom.id())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(createdChatRoom.id().toString())) + .andExpect(jsonPath("$.userId").value(1L)) + .andExpect(jsonPath("$.title").value("테스트 채팅방")) + .andExpect(jsonPath("$.messageCount").value(0)); + } + + @Test + @DisplayName("실패: 존재하지 않는 채팅방 조회") + void getChatRoom_Fail_NotFound() throws Exception { + // given + UUID nonExistentId = UUID.randomUUID(); + + // when & then + mockMvc.perform(get("/api/v1/chat-rooms/{chatRoomId}", nonExistentId)) + .andDo(print()) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("GET /api/v1/chat-rooms/paging - 채팅방 페이징 조회") + class GetChatRoomsByUserIdPaging { + + @Test + @DisplayName("성공: 페이징 파라미터로 채팅방 조회") + void getChatRoomsByUserId_Success() throws Exception { + // given + Long userId = 1L; + for (int i = 1; i <= 25; i++) { + chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "채팅방 " + i)); + } + + // when & then + mockMvc.perform(get("/api/v1/chat-rooms") + .param("userId", userId.toString()) + .param("page", "0") + .param("size", "10") + .param("sort", "createdDate,desc")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(10)) + .andExpect(jsonPath("$.totalElements").value(25)) + .andExpect(jsonPath("$.totalPages").value(3)) + .andExpect(jsonPath("$.size").value(10)) + .andExpect(jsonPath("$.number").value(0)); + } + + @Test + @DisplayName("성공: 기본 페이징 설정으로 조회") + void getChatRoomsByUserId_Success_DefaultPaging() throws Exception { + // given + Long userId = 1L; + chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "채팅방 1")); + + // when & then + mockMvc.perform(get("/api/v1/chat-rooms") + .param("userId", userId.toString())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.size").value(20)); // 기본 size + } + } + + @Nested + @DisplayName("GET /api/v1/chat-rooms/search - 채팅방 제목 검색") + class SearchChatRooms { + + @Test + @DisplayName("성공: 특정 userId와 keyword로 검색 시 조건에 맞는 채팅방 목록 반환") + void searchChatRooms_Success() throws Exception { + // given + Long userId = 1L; + chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "Java 스터디")); + chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "Spring Boot 프로젝트")); + chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "React 개발")); + chatRoomService.createChatRoom(new ChatRoomRequest.Create(2L, "Java 마스터")); // 다른 사용자 + + // when & then + mockMvc.perform(get("/api/v1/chat-rooms/search") + .param("userId", userId.toString()) + .param("keyword", "Java")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].title").value("Java 스터디")) + .andExpect(jsonPath("$[0].id").exists()) + .andExpect(jsonPath("$[0].createdDate").exists()) + .andExpect(jsonPath("$[0].messageCount").exists()); + } + + @Test + @DisplayName("성공: 부분 문자열 검색") + void searchChatRooms_Success_PartialMatch() throws Exception { + // given + Long userId = 1L; + chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "Spring Boot 프로젝트")); + chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "Spring Security 학습")); + chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "React 개발")); + + // when & then + mockMvc.perform(get("/api/v1/chat-rooms/search") + .param("userId", userId.toString()) + .param("keyword", "Spring")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].title").value("Spring Boot 프로젝트")) + .andExpect(jsonPath("$[1].title").value("Spring Security 학습")); + } + + @Test + @DisplayName("성공: 검색 결과가 없는 경우 빈 배열 반환") + void searchChatRooms_Success_EmptyResult() throws Exception { + // given + Long userId = 1L; + chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "Java 스터디")); + chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "Spring Boot 프로젝트")); + + // when & then + mockMvc.perform(get("/api/v1/chat-rooms/search") + .param("userId", userId.toString()) + .param("keyword", "Python")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(0)); + } + + @Test + @DisplayName("성공: 사용자 격리 - 다른 사용자의 채팅방은 검색되지 않음") + void searchChatRooms_Success_UserIsolation() throws Exception { + // given + Long userId1 = 1L; + Long userId2 = 2L; + chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId1, "Java 스터디")); + chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId2, "Java 마스터")); + + // when & then + mockMvc.perform(get("/api/v1/chat-rooms/search") + .param("userId", userId1.toString()) + .param("keyword", "Java")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].title").value("Java 스터디")); + } + + @Test + @DisplayName("성공: 키워드 포함 검색") + void searchChatRooms_Success_KeywordContaining() throws Exception { + // given + Long userId = 1L; + chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "Java 스터디")); + chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "JavaScript 프로젝트")); + + // when & then + mockMvc.perform(get("/api/v1/chat-rooms/search") + .param("userId", userId.toString()) + .param("keyword", "Java")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(2)); + } + + @Test + @DisplayName("성공: 빈 키워드로 검색 시 모든 채팅방 반환") + void searchChatRooms_Success_EmptyKeyword() throws Exception { + // given + Long userId = 1L; + chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "채팅방 1")); + chatRoomService.createChatRoom(new ChatRoomRequest.Create(userId, "채팅방 2")); + + // when & then + mockMvc.perform(get("/api/v1/chat-rooms/search") + .param("userId", userId.toString()) + .param("keyword", "")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(2)); + } + } + + @Nested + @DisplayName("PUT /api/v1/chat-rooms/{chatRoomId} - 채팅방 수정") + class UpdateChatRoom { + + @Test + @DisplayName("성공: 유효한 요청으로 채팅방 수정") + void updateChatRoom_Success() throws Exception { + // given + ChatRoomRequest.Create createRequest = new ChatRoomRequest.Create(1L, "원본 제목"); + ChatRoomResponse.Detail createdChatRoom = chatRoomService.createChatRoom(createRequest); + + ChatRoomRequest.Update updateRequest = new ChatRoomRequest.Update("수정된 제목"); + + // when & then + mockMvc.perform(put("/api/v1/chat-rooms/{chatRoomId}", createdChatRoom.id()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(createdChatRoom.id().toString())) + .andExpect(jsonPath("$.title").value("수정된 제목")) + .andExpect(jsonPath("$.userId").value(1L)); + } + + @Test + @DisplayName("실패: title이 비어있는 경우") + void updateChatRoom_Fail_TitleBlank() throws Exception { + // given + ChatRoomRequest.Create createRequest = new ChatRoomRequest.Create(1L, "원본 제목"); + ChatRoomResponse.Detail createdChatRoom = chatRoomService.createChatRoom(createRequest); + + ChatRoomRequest.Update updateRequest = new ChatRoomRequest.Update(""); + + // when & then + mockMvc.perform(put("/api/v1/chat-rooms/{chatRoomId}", createdChatRoom.id()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("실패: 존재하지 않는 채팅방 수정") + void updateChatRoom_Fail_NotFound() throws Exception { + // given + UUID nonExistentId = UUID.randomUUID(); + ChatRoomRequest.Update updateRequest = new ChatRoomRequest.Update("수정된 제목"); + + // when & then + mockMvc.perform(put("/api/v1/chat-rooms/{chatRoomId}", nonExistentId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andDo(print()) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("DELETE /api/v1/chat-rooms/{chatRoomId} - 채팅방 삭제") + class DeleteChatRoom { + + @Test + @DisplayName("성공: 존재하는 채팅방 삭제") + void deleteChatRoom_Success() throws Exception { + // given + ChatRoomRequest.Create createRequest = new ChatRoomRequest.Create(1L, "삭제할 채팅방"); + ChatRoomResponse.Detail createdChatRoom = chatRoomService.createChatRoom(createRequest); + + // when & then + mockMvc.perform(delete("/api/v1/chat-rooms/{chatRoomId}", createdChatRoom.id())) + .andDo(print()) + .andExpect(status().isNoContent()); + + // 삭제 확인 + mockMvc.perform(get("/api/v1/chat-rooms/{chatRoomId}", createdChatRoom.id())) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("실패: 존재하지 않는 채팅방 삭제") + void deleteChatRoom_Fail_NotFound() throws Exception { + // given + UUID nonExistentId = UUID.randomUUID(); + + // when & then + mockMvc.perform(delete("/api/v1/chat-rooms/{chatRoomId}", nonExistentId)) + .andDo(print()) + .andExpect(status().isNotFound()); + } + } +} diff --git a/chat_service/src/test/java/com/synapse/chat_service/controller/MessageControllerTest.java b/chat_service/src/test/java/com/synapse/chat_service/controller/MessageControllerTest.java new file mode 100644 index 0000000..5596d52 --- /dev/null +++ b/chat_service/src/test/java/com/synapse/chat_service/controller/MessageControllerTest.java @@ -0,0 +1,489 @@ +package com.synapse.chat_service.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.synapse.chat_service.domain.entity.ChatRoom; +import com.synapse.chat_service.domain.entity.Message; +import com.synapse.chat_service.domain.entity.enums.SenderType; +import com.synapse.chat_service.domain.repository.ChatRoomRepository; +import com.synapse.chat_service.domain.repository.MessageRepository; +import com.synapse.chat_service.dto.request.MessageRequest; +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.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@DisplayName("MessageController 통합 테스트") +class MessageControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Autowired + private MessageRepository messageRepository; + + private ChatRoom testChatRoom; + private Message testMessage; + + @BeforeEach + void setUp() { + // 테스트용 채팅방 생성 + testChatRoom = TestObjectFactory.createChatRoom(1L, "테스트 채팅방"); + testChatRoom = chatRoomRepository.save(testChatRoom); + + // 테스트용 메시지 생성 + testMessage = TestObjectFactory.createUserMessage(testChatRoom, "테스트 메시지"); + testMessage = messageRepository.save(testMessage); + } + + @Nested + @DisplayName("POST /api/v1/messages - 메시지 생성") + class CreateMessage { + + @Test + @DisplayName("성공: 유효한 메시지 생성 요청") + void createMessage_Success() throws Exception { + // given + MessageRequest.Create request = new MessageRequest.Create( + testChatRoom.getId(), + SenderType.USER, + "새로운 메시지" + ); + + // when & then + mockMvc.perform(post("/api/v1/messages") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.chatRoomId").value(testChatRoom.getId().toString())) + .andExpect(jsonPath("$.senderType").value("USER")) + .andExpect(jsonPath("$.content").value("새로운 메시지")) + .andExpect(jsonPath("$.createdDate").exists()) + .andExpect(jsonPath("$.updatedDate").exists()); + } + + @Test + @DisplayName("실패: 채팅방 ID가 null인 경우") + void createMessage_Fail_NullChatRoomId() throws Exception { + // given + MessageRequest.Create request = new MessageRequest.Create( + null, + SenderType.USER, + "메시지 내용" + ); + + // when & then + mockMvc.perform(post("/api/v1/messages") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("실패: 발신자 타입이 null인 경우") + void createMessage_Fail_NullSenderType() throws Exception { + // given + MessageRequest.Create request = new MessageRequest.Create( + testChatRoom.getId(), + null, + "메시지 내용" + ); + + // when & then + mockMvc.perform(post("/api/v1/messages") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("실패: 메시지 내용이 비어있는 경우") + void createMessage_Fail_BlankContent() throws Exception { + // given + MessageRequest.Create request = new MessageRequest.Create( + testChatRoom.getId(), + SenderType.USER, + "" + ); + + // when & then + mockMvc.perform(post("/api/v1/messages") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("실패: 존재하지 않는 채팅방 ID") + void createMessage_Fail_ChatRoomNotFound() throws Exception { + // given + UUID nonExistentChatRoomId = UUID.randomUUID(); + MessageRequest.Create request = new MessageRequest.Create( + nonExistentChatRoomId, + SenderType.USER, + "메시지 내용" + ); + + // when & then + mockMvc.perform(post("/api/v1/messages") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("GET /api/v1/messages/{messageId} - 메시지 단건 조회") + class GetMessage { + + @Test + @DisplayName("성공: 존재하는 메시지 조회") + void getMessage_Success() throws Exception { + // when & then + mockMvc.perform(get("/api/v1/messages/{messageId}", testMessage.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(testMessage.getId())) + .andExpect(jsonPath("$.chatRoomId").value(testChatRoom.getId().toString())) + .andExpect(jsonPath("$.senderType").value("USER")) + .andExpect(jsonPath("$.content").value("테스트 메시지")) + .andExpect(jsonPath("$.createdDate").exists()) + .andExpect(jsonPath("$.updatedDate").exists()); + } + + @Test + @DisplayName("실패: 존재하지 않는 메시지 ID") + void getMessage_Fail_NotFound() throws Exception { + // given + Long nonExistentMessageId = 99999L; + + // when & then + mockMvc.perform(get("/api/v1/messages/{messageId}", nonExistentMessageId)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("GET /api/v1/messages/chat-room/{chatRoomId} - 채팅방별 메시지 목록 조회") + class GetMessagesByChatRoomId { + + @Test + @DisplayName("성공: 채팅방의 메시지 목록 조회") + void getMessagesByChatRoomId_Success() throws Exception { + // when & then + mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}", testChatRoom.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].id").value(testMessage.getId())) + .andExpect(jsonPath("$[0].chatRoomId").value(testChatRoom.getId().toString())) + .andExpect(jsonPath("$[0].senderType").value("USER")) + .andExpect(jsonPath("$[0].content").value("테스트 메시지")); + } + + @Test + @DisplayName("실패: 존재하지 않는 채팅방 ID") + void getMessagesByChatRoomId_Fail_ChatRoomNotFound() throws Exception { + // given + UUID nonExistentChatRoomId = UUID.randomUUID(); + + // when & then + mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}", nonExistentChatRoomId)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("GET /api/v1/messages/chat-room/{chatRoomId}/paging - 채팅방별 메시지 페이징 조회") + class GetMessagesByChatRoomIdWithPaging { + + @Test + @DisplayName("성공: 기본 페이징 파라미터로 조회") + void getMessagesByChatRoomIdWithPaging_Success_DefaultParams() throws Exception { + // when & then + mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/paging", testChatRoom.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].id").value(testMessage.getId())) + .andExpect(jsonPath("$.totalElements").value(1)) + .andExpect(jsonPath("$.totalPages").value(1)) + .andExpect(jsonPath("$.size").value(50)) + .andExpect(jsonPath("$.number").value(0)); + } + + @Test + @DisplayName("성공: 커스텀 페이징 파라미터로 조회") + void getMessagesByChatRoomIdWithPaging_Success_CustomParams() throws Exception { + // when & then + mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/paging", testChatRoom.getId()) + .param("page", "0") + .param("size", "10") + .param("sort", "createdDate,desc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.size").value(10)) + .andExpect(jsonPath("$.number").value(0)); + } + } + + @Nested + @DisplayName("GET /api/v1/messages/chat-room/{chatRoomId}/recent - 최신순 메시지 페이징 조회") + class GetRecentMessages { + + @Test + @DisplayName("성공: 메시지가 createdDate 기준 내림차순(DESC)으로 정렬되어 반환") + void getRecentMessages_Success_DescendingOrder() throws Exception { + // given + // 추가 메시지들을 생성하여 정렬 테스트 + Message message1 = Message.builder() + .chatRoom(testChatRoom) + .senderType(SenderType.USER) + .content("첫 번째 메시지") + .build(); + Message message2 = Message.builder() + .chatRoom(testChatRoom) + .senderType(SenderType.USER) + .content("두 번째 메시지") + .build(); + Message message3 = Message.builder() + .chatRoom(testChatRoom) + .senderType(SenderType.USER) + .content("세 번째 메시지") + .build(); + + messageRepository.save(message1); + Thread.sleep(10); // 시간 차이를 위한 짧은 대기 + messageRepository.save(message2); + Thread.sleep(10); + messageRepository.save(message3); + + // when & then + mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/recent", testChatRoom.getId()) + .param("page", "0") + .param("size", "10")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(4)) // 기존 testMessage + 3개 추가 + .andExpect(jsonPath("$.content[0].content").value("세 번째 메시지")) // 가장 최신 + .andExpect(jsonPath("$.content[1].content").value("두 번째 메시지")) + .andExpect(jsonPath("$.content[2].content").value("첫 번째 메시지")) + .andExpect(jsonPath("$.content[3].content").value("테스트 메시지")) // 가장 오래된 + .andExpect(jsonPath("$.totalElements").value(4)) + .andExpect(jsonPath("$.totalPages").value(1)) + .andExpect(jsonPath("$.size").value(10)) + .andExpect(jsonPath("$.number").value(0)) + .andExpect(jsonPath("$.first").value(true)) + .andExpect(jsonPath("$.last").value(true)); + } + + @Test + @DisplayName("성공: 페이징 정보가 정확한지 확인") + void getRecentMessages_Success_PagingInfo() throws Exception { + // given + // 페이징 테스트를 위해 여러 메시지 생성 + for (int i = 1; i <= 15; i++) { + Message message = Message.builder() + .chatRoom(testChatRoom) + .senderType(SenderType.USER) + .content("메시지 " + i) + .build(); + messageRepository.save(message); + Thread.sleep(5); // 시간 차이를 위한 짧은 대기 + } + + // when & then - 첫 번째 페이지 (size=5) + mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/recent", testChatRoom.getId()) + .param("page", "0") + .param("size", "5")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.totalElements").value(16)) // 기존 testMessage + 15개 추가 + .andExpect(jsonPath("$.totalPages").value(4)) // 16개 / 5 = 4페이지 + .andExpect(jsonPath("$.size").value(5)) + .andExpect(jsonPath("$.number").value(0)) + .andExpect(jsonPath("$.first").value(true)) + .andExpect(jsonPath("$.last").value(false)); + + // when & then - 두 번째 페이지 + mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/recent", testChatRoom.getId()) + .param("page", "1") + .param("size", "5")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.number").value(1)) + .andExpect(jsonPath("$.first").value(false)) + .andExpect(jsonPath("$.last").value(false)); + + // when & then - 마지막 페이지 + mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/recent", testChatRoom.getId()) + .param("page", "3") + .param("size", "5")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(1)) // 마지막 페이지는 1개만 + .andExpect(jsonPath("$.number").value(3)) + .andExpect(jsonPath("$.first").value(false)) + .andExpect(jsonPath("$.last").value(true)); + } + + @Test + @DisplayName("성공: 기본 페이징 파라미터 적용") + void getRecentMessages_Success_DefaultParams() throws Exception { + // when & then + mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/recent", testChatRoom.getId())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.size").value(50)) // 기본 size + .andExpect(jsonPath("$.number").value(0)) // 기본 page + .andExpect(jsonPath("$.first").value(true)) + .andExpect(jsonPath("$.last").value(true)); + } + + @Test + @DisplayName("성공: 빈 채팅방의 경우 빈 페이지 반환") + void getRecentMessages_Success_EmptyResult() throws Exception { + // given + ChatRoom emptyChatRoom = ChatRoom.builder() + .userId(1L) + .title("빈 채팅방") + .build(); + emptyChatRoom = chatRoomRepository.save(emptyChatRoom); + + // when & then + mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/recent", emptyChatRoom.getId())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(0)) + .andExpect(jsonPath("$.totalElements").value(0)) + .andExpect(jsonPath("$.totalPages").value(0)) + .andExpect(jsonPath("$.first").value(true)) + .andExpect(jsonPath("$.last").value(true)); + } + + @Test + @DisplayName("실패: 존재하지 않는 채팅방 ID") + void getRecentMessages_Fail_ChatRoomNotFound() throws Exception { + // given + UUID nonExistentChatRoomId = UUID.randomUUID(); + + // when & then + mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/recent", nonExistentChatRoomId)) + .andDo(print()) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("성공: 다양한 SenderType 메시지 혼재 시 정렬 확인") + void getRecentMessages_Success_MixedSenderTypes() throws Exception { + // given + Message userMessage = Message.builder() + .chatRoom(testChatRoom) + .senderType(SenderType.USER) + .content("사용자 메시지") + .build(); + Message assistantMessage = Message.builder() + .chatRoom(testChatRoom) + .senderType(SenderType.ASSISTANT) + .content("어시스턴트 메시지") + .build(); + + messageRepository.save(userMessage); + Thread.sleep(10); + messageRepository.save(assistantMessage); + + // when & then + mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/recent", testChatRoom.getId()) + .param("page", "0") + .param("size", "10")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(3)) + .andExpect(jsonPath("$.content[0].content").value("어시스턴트 메시지")) + .andExpect(jsonPath("$.content[0].senderType").value("ASSISTANT")) + .andExpect(jsonPath("$.content[1].content").value("사용자 메시지")) + .andExpect(jsonPath("$.content[1].senderType").value("USER")) + .andExpect(jsonPath("$.content[2].content").value("테스트 메시지")) + .andExpect(jsonPath("$.content[2].senderType").value("USER")); + } + } + + @Nested + @DisplayName("GET /api/v1/messages/chat-room/{chatRoomId}/search - 메시지 내용 검색") + class SearchMessages { + + @Test + @DisplayName("성공: 키워드로 메시지 검색") + void searchMessages_Success() throws Exception { + // when & then + mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/search", testChatRoom.getId()) + .param("keyword", "테스트")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].content").value("테스트 메시지")); + } + + @Test + @DisplayName("성공: 존재하지 않는 키워드 검색 (빈 결과)") + void searchMessages_Success_NoResults() throws Exception { + // when & then + mockMvc.perform(get("/api/v1/messages/chat-room/{chatRoomId}/search", testChatRoom.getId()) + .param("keyword", "존재하지않는키워드")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); + } + } + + @Nested + @DisplayName("DELETE /api/v1/messages/{messageId} - 메시지 삭제") + class DeleteMessage { + + @Test + @DisplayName("성공: 존재하는 메시지 삭제") + void deleteMessage_Success() throws Exception { + // when & then + mockMvc.perform(delete("/api/v1/messages/{messageId}", testMessage.getId())) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("실패: 존재하지 않는 메시지 ID") + void deleteMessage_Fail_NotFound() throws Exception { + // given + Long nonExistentMessageId = 99999L; + + // when & then + mockMvc.perform(delete("/api/v1/messages/{messageId}", nonExistentMessageId)) + .andExpect(status().isNotFound()); + } + } +} diff --git a/chat_service/src/test/java/com/synapse/chat_service/domain/entity/ChatRoomTest.java b/chat_service/src/test/java/com/synapse/chat_service/domain/entity/ChatRoomTest.java new file mode 100644 index 0000000..7692937 --- /dev/null +++ b/chat_service/src/test/java/com/synapse/chat_service/domain/entity/ChatRoomTest.java @@ -0,0 +1,206 @@ +package com.synapse.chat_service.domain.entity; + +import com.synapse.chat_service.exception.commonexception.ValidException; +import com.synapse.chat_service.testutil.TestObjectFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("ChatRoom 도메인 엔티티 테스트") +class ChatRoomTest { + + private ChatRoom chatRoom; + private final Long userId = 1L; + private final String initialTitle = "초기 채팅방 제목"; + + @BeforeEach + void setUp() { + chatRoom = TestObjectFactory.createChatRoom(userId, initialTitle); + } + + @Nested + @DisplayName("updateTitle 메소드 테스트") + class UpdateTitleTest { + + @Test + @DisplayName("성공: 유효한 새 제목으로 업데이트") + void updateTitle_Success() { + // given + String newTitle = "새로운 채팅방 제목"; + + // when + chatRoom.updateTitle(newTitle); + + // then + assertThat(chatRoom.getTitle()).isEqualTo(newTitle); + } + + @Test + @DisplayName("성공: 앞뒤 공백이 있는 제목으로 업데이트 시 trim() 적용") + void updateTitle_Success_WithWhitespace() { + // given + String newTitleWithWhitespace = " 새로운 채팅방 제목 "; + String expectedTitle = "새로운 채팅방 제목"; + + // when + chatRoom.updateTitle(newTitleWithWhitespace); + + // then + assertThat(chatRoom.getTitle()).isEqualTo(expectedTitle); + } + + @Test + @DisplayName("성공: 최대 길이(255자) 제목으로 업데이트") + void updateTitle_Success_MaxLength() { + // given + String maxLengthTitle = "a".repeat(255); + + // when + chatRoom.updateTitle(maxLengthTitle); + + // then + assertThat(chatRoom.getTitle()).isEqualTo(maxLengthTitle); + assertThat(chatRoom.getTitle().length()).isEqualTo(255); + } + + @Test + @DisplayName("성공: 한글 제목으로 업데이트") + void updateTitle_Success_Korean() { + // given + String koreanTitle = "한글 채팅방 제목입니다"; + + // when + chatRoom.updateTitle(koreanTitle); + + // then + assertThat(chatRoom.getTitle()).isEqualTo(koreanTitle); + } + + @Test + @DisplayName("실패: null 제목으로 업데이트 시 ValidException 발생") + void updateTitle_Fail_NullTitle() { + // given + String nullTitle = null; + + // when & then + ValidException exception = assertThrows(ValidException.class, () -> { + chatRoom.updateTitle(nullTitle); + }); + + assertThat(exception.getMessage()).contains("채팅방 제목은 비어있을 수 없습니다"); + assertThat(chatRoom.getTitle()).isEqualTo(initialTitle); // 기존 제목 유지 + } + + @Test + @DisplayName("실패: 빈 문자열 제목으로 업데이트 시 ValidException 발생") + void updateTitle_Fail_EmptyTitle() { + // given + String emptyTitle = ""; + + // when & then + ValidException exception = assertThrows(ValidException.class, () -> { + chatRoom.updateTitle(emptyTitle); + }); + + assertThat(exception.getMessage()).contains("채팅방 제목은 비어있을 수 없습니다"); + assertThat(chatRoom.getTitle()).isEqualTo(initialTitle); // 기존 제목 유지 + } + + @Test + @DisplayName("실패: 공백만 있는 제목으로 업데이트 시 ValidException 발생") + void updateTitle_Fail_WhitespaceOnlyTitle() { + // given + String whitespaceOnlyTitle = " "; + + // when & then + ValidException exception = assertThrows(ValidException.class, () -> { + chatRoom.updateTitle(whitespaceOnlyTitle); + }); + + assertThat(exception.getMessage()).contains("채팅방 제목은 비어있을 수 없습니다"); + assertThat(chatRoom.getTitle()).isEqualTo(initialTitle); // 기존 제목 유지 + } + + @Test + @DisplayName("실패: 255자를 초과하는 제목으로 업데이트 시 ValidException 발생") + void updateTitle_Fail_ExceedsMaxLength() { + // given + String tooLongTitle = "a".repeat(256); // 256자 + + // when & then + ValidException exception = assertThrows(ValidException.class, () -> { + chatRoom.updateTitle(tooLongTitle); + }); + + assertThat(exception.getMessage()).contains("채팅방 제목은 255자를 초과할 수 없습니다"); + assertThat(chatRoom.getTitle()).isEqualTo(initialTitle); // 기존 제목 유지 + } + + @Test + @DisplayName("실패: trim 후 255자를 초과하는 제목으로 업데이트 시 ValidException 발생") + void updateTitle_Fail_ExceedsMaxLengthAfterTrim() { + // given + String tooLongTitleWithWhitespace = " " + "a".repeat(256) + " "; // trim 후 256자 + + // when & then + ValidException exception = assertThrows(ValidException.class, () -> { + chatRoom.updateTitle(tooLongTitleWithWhitespace); + }); + + assertThat(exception.getMessage()).contains("채팅방 제목은 255자를 초과할 수 없습니다"); + assertThat(chatRoom.getTitle()).isEqualTo(initialTitle); // 기존 제목 유지 + } + + @Test + @DisplayName("경계값 테스트: trim 후 정확히 255자인 제목으로 업데이트") + void updateTitle_BoundaryTest_ExactlyMaxLengthAfterTrim() { + // given + String exactMaxLengthWithWhitespace = " " + "a".repeat(255) + " "; // trim 후 정확히 255자 + String expectedTitle = "a".repeat(255); + + // when + chatRoom.updateTitle(exactMaxLengthWithWhitespace); + + // then + assertThat(chatRoom.getTitle()).isEqualTo(expectedTitle); + assertThat(chatRoom.getTitle().length()).isEqualTo(255); + } + } + + @Nested + @DisplayName("ChatRoom 생성자 테스트") + class ConstructorTest { + + @Test + @DisplayName("성공: 유효한 파라미터로 ChatRoom 생성") + void constructor_Success() { + // given + Long testUserId = 123L; + String testTitle = "테스트 채팅방"; + + // when + ChatRoom newChatRoom = TestObjectFactory.createChatRoom(testUserId, testTitle); + + // then + assertThat(newChatRoom.getUserId()).isEqualTo(testUserId); + assertThat(newChatRoom.getTitle()).isEqualTo(testTitle); + assertThat(newChatRoom.getMessages()).isNotNull(); + assertThat(newChatRoom.getMessages()).isEmpty(); + } + + @Test + @DisplayName("성공: 빈 메시지 리스트로 초기화") + void constructor_Success_EmptyMessagesList() { + // when + ChatRoom newChatRoom = TestObjectFactory.createChatRoom(1L, "테스트"); + + // then + assertThat(newChatRoom.getMessages()).isNotNull(); + assertThat(newChatRoom.getMessages()).hasSize(0); + } + } +} diff --git a/chat_service/src/test/java/com/synapse/chat_service/domain/entity/MessageTest.java b/chat_service/src/test/java/com/synapse/chat_service/domain/entity/MessageTest.java new file mode 100644 index 0000000..3a71bc4 --- /dev/null +++ b/chat_service/src/test/java/com/synapse/chat_service/domain/entity/MessageTest.java @@ -0,0 +1,321 @@ +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 ChatRoom chatRoom; + private Message message; + private final String initialContent = "초기 메시지 내용"; + + @BeforeEach + void setUp() { + chatRoom = TestObjectFactory.createChatRoom(1L, "테스트 채팅방"); + + message = TestObjectFactory.createUserMessage(chatRoom, 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(chatRoom, testContent); + + // then + assertThat(newMessage.getChatRoom()).isEqualTo(chatRoom); + 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(chatRoom, 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(chatRoom, 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() + .chatRoom(chatRoom) + .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() + .chatRoom(chatRoom) + .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() + .chatRoom(chatRoom) + .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() + .chatRoom(chatRoom) + .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(chatRoom, 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(chatRoom, 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/ChatRoomRepositoryTest.java b/chat_service/src/test/java/com/synapse/chat_service/repository/ChatRoomRepositoryTest.java new file mode 100644 index 0000000..5afd505 --- /dev/null +++ b/chat_service/src/test/java/com/synapse/chat_service/repository/ChatRoomRepositoryTest.java @@ -0,0 +1,355 @@ +package com.synapse.chat_service.repository; + +import com.synapse.chat_service.domain.entity.ChatRoom; +import com.synapse.chat_service.domain.entity.Message; +import com.synapse.chat_service.domain.entity.enums.SenderType; +import com.synapse.chat_service.domain.repository.ChatRoomRepository; +import com.synapse.chat_service.domain.repository.MessageRepository; +import com.synapse.chat_service.testutil.TestObjectFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; + + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +@DisplayName("ChatRoomRepository 단위 테스트") +class ChatRoomRepositoryTest { + + @Autowired + private TestEntityManager entityManager; + + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Autowired + private MessageRepository messageRepository; + + private Long userId1; + private Long userId2; + private ChatRoom chatRoom1; + private ChatRoom chatRoom2; + private ChatRoom chatRoom3; + private ChatRoom chatRoom4; + + @BeforeEach + void setUp() throws Exception { + userId1 = 1L; + userId2 = 2L; + + // 테스트용 ChatRoom 데이터 생성 + chatRoom1 = TestObjectFactory.createChatRoomWithCreatedDate(userId1, "자바 스터디", LocalDateTime.now().minusDays(3)); + chatRoom2 = TestObjectFactory.createChatRoomWithCreatedDate(userId1, "스프링 부트 학습", LocalDateTime.now().minusDays(2)); + chatRoom3 = TestObjectFactory.createChatRoomWithCreatedDate(userId1, "리액트 프로젝트", LocalDateTime.now().minusDays(1)); + chatRoom4 = TestObjectFactory.createChatRoomWithCreatedDate(userId2, "파이썬 기초", LocalDateTime.now()); + + // 데이터베이스에 저장 + entityManager.persistAndFlush(chatRoom1); + entityManager.persistAndFlush(chatRoom2); + entityManager.persistAndFlush(chatRoom3); + entityManager.persistAndFlush(chatRoom4); + } + + + + @Nested + @DisplayName("findByUserIdAndTitleContaining 테스트") + class FindByUserIdAndTitleContainingTest { + + @Test + @DisplayName("성공: 특정 사용자의 제목에 키워드가 포함된 채팅방 조회") + void findByUserIdAndTitleContaining_Success() { + // given + String keyword = "스"; + + // when + List result = chatRoomRepository.findByUserIdAndTitleContaining(userId1, keyword); + + // then + assertThat(result).hasSize(2); + assertThat(result).extracting(ChatRoom::getTitle) + .containsExactlyInAnyOrder("자바 스터디", "스프링 부트 학습"); + assertThat(result).allMatch(chatRoom -> chatRoom.getUserId().equals(userId1)); + } + + @Test + @DisplayName("성공: 키워드가 정확히 일치하는 경우") + void findByUserIdAndTitleContaining_ExactMatch() { + // given + String keyword = "자바"; + + // when + List result = chatRoomRepository.findByUserIdAndTitleContaining(userId1, keyword); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getTitle()).isEqualTo("자바 스터디"); + assertThat(result.get(0).getUserId()).isEqualTo(userId1); + } + + @Test + @DisplayName("성공: 검색 결과가 없는 경우 빈 리스트 반환") + void findByUserIdAndTitleContaining_EmptyResult() { + // given + String keyword = "존재하지않는키워드"; + + // when + List result = chatRoomRepository.findByUserIdAndTitleContaining(userId1, keyword); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("성공: 다른 사용자의 채팅방은 검색되지 않음") + void findByUserIdAndTitleContaining_DifferentUser() { + // given + String keyword = "파이썬"; + + // when + List result = chatRoomRepository.findByUserIdAndTitleContaining(userId1, keyword); + + // then + assertThat(result).isEmpty(); // userId1에는 파이썬 관련 채팅방이 없음 + } + + @Test + @DisplayName("성공: 대소문자 구분 없이 검색") + void findByUserIdAndTitleContaining_CaseInsensitive() { + // given + String keyword = "JAVA"; + + // when + List result = chatRoomRepository.findByUserIdAndTitleContaining(userId1, keyword); + + // then + // 한글 제목이므로 대소문자 테스트는 영문 제목으로 추가 데이터 생성 필요 + // 현재는 빈 결과 확인 + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("findByUserIdOrderByCreatedDateDesc 테스트") + class FindByUserIdOrderByCreatedDateDescTest { + + @Test + @DisplayName("성공: 특정 사용자의 채팅방을 생성일 기준 내림차순으로 페이징 조회") + void findByUserIdOrderByCreatedDateDesc_Success() { + // given + Pageable pageable = PageRequest.of(0, 2); + + // when + Page result = chatRoomRepository.findByUserIdOrderByCreatedDateDesc(userId1, pageable); + + // then + assertThat(result.getContent()).hasSize(2); + assertThat(result.getTotalElements()).isEqualTo(3); // userId1의 총 채팅방 개수 + assertThat(result.getTotalPages()).isEqualTo(2); // 총 페이지 수 (3개를 2개씩 나누면 2페이지) + assertThat(result.getNumber()).isEqualTo(0); // 현재 페이지 번호 + assertThat(result.getSize()).isEqualTo(2); // 페이지 크기 + assertThat(result.isFirst()).isTrue(); + assertThat(result.isLast()).isFalse(); + + // 생성일 기준 내림차순 정렬 확인 (최신순) + List content = result.getContent(); + // 첫 번째가 두 번째보다 더 최신이거나 같아야 함 + assertThat(content.get(0).getCreatedDate()).isAfterOrEqualTo(content.get(1).getCreatedDate()); + } + + @Test + @DisplayName("성공: 두 번째 페이지 조회") + void findByUserIdOrderByCreatedDateDesc_SecondPage() { + // given + Pageable pageable = PageRequest.of(1, 2); + + // when + Page result = chatRoomRepository.findByUserIdOrderByCreatedDateDesc(userId1, pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getTotalElements()).isEqualTo(3); + assertThat(result.getTotalPages()).isEqualTo(2); + assertThat(result.getNumber()).isEqualTo(1); + assertThat(result.isFirst()).isFalse(); + assertThat(result.isLast()).isTrue(); + + // 가장 오래된 채팅방 (두 번째 페이지이므로 첫 번째 페이지보다 오래된 것) + // 실제 데이터 검증보다는 페이징이 올바르게 동작하는지 확인 + assertThat(result.getContent().get(0)).isNotNull(); + } + + @Test + @DisplayName("성공: 채팅방이 없는 사용자의 경우 빈 페이지 반환") + void findByUserIdOrderByCreatedDateDesc_EmptyResult() { + // given + Long nonExistentUserId = 999L; + Pageable pageable = PageRequest.of(0, 10); + + // when + Page result = chatRoomRepository.findByUserIdOrderByCreatedDateDesc(nonExistentUserId, pageable); + + // then + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); + assertThat(result.getTotalPages()).isEqualTo(0); + assertThat(result.isEmpty()).isTrue(); + } + + @Test + @DisplayName("성공: 페이지 크기가 전체 데이터보다 큰 경우") + void findByUserIdOrderByCreatedDateDesc_LargePageSize() { + // given + Pageable pageable = PageRequest.of(0, 10); + + // when + Page result = chatRoomRepository.findByUserIdOrderByCreatedDateDesc(userId1, pageable); + + // then + assertThat(result.getContent()).hasSize(3); // 실제 데이터 개수 + assertThat(result.getTotalElements()).isEqualTo(3); + assertThat(result.getTotalPages()).isEqualTo(1); + assertThat(result.isFirst()).isTrue(); + assertThat(result.isLast()).isTrue(); + + // 정렬 순서 확인 + List content = result.getContent(); + assertThat(content.get(0).getTitle()).isEqualTo("리액트 프로젝트"); + assertThat(content.get(1).getTitle()).isEqualTo("스프링 부트 학습"); + assertThat(content.get(2).getTitle()).isEqualTo("자바 스터디"); + } + } + + @Nested + @DisplayName("연관관계 영속성 테스트 (CascadeType.ALL)") + class CascadePersistenceTest { + + @Test + @DisplayName("성공: 채팅방 삭제 시 연관된 메시지들이 함께 삭제된다 (CascadeType.ALL)") + void deleteChatRoom_CascadeDeleteMessages_Success() { + // given + // 테스트용 채팅방 생성 + ChatRoom testChatRoom = ChatRoom.builder() + .title("테스트 채팅방") + .userId(userId1) + .build(); + entityManager.persistAndFlush(testChatRoom); + + // 해당 채팅방에 여러 메시지 생성 (CascadeType.ALL을 활용하여 ChatRoom을 통해 저장) + Message message1 = Message.builder() + .chatRoom(testChatRoom) + .senderType(SenderType.USER) + .content("첫 번째 메시지") + .build(); + + Message message2 = Message.builder() + .chatRoom(testChatRoom) + .senderType(SenderType.ASSISTANT) + .content("두 번째 메시지") + .build(); + + Message message3 = Message.builder() + .chatRoom(testChatRoom) + .senderType(SenderType.USER) + .content("세 번째 메시지") + .build(); + + // 양방향 관계 설정: ChatRoom의 messages 컬렉션에 추가 + testChatRoom.getMessages().add(message1); + testChatRoom.getMessages().add(message2); + testChatRoom.getMessages().add(message3); + + // CascadeType.ALL로 인해 ChatRoom 저장 시 Message들도 함께 저장됨 + entityManager.persistAndFlush(testChatRoom); + + // 메시지가 정상적으로 저장되었는지 확인 + long initialMessageCount = messageRepository.countByChatRoomId(testChatRoom.getId()); + assertThat(initialMessageCount).isEqualTo(3); + + // when + // 채팅방 삭제 + chatRoomRepository.delete(testChatRoom); + entityManager.flush(); + entityManager.clear(); + + // then + // 채팅방이 삭제되었는지 확인 + Optional deletedChatRoom = chatRoomRepository.findById(testChatRoom.getId()); + assertThat(deletedChatRoom).isEmpty(); + + // 연관된 메시지들도 함께 삭제되었는지 확인 (CascadeType.ALL 검증) + long remainingMessageCount = messageRepository.countByChatRoomId(testChatRoom.getId()); + assertThat(remainingMessageCount).isEqualTo(0); + } + + @Test + @DisplayName("성공: 다른 채팅방의 메시지는 영향받지 않는다") + void deleteChatRoom_OtherChatRoomMessagesUnaffected_Success() { + // given + // 첫 번째 채팅방과 메시지 + ChatRoom chatRoom1 = ChatRoom.builder() + .title("삭제될 채팅방") + .userId(userId1) + .build(); + + Message messageToDelete = Message.builder() + .chatRoom(chatRoom1) + .senderType(SenderType.USER) + .content("삭제될 메시지") + .build(); + + // 양방향 관계 설정 + chatRoom1.getMessages().add(messageToDelete); + entityManager.persistAndFlush(chatRoom1); + + // 두 번째 채팅방과 메시지 (영향받지 않아야 함) + ChatRoom chatRoom2 = ChatRoom.builder() + .title("유지될 채팅방") + .userId(userId1) + .build(); + + Message messageToKeep = Message.builder() + .chatRoom(chatRoom2) + .senderType(SenderType.USER) + .content("유지될 메시지") + .build(); + + // 양방향 관계 설정 + chatRoom2.getMessages().add(messageToKeep); + entityManager.persistAndFlush(chatRoom2); + + // 초기 상태 확인 + assertThat(messageRepository.countByChatRoomId(chatRoom1.getId())).isEqualTo(1); + assertThat(messageRepository.countByChatRoomId(chatRoom2.getId())).isEqualTo(1); + + // when + // 첫 번째 채팅방만 삭제 + chatRoomRepository.delete(chatRoom1); + entityManager.flush(); + entityManager.clear(); + + // then + // 첫 번째 채팅방과 그 메시지는 삭제됨 + assertThat(chatRoomRepository.findById(chatRoom1.getId())).isEmpty(); + assertThat(messageRepository.countByChatRoomId(chatRoom1.getId())).isEqualTo(0); + + // 두 번째 채팅방과 그 메시지는 유지됨 + assertThat(chatRoomRepository.findById(chatRoom2.getId())).isPresent(); + assertThat(messageRepository.countByChatRoomId(chatRoom2.getId())).isEqualTo(1); + } + } +} diff --git a/chat_service/src/test/java/com/synapse/chat_service/repository/ChatUsageRepositoryTest.java b/chat_service/src/test/java/com/synapse/chat_service/repository/ChatUsageRepositoryTest.java new file mode 100644 index 0000000..3d72d3b --- /dev/null +++ b/chat_service/src/test/java/com/synapse/chat_service/repository/ChatUsageRepositoryTest.java @@ -0,0 +1,267 @@ +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 new file mode 100644 index 0000000..8f13d71 --- /dev/null +++ b/chat_service/src/test/java/com/synapse/chat_service/repository/MessageRepositoryTest.java @@ -0,0 +1,398 @@ +package com.synapse.chat_service.repository; + +import com.synapse.chat_service.domain.entity.ChatRoom; +import com.synapse.chat_service.domain.entity.Message; +import com.synapse.chat_service.domain.entity.enums.SenderType; +import com.synapse.chat_service.domain.repository.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 ChatRoom chatRoom1; + private ChatRoom chatRoom2; + private Message message1; + private Message message2; + private Message message3; + private Message message4; + private Message message5; + + @BeforeEach + void setUp() { + // 테스트용 ChatRoom 데이터 생성 + chatRoom1 = TestObjectFactory.createChatRoom(1L, "자바 스터디"); + chatRoom2 = TestObjectFactory.createChatRoom(2L, "스프링 부트 학습"); + + entityManager.persistAndFlush(chatRoom1); + entityManager.persistAndFlush(chatRoom2); + + // 테스트용 Message 데이터 생성 + message1 = TestObjectFactory.createMessageWithCreatedDate(chatRoom1, SenderType.USER, "안녕하세요! 자바 공부를 시작해봅시다.", LocalDateTime.now().minusHours(4)); + message2 = TestObjectFactory.createMessageWithCreatedDate(chatRoom1, SenderType.ASSISTANT, "자바의 기본 문법에 대해 알아보겠습니다.", LocalDateTime.now().minusHours(3)); + message3 = TestObjectFactory.createMessageWithCreatedDate(chatRoom1, SenderType.USER, "객체지향 프로그래밍의 핵심 개념을 설명해주세요.", LocalDateTime.now().minusHours(2)); + message4 = TestObjectFactory.createMessageWithCreatedDate(chatRoom2, SenderType.USER, "스프링 부트 프로젝트를 생성하는 방법을 알려주세요.", LocalDateTime.now().minusHours(1)); + message5 = TestObjectFactory.createMessageWithCreatedDate(chatRoom2, SenderType.ASSISTANT, "Spring Initializr를 사용하여 프로젝트를 생성할 수 있습니다.", LocalDateTime.now()); + + // 데이터베이스에 저장 + entityManager.persistAndFlush(message1); + entityManager.persistAndFlush(message2); + entityManager.persistAndFlush(message3); + entityManager.persistAndFlush(message4); + entityManager.persistAndFlush(message5); + } + + + + @Nested + @DisplayName("findByChatRoomIdAndContentContaining 테스트") + class FindByChatRoomIdAndContentContainingTest { + + @Test + @DisplayName("성공: 특정 채팅방에서 키워드가 포함된 메시지 조회") + void findByChatRoomIdAndContentContaining_Success() { + // given + String keyword = "자바"; + + // when + List result = messageRepository.findByChatRoomIdAndContentContaining(chatRoom1.getId(), keyword); + + // then + assertThat(result).hasSize(2); + assertThat(result).extracting(Message::getContent) + .containsExactlyInAnyOrder( + "안녕하세요! 자바 공부를 시작해봅시다.", + "자바의 기본 문법에 대해 알아보겠습니다." + ); + assertThat(result).allMatch(message -> message.getChatRoom().getId().equals(chatRoom1.getId())); + } + + @Test + @DisplayName("성공: 키워드가 정확히 일치하는 경우") + void findByChatRoomIdAndContentContaining_ExactMatch() { + // given + String keyword = "객체지향"; + + // when + List result = messageRepository.findByChatRoomIdAndContentContaining(chatRoom1.getId(), keyword); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getContent()).isEqualTo("객체지향 프로그래밍의 핵심 개념을 설명해주세요."); + assertThat(result.get(0).getChatRoom().getId()).isEqualTo(chatRoom1.getId()); + } + + @Test + @DisplayName("성공: 검색 결과가 없는 경우 빈 리스트 반환") + void findByChatRoomIdAndContentContaining_EmptyResult() { + // given + String keyword = "존재하지않는키워드"; + + // when + List result = messageRepository.findByChatRoomIdAndContentContaining(chatRoom1.getId(), keyword); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("성공: 다른 채팅방의 메시지는 검색되지 않음") + void findByChatRoomIdAndContentContaining_DifferentChatRoom() { + // given + String keyword = "스프링"; + + // when + List result = messageRepository.findByChatRoomIdAndContentContaining(chatRoom1.getId(), keyword); + + // then + assertThat(result).isEmpty(); // chatRoom1에는 스프링 관련 메시지가 없음 + } + + @Test + @DisplayName("성공: 부분 문자열 검색") + void findByChatRoomIdAndContentContaining_PartialMatch() { + // given + String keyword = "프로그래밍"; + + // when + List result = messageRepository.findByChatRoomIdAndContentContaining(chatRoom1.getId(), keyword); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getContent()).contains("프로그래밍"); + } + + @Test + @DisplayName("성공: 여러 채팅방에서 같은 키워드 검색") + void findByChatRoomIdAndContentContaining_MultipleKeywords() { + // given + String keyword = "프로젝트"; + + // when + List chatRoom1Result = messageRepository.findByChatRoomIdAndContentContaining(chatRoom1.getId(), keyword); + List chatRoom2Result = messageRepository.findByChatRoomIdAndContentContaining(chatRoom2.getId(), keyword); + + // then + assertThat(chatRoom1Result).isEmpty(); // chatRoom1에는 "프로젝트" 키워드가 없음 + assertThat(chatRoom2Result).hasSize(2); // chatRoom2에는 "프로젝트" 키워드가 2개 메시지에 있음 + assertThat(chatRoom2Result).extracting(Message::getContent) + .allMatch(content -> content.contains("프로젝트")); + } + } + + @Nested + @DisplayName("countByChatRoomId 테스트") + class CountByChatRoomIdTest { + + @Test + @DisplayName("성공: 특정 채팅방의 메시지 개수 조회") + void countByChatRoomId_Success() { + // when + long chatRoom1Count = messageRepository.countByChatRoomId(chatRoom1.getId()); + long chatRoom2Count = messageRepository.countByChatRoomId(chatRoom2.getId()); + + // then + assertThat(chatRoom1Count).isEqualTo(3); // chatRoom1에 3개의 메시지 + assertThat(chatRoom2Count).isEqualTo(2); // chatRoom2에 2개의 메시지 + } + + @Test + @DisplayName("성공: 메시지가 없는 채팅방의 경우 0 반환") + void countByChatRoomId_EmptyResult() { + // given + ChatRoom emptyChatRoom = ChatRoom.builder() + .title("빈 채팅방") + .userId(3L) + .build(); + entityManager.persistAndFlush(emptyChatRoom); + + // when + long count = messageRepository.countByChatRoomId(emptyChatRoom.getId()); + + // then + assertThat(count).isEqualTo(0); + } + + @Test + @DisplayName("성공: 존재하지 않는 채팅방 ID의 경우 0 반환") + void countByChatRoomId_NonExistentChatRoom() { + // given + UUID nonExistentChatRoomId = UUID.randomUUID(); + + // when + long count = messageRepository.countByChatRoomId(nonExistentChatRoomId); + + // then + assertThat(count).isEqualTo(0); + } + + @Test + @DisplayName("성공: 메시지 추가 후 개수 증가 확인") + void countByChatRoomId_AfterAddingMessage() { + // given + long initialCount = messageRepository.countByChatRoomId(chatRoom1.getId()); + + Message newMessage = Message.builder() + .content("새로운 메시지입니다.") + .senderType(SenderType.USER) + .chatRoom(chatRoom1) + .build(); + entityManager.persistAndFlush(newMessage); + + // when + long updatedCount = messageRepository.countByChatRoomId(chatRoom1.getId()); + + // then + assertThat(updatedCount).isEqualTo(initialCount + 1); + assertThat(updatedCount).isEqualTo(4); // 기존 3개 + 새로 추가된 1개 + } + + @Test + @DisplayName("성공: 다른 채팅방의 메시지는 카운트에 포함되지 않음") + void countByChatRoomId_IsolatedCount() { + // given + long chatRoom1InitialCount = messageRepository.countByChatRoomId(chatRoom1.getId()); + long chatRoom2InitialCount = messageRepository.countByChatRoomId(chatRoom2.getId()); + + // chatRoom2에 새 메시지 추가 + Message newMessage = Message.builder() + .content("chatRoom2에 추가된 메시지") + .senderType(SenderType.ASSISTANT) + .chatRoom(chatRoom2) + .build(); + entityManager.persistAndFlush(newMessage); + + // when + long chatRoom1FinalCount = messageRepository.countByChatRoomId(chatRoom1.getId()); + long chatRoom2FinalCount = messageRepository.countByChatRoomId(chatRoom2.getId()); + + // then + assertThat(chatRoom1FinalCount).isEqualTo(chatRoom1InitialCount); // chatRoom1 개수는 변화 없음 + assertThat(chatRoom2FinalCount).isEqualTo(chatRoom2InitialCount + 1); // chatRoom2 개수만 증가 + } + } + + @Nested + @DisplayName("findByChatRoomIdOrderByCreatedDateAsc 페이징 테스트") + class FindByChatRoomIdOrderByCreatedDateAscTest { + + @Test + @DisplayName("성공: 시간순(ASC) 정렬이 올바르게 동작") + void findByChatRoomIdOrderByCreatedDateAsc_Success() { + // given + Pageable pageable = PageRequest.of(0, 10); + + // when + Page result = messageRepository.findByChatRoomIdOrderByCreatedDateAsc(chatRoom1.getId(), pageable); + + // then + assertThat(result.getContent()).hasSize(3); + assertThat(result.getTotalElements()).isEqualTo(3); + + // 시간순(ASC) 정렬 확인: 가장 오래된 것부터 (같은 시간일 수도 있으므로 isBeforeOrEqualTo 사용) + List messages = result.getContent(); + assertThat(messages.get(0).getCreatedDate()).isBeforeOrEqualTo(messages.get(1).getCreatedDate()); // 첫 번째가 더 오래되거나 같음 + assertThat(messages.get(1).getCreatedDate()).isBeforeOrEqualTo(messages.get(2).getCreatedDate()); // 두 번째가 세 번째보다 오래되거나 같음 + } + + @Test + @DisplayName("성공: 페이징 처리") + void findByChatRoomIdOrderByCreatedDateAsc_Paging() { + // given + Pageable firstPage = PageRequest.of(0, 2); + Pageable secondPage = PageRequest.of(1, 2); + + // when + Page firstResult = messageRepository.findByChatRoomIdOrderByCreatedDateAsc(chatRoom1.getId(), firstPage); + Page secondResult = messageRepository.findByChatRoomIdOrderByCreatedDateAsc(chatRoom1.getId(), secondPage); + + // then + // 첫 번째 페이지 (가장 오래된 2개) + assertThat(firstResult.getContent()).hasSize(2); + assertThat(firstResult.getTotalElements()).isEqualTo(3); + assertThat(firstResult.getTotalPages()).isEqualTo(2); + // ASC 정렬이므로 첫 번째 페이지 내에서도 시간순 정렬 확인 + assertThat(firstResult.getContent().get(0).getCreatedDate()).isBeforeOrEqualTo(firstResult.getContent().get(1).getCreatedDate()); + + // 두 번째 페이지 (가장 최신 1개) + assertThat(secondResult.getContent()).hasSize(1); + assertThat(secondResult.getTotalElements()).isEqualTo(3); + assertThat(secondResult.getTotalPages()).isEqualTo(2); + // ASC 정렬에서 두 번째 페이지의 메시지는 첫 번째 페이지의 마지막 메시지보다 더 최신이어야 함 + assertThat(secondResult.getContent().get(0).getCreatedDate()).isAfter(firstResult.getContent().get(1).getCreatedDate()); + } + + @Test + @DisplayName("성공: 빈 결과 페이지") + void findByChatRoomIdOrderByCreatedDateAsc_EmptyResult() { + // given + UUID nonExistentChatRoomId = UUID.randomUUID(); + Pageable pageable = PageRequest.of(0, 10); + + // when + Page result = messageRepository.findByChatRoomIdOrderByCreatedDateAsc(nonExistentChatRoomId, pageable); + + // then + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); + assertThat(result.getTotalPages()).isEqualTo(0); + } + } + + @Nested + @DisplayName("findByChatRoomIdOrderByCreatedDateDesc 페이징 테스트") + class FindByChatRoomIdOrderByCreatedDateDescTest { + + @Test + @DisplayName("성공: 최신순(DESC) 정렬이 올바르게 동작") + void findByChatRoomIdOrderByCreatedDateDesc_Success() { + // given + Pageable pageable = PageRequest.of(0, 10); + + // when + Page result = messageRepository.findByChatRoomIdOrderByCreatedDateDesc(chatRoom1.getId(), pageable); + + // then + assertThat(result.getContent()).hasSize(3); + assertThat(result.getTotalElements()).isEqualTo(3); + + // 최신순(DESC) 정렬 확인: 가장 최신 것부터 (같은 시간일 수도 있으므로 isAfterOrEqualTo 사용) + List messages = result.getContent(); + assertThat(messages.get(0).getCreatedDate()).isAfterOrEqualTo(messages.get(1).getCreatedDate()); // 첫 번째가 더 최신이거나 같음 + assertThat(messages.get(1).getCreatedDate()).isAfterOrEqualTo(messages.get(2).getCreatedDate()); // 두 번째가 세 번째보다 최신이거나 같음 + } + + @Test + @DisplayName("성공: 페이징 처리") + void findByChatRoomIdOrderByCreatedDateDesc_Paging() { + // given + Pageable firstPage = PageRequest.of(0, 2); + Pageable secondPage = PageRequest.of(1, 2); + + // when + Page firstResult = messageRepository.findByChatRoomIdOrderByCreatedDateDesc(chatRoom1.getId(), firstPage); + Page secondResult = messageRepository.findByChatRoomIdOrderByCreatedDateDesc(chatRoom1.getId(), secondPage); + + // then + // 첫 번째 페이지 (최신 2개) + assertThat(firstResult.getContent()).hasSize(2); + assertThat(firstResult.getTotalElements()).isEqualTo(3); + assertThat(firstResult.getTotalPages()).isEqualTo(2); + // DESC 정렬이므로 첫 번째 페이지 내에서도 최신순 정렬 확인 + assertThat(firstResult.getContent().get(0).getCreatedDate()).isAfterOrEqualTo(firstResult.getContent().get(1).getCreatedDate()); + + // 두 번째 페이지 (가장 오래된 1개) + assertThat(secondResult.getContent()).hasSize(1); + assertThat(secondResult.getTotalElements()).isEqualTo(3); + assertThat(secondResult.getTotalPages()).isEqualTo(2); + // DESC 정렬에서 두 번째 페이지의 메시지는 첫 번째 페이지의 마지막 메시지보다 더 오래되어야 함 + assertThat(secondResult.getContent().get(0).getCreatedDate()).isBefore(firstResult.getContent().get(1).getCreatedDate()); + } + + @Test + @DisplayName("성공: 다른 채팅방과 격리된 결과") + void findByChatRoomIdOrderByCreatedDateDesc_IsolatedResult() { + // given + Pageable pageable = PageRequest.of(0, 10); + + // when + Page chatRoom1Result = messageRepository.findByChatRoomIdOrderByCreatedDateDesc(chatRoom1.getId(), pageable); + Page chatRoom2Result = messageRepository.findByChatRoomIdOrderByCreatedDateDesc(chatRoom2.getId(), pageable); + + // then + assertThat(chatRoom1Result.getContent()).hasSize(3); + assertThat(chatRoom2Result.getContent()).hasSize(2); + + // 각 채팅방의 메시지만 포함되는지 확인 + assertThat(chatRoom1Result.getContent()).allMatch(message -> + message.getChatRoom().getId().equals(chatRoom1.getId())); + assertThat(chatRoom2Result.getContent()).allMatch(message -> + message.getChatRoom().getId().equals(chatRoom2.getId())); + } + } +} diff --git a/chat_service/src/test/java/com/synapse/chat_service/repository/UserRepositoryTest.java b/chat_service/src/test/java/com/synapse/chat_service/repository/UserRepositoryTest.java new file mode 100644 index 0000000..e5a8640 --- /dev/null +++ b/chat_service/src/test/java/com/synapse/chat_service/repository/UserRepositoryTest.java @@ -0,0 +1,289 @@ +package com.synapse.chat_service.repository; + +import com.synapse.chat_service.domain.entity.User; +import com.synapse.chat_service.domain.repository.UserRepository; +import com.synapse.chat_service.testutil.TestObjectFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +@DisplayName("UserRepository 단위 테스트") +class UserRepositoryTest { + + @Autowired + private TestEntityManager entityManager; + + @Autowired + private UserRepository userRepository; + + private User user1; + private User user2; + + @BeforeEach + void setUp() { + // 테스트용 User 데이터 생성 + user1 = TestObjectFactory.createUser(1L, "testuser1", "testuser1@example.com"); + user2 = TestObjectFactory.createUser(2L, "testuser2", "testuser2@example.com"); + + entityManager.persistAndFlush(user1); + entityManager.persistAndFlush(user2); + } + + @Nested + @DisplayName("기본 CRUD 테스트") + class BasicCrudTest { + + @Test + @DisplayName("성공: 새로운 사용자 저장") + void save_Success() { + // given + User newUser = TestObjectFactory.createUser(3L, "newuser", "newuser@example.com"); + + // when + User savedUser = userRepository.save(newUser); + + // then + assertThat(savedUser).isNotNull(); + assertThat(savedUser.getId()).isNotNull(); + assertThat(savedUser.getUsername()).isEqualTo("newuser"); + assertThat(savedUser.getEmail()).isEqualTo("newuser@example.com"); + } + + @Test + @DisplayName("성공: ID로 사용자 조회") + void findById_Success() { + // when + Optional foundUser = userRepository.findById(user1.getId()); + + // then + assertThat(foundUser).isPresent(); + assertThat(foundUser.get().getUsername()).isEqualTo("testuser1"); + assertThat(foundUser.get().getEmail()).isEqualTo("testuser1@example.com"); + } + + @Test + @DisplayName("성공: 존재하지 않는 ID로 조회 시 빈 Optional 반환") + void findById_NotFound() { + // given + Long nonExistentId = 999L; + + // when + Optional foundUser = userRepository.findById(nonExistentId); + + // then + assertThat(foundUser).isEmpty(); + } + + @Test + @DisplayName("성공: 사용자 삭제") + void delete_Success() { + // given + Long userId = user1.getId(); + + // when + userRepository.delete(user1); + entityManager.flush(); + + // then + Optional deletedUser = userRepository.findById(userId); + assertThat(deletedUser).isEmpty(); + } + } + + @Nested + @DisplayName("findByUsername 테스트") + class FindByUsernameTest { + + @Test + @DisplayName("성공: 사용자명으로 사용자 조회") + void findByUsername_Success() { + // when + Optional foundUser = userRepository.findByUsername("testuser1"); + + // then + assertThat(foundUser).isPresent(); + assertThat(foundUser.get().getUsername()).isEqualTo("testuser1"); + assertThat(foundUser.get().getEmail()).isEqualTo("testuser1@example.com"); + } + + @Test + @DisplayName("성공: 존재하지 않는 사용자명으로 조회 시 빈 Optional 반환") + void findByUsername_NotFound() { + // when + Optional foundUser = userRepository.findByUsername("nonexistentuser"); + + // then + assertThat(foundUser).isEmpty(); + } + + @Test + @DisplayName("성공: 대소문자 구분하여 조회") + void findByUsername_CaseSensitive() { + // when + Optional foundUser = userRepository.findByUsername("TESTUSER1"); + + // then + assertThat(foundUser).isEmpty(); // 대소문자가 다르므로 찾을 수 없음 + } + } + + @Nested + @DisplayName("findByEmail 테스트") + class FindByEmailTest { + + @Test + @DisplayName("성공: 이메일로 사용자 조회") + void findByEmail_Success() { + // when + Optional foundUser = userRepository.findByEmail("testuser1@example.com"); + + // then + assertThat(foundUser).isPresent(); + assertThat(foundUser.get().getUsername()).isEqualTo("testuser1"); + assertThat(foundUser.get().getEmail()).isEqualTo("testuser1@example.com"); + } + + @Test + @DisplayName("성공: 존재하지 않는 이메일로 조회 시 빈 Optional 반환") + void findByEmail_NotFound() { + // when + Optional foundUser = userRepository.findByEmail("nonexistent@example.com"); + + // then + assertThat(foundUser).isEmpty(); + } + + @Test + @DisplayName("성공: 대소문자 구분하여 조회") + void findByEmail_CaseSensitive() { + // when + Optional foundUser = userRepository.findByEmail("TESTUSER1@EXAMPLE.COM"); + + // then + assertThat(foundUser).isEmpty(); // 대소문자가 다르므로 찾을 수 없음 + } + } + + @Nested + @DisplayName("existsByUsername 테스트") + class ExistsByUsernameTest { + + @Test + @DisplayName("성공: 존재하는 사용자명 확인") + void existsByUsername_True() { + // when + boolean exists = userRepository.existsByUsername("testuser1"); + + // then + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("성공: 존재하지 않는 사용자명 확인") + void existsByUsername_False() { + // when + boolean exists = userRepository.existsByUsername("nonexistentuser"); + + // then + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("성공: 대소문자 구분하여 확인") + void existsByUsername_CaseSensitive() { + // when + boolean exists = userRepository.existsByUsername("TESTUSER1"); + + // then + assertThat(exists).isFalse(); // 대소문자가 다르므로 존재하지 않음 + } + } + + @Nested + @DisplayName("existsByEmail 테스트") + class ExistsByEmailTest { + + @Test + @DisplayName("성공: 존재하는 이메일 확인") + void existsByEmail_True() { + // when + boolean exists = userRepository.existsByEmail("testuser1@example.com"); + + // then + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("성공: 존재하지 않는 이메일 확인") + void existsByEmail_False() { + // when + boolean exists = userRepository.existsByEmail("nonexistent@example.com"); + + // then + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("성공: 대소문자 구분하여 확인") + void existsByEmail_CaseSensitive() { + // when + boolean exists = userRepository.existsByEmail("TESTUSER1@EXAMPLE.COM"); + + // then + assertThat(exists).isFalse(); // 대소문자가 다르므로 존재하지 않음 + } + } + + @Nested + @DisplayName("중복성 검증 테스트") + class DuplicateValidationTest { + + @Test + @DisplayName("성공: 사용자명 중복 검증") + void validateUsernameDuplication() { + // given + String existingUsername = "testuser1"; + String newUsername = "newuser"; + + // when & then + assertThat(userRepository.existsByUsername(existingUsername)).isTrue(); + assertThat(userRepository.existsByUsername(newUsername)).isFalse(); + } + + @Test + @DisplayName("성공: 이메일 중복 검증") + void validateEmailDuplication() { + // given + String existingEmail = "testuser1@example.com"; + String newEmail = "newuser@example.com"; + + // when & then + assertThat(userRepository.existsByEmail(existingEmail)).isTrue(); + assertThat(userRepository.existsByEmail(newEmail)).isFalse(); + } + + @Test + @DisplayName("성공: 동일한 사용자명과 이메일로 새 사용자 생성 시 제약 조건 확인") + void validateUniqueConstraints() { + // given + User duplicateUser = TestObjectFactory.createUser(4L, "testuser1", "testuser1@example.com"); // 이미 존재하는 사용자명과 이메일 + + // when & then + // 실제로는 데이터베이스 제약 조건에 의해 예외가 발생할 것이지만, + // 여기서는 존재 여부만 확인 + assertThat(userRepository.existsByUsername(duplicateUser.getUsername())).isTrue(); + assertThat(userRepository.existsByEmail(duplicateUser.getEmail())).isTrue(); + } + } +} \ No newline at end of file diff --git a/chat_service/src/test/java/com/synapse/chat_service/service/ChatRoomServiceTest.java b/chat_service/src/test/java/com/synapse/chat_service/service/ChatRoomServiceTest.java new file mode 100644 index 0000000..d3cb678 --- /dev/null +++ b/chat_service/src/test/java/com/synapse/chat_service/service/ChatRoomServiceTest.java @@ -0,0 +1,313 @@ +package com.synapse.chat_service.service; + +import com.synapse.chat_service.domain.entity.ChatRoom; +import com.synapse.chat_service.dto.request.ChatRoomRequest; +import com.synapse.chat_service.dto.response.ChatRoomResponse; +import com.synapse.chat_service.exception.commonexception.NotFoundException; +import com.synapse.chat_service.domain.repository.ChatRoomRepository; +import com.synapse.chat_service.domain.repository.MessageRepository; +import com.synapse.chat_service.testutil.TestObjectFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatRoomService 단위 테스트") +class ChatRoomServiceTest { + + @Mock + private ChatRoomRepository chatRoomRepository; + + @Mock + private MessageRepository messageRepository; + + @InjectMocks + private ChatRoomService chatRoomService; + + private ChatRoom testChatRoom; + private UUID testChatRoomId; + + @BeforeEach + void setUp() { + testChatRoomId = UUID.randomUUID(); + testChatRoom = TestObjectFactory.createChatRoomWithId(testChatRoomId, 1L, "테스트 채팅방"); + } + + @Nested + @DisplayName("createChatRoom 테스트") + class CreateChatRoomTest { + + @Test + @DisplayName("성공: 채팅방 생성") + void createChatRoom_Success() { + // given + ChatRoomRequest.Create request = new ChatRoomRequest.Create(1L, "새 채팅방"); + ChatRoom savedChatRoom = TestObjectFactory.createChatRoomWithId(testChatRoomId, 1L, "새 채팅방"); + when(chatRoomRepository.save(any(ChatRoom.class))).thenReturn(savedChatRoom); + + // when + ChatRoomResponse.Detail result = chatRoomService.createChatRoom(request); + + // then + assertThat(result.id()).isEqualTo(testChatRoomId); + assertThat(result.title()).isEqualTo("새 채팅방"); + assertThat(result.userId()).isEqualTo(1L); + verify(chatRoomRepository, times(1)).save(any(ChatRoom.class)); + } + } + + @Nested + @DisplayName("getChatRoom 테스트") + class GetChatRoomTest { + + @Test + @DisplayName("성공: 채팅방 조회") + void getChatRoom_Success() { + // given + when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.of(testChatRoom)); + when(messageRepository.countByChatRoomId(testChatRoomId)).thenReturn(5L); + + // when + ChatRoomResponse.Detail result = chatRoomService.getChatRoom(testChatRoomId); + + // then + assertThat(result.id()).isEqualTo(testChatRoomId); + assertThat(result.title()).isEqualTo("테스트 채팅방"); + assertThat(result.userId()).isEqualTo(1L); + assertThat(result.messageCount()).isEqualTo(5L); + verify(chatRoomRepository, times(1)).findById(testChatRoomId); + verify(messageRepository, times(1)).countByChatRoomId(testChatRoomId); + } + + @Test + @DisplayName("실패: 채팅방을 찾을 수 없음") + void getChatRoom_Fail_NotFound() { + // given + when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.empty()); + + // when & then + assertThrows(NotFoundException.class, () -> chatRoomService.getChatRoom(testChatRoomId)); + verify(chatRoomRepository, times(1)).findById(testChatRoomId); + verify(messageRepository, never()).countByChatRoomId(any()); + } + } + + @Nested + @DisplayName("updateChatRoom 테스트") + class UpdateChatRoomTest { + + @Test + @DisplayName("성공: 채팅방 제목 수정") + void updateChatRoom_Success() { + // given + ChatRoomRequest.Update request = new ChatRoomRequest.Update("수정된 제목"); + ChatRoom updatedChatRoom = TestObjectFactory.createChatRoomWithId(testChatRoomId, 1L, "수정된 제목"); + + when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.of(testChatRoom)); + when(chatRoomRepository.save(any(ChatRoom.class))).thenReturn(updatedChatRoom); + + // when + ChatRoomResponse.Detail result = chatRoomService.updateChatRoom(testChatRoomId, request); + + // then + assertThat(result.title()).isEqualTo("수정된 제목"); + assertThat(result.userId()).isEqualTo(1L); + verify(chatRoomRepository, times(1)).findById(testChatRoomId); + verify(chatRoomRepository, times(1)).save(any(ChatRoom.class)); + } + + @Test + @DisplayName("실패: 수정할 채팅방을 찾을 수 없음") + void updateChatRoom_Fail_NotFound() { + // given + ChatRoomRequest.Update request = new ChatRoomRequest.Update("수정된 제목"); + when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.empty()); + + // when & then + assertThrows(NotFoundException.class, () -> chatRoomService.updateChatRoom(testChatRoomId, request)); + verify(chatRoomRepository, times(1)).findById(testChatRoomId); + verify(chatRoomRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("deleteChatRoom 테스트") + class DeleteChatRoomTest { + + @Test + @DisplayName("성공: 채팅방 삭제") + void deleteChatRoom_Success() { + // given + when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.of(testChatRoom)); + + // when + chatRoomService.deleteChatRoom(testChatRoomId); + + // then + verify(chatRoomRepository, times(1)).findById(testChatRoomId); + verify(chatRoomRepository, times(1)).delete(testChatRoom); + } + + @Test + @DisplayName("실패: 삭제할 채팅방을 찾을 수 없음") + void deleteChatRoom_Fail_NotFound() { + // given + when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.empty()); + + // when & then + assertThrows(NotFoundException.class, () -> chatRoomService.deleteChatRoom(testChatRoomId)); + verify(chatRoomRepository, times(1)).findById(testChatRoomId); + verify(chatRoomRepository, never()).delete(any()); + } + } + + @Nested + @DisplayName("getChatRoomsByUserId 테스트") + class GetChatRoomsByUserIdTest { + + @Test + @DisplayName("성공: 페이징을 통한 채팅방 조회") + void getChatRoomsByUserId_Success() { + // given + Long userId = 1L; + Pageable pageable = PageRequest.of(0, 2); + ChatRoom chatRoom1 = TestObjectFactory.createChatRoom(userId, "채팅방 1"); + ChatRoom chatRoom2 = TestObjectFactory.createChatRoom(userId, "채팅방 2"); + List chatRooms = Arrays.asList(chatRoom1, chatRoom2); + Page chatRoomPage = new PageImpl<>(chatRooms, pageable, 2); + + when(chatRoomRepository.findByUserIdOrderByCreatedDateDesc(userId, pageable)).thenReturn(chatRoomPage); + when(messageRepository.countByChatRoomId(any())).thenReturn(3L); + + // when + Page result = chatRoomService.getChatRoomsByUserId(userId, pageable); + + // then (기존 검증) + assertThat(result.getContent()).hasSize(2); + assertThat(result.getTotalElements()).isEqualTo(2); + verify(chatRoomRepository, times(1)).findByUserIdOrderByCreatedDateDesc(userId, pageable); + verify(messageRepository, times(2)).countByChatRoomId(any()); + + // then (개선된 DTO 변환 로직 검증) + assertThat(result.getContent().get(0).title()).isEqualTo("채팅방 1"); + assertThat(result.getContent().get(0).messageCount()).isEqualTo(3L); + assertThat(result.getContent().get(1).title()).isEqualTo("채팅방 2"); + assertThat(result.getContent().get(1).messageCount()).isEqualTo(3L); + } + + @Test + @DisplayName("성공: 빈 페이지 반환") + void getChatRoomsByUserId_EmptyPage() { + // given + Long userId = 1L; + Pageable pageable = PageRequest.of(0, 10); + Page emptyPage = new PageImpl<>(Collections.emptyList(), pageable, 0); + + when(chatRoomRepository.findByUserIdOrderByCreatedDateDesc(userId, pageable)).thenReturn(emptyPage); + + // when + Page result = chatRoomService.getChatRoomsByUserId(userId, pageable); + + // then + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); + verify(chatRoomRepository, times(1)).findByUserIdOrderByCreatedDateDesc(userId, pageable); + verify(messageRepository, never()).countByChatRoomId(any()); + } + } + + @Nested + @DisplayName("searchChatRooms 테스트") + class SearchChatRoomsTest { + + @Test + @DisplayName("성공: 키워드로 채팅방 검색") + void searchChatRooms_Success() { + // given + Long userId = 1L; + String keyword = "Java"; + ChatRoom chatRoom1 = TestObjectFactory.createChatRoom(userId, "Java 스터디"); + ChatRoom chatRoom2 = TestObjectFactory.createChatRoom(userId, "JavaScript 프로젝트"); + List searchResults = Arrays.asList(chatRoom1, chatRoom2); + + when(chatRoomRepository.findByUserIdAndTitleContaining(userId, keyword)).thenReturn(searchResults); + when(messageRepository.countByChatRoomId(any())).thenReturn(5L); + + // when + List result = chatRoomService.searchChatRooms(userId, keyword); + + // then (기존 검증) + assertThat(result).hasSize(2); + verify(chatRoomRepository, times(1)).findByUserIdAndTitleContaining(userId, keyword); + verify(messageRepository, times(2)).countByChatRoomId(any()); + + // then (개선된 DTO 변환 로직 검증) + assertThat(result.get(0).title()).isEqualTo("Java 스터디"); + assertThat(result.get(0).messageCount()).isEqualTo(5L); + + assertThat(result.get(1).title()).isEqualTo("JavaScript 프로젝트"); + assertThat(result.get(1).messageCount()).isEqualTo(5L); + } + + @Test + @DisplayName("성공: 검색 결과가 없는 경우 빈 리스트 반환") + void searchChatRooms_EmptyResult() { + // given + Long userId = 1L; + String keyword = "Python"; + when(chatRoomRepository.findByUserIdAndTitleContaining(userId, keyword)).thenReturn(Collections.emptyList()); + + // when + List result = chatRoomService.searchChatRooms(userId, keyword); + + // then + assertThat(result).isEmpty(); + verify(chatRoomRepository, times(1)).findByUserIdAndTitleContaining(userId, keyword); + verify(messageRepository, never()).countByChatRoomId(any()); + } + + @Test + @DisplayName("성공: 빈 키워드로 검색") + void searchChatRooms_EmptyKeyword() { + // given + Long userId = 1L; + String keyword = ""; + ChatRoom chatRoom = TestObjectFactory.createChatRoom(userId, "테스트 채팅방"); + List allChatRooms = Arrays.asList(chatRoom); + + when(chatRoomRepository.findByUserIdAndTitleContaining(userId, keyword)).thenReturn(allChatRooms); + when(messageRepository.countByChatRoomId(any())).thenReturn(2L); + + // when + List result = chatRoomService.searchChatRooms(userId, keyword); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).title()).isEqualTo("테스트 채팅방"); + verify(chatRoomRepository, times(1)).findByUserIdAndTitleContaining(userId, keyword); + verify(messageRepository, times(1)).countByChatRoomId(any()); + } + } +} diff --git a/chat_service/src/test/java/com/synapse/chat_service/service/MessageServiceTest.java b/chat_service/src/test/java/com/synapse/chat_service/service/MessageServiceTest.java new file mode 100644 index 0000000..df5ea7c --- /dev/null +++ b/chat_service/src/test/java/com/synapse/chat_service/service/MessageServiceTest.java @@ -0,0 +1,311 @@ +package com.synapse.chat_service.service; + +import com.synapse.chat_service.domain.entity.ChatRoom; +import com.synapse.chat_service.domain.entity.Message; +import com.synapse.chat_service.domain.entity.enums.SenderType; +import com.synapse.chat_service.dto.request.MessageRequest; +import com.synapse.chat_service.dto.response.MessageResponse; +import com.synapse.chat_service.exception.commonexception.NotFoundException; +import com.synapse.chat_service.domain.repository.ChatRoomRepository; +import com.synapse.chat_service.domain.repository.MessageRepository; +import com.synapse.chat_service.testutil.TestObjectFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("MessageService 단위 테스트") +class MessageServiceTest { + + @Mock + private MessageRepository messageRepository; + + @Mock + private ChatRoomRepository chatRoomRepository; + + @InjectMocks + private MessageService messageService; + + private ChatRoom testChatRoom; + private Message testMessage; + private UUID testChatRoomId; + private Long testMessageId; + + @BeforeEach + void setUp() { + testChatRoomId = UUID.randomUUID(); + testMessageId = 1L; + + testChatRoom = TestObjectFactory.createChatRoomWithId(testChatRoomId, 1L, "테스트 채팅방"); + testMessage = TestObjectFactory.createUserMessageWithId(testMessageId, testChatRoom, "테스트 메시지"); + } + + @Nested + @DisplayName("createMessage 테스트") + class CreateMessageTest { + + @Test + @DisplayName("성공: 메시지 생성") + void createMessage_Success() { + // given + MessageRequest.Create request = new MessageRequest.Create(testChatRoomId, SenderType.USER, "새 메시지"); + when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.of(testChatRoom)); + when(messageRepository.save(any(Message.class))).thenReturn(testMessage); + + // when + MessageResponse.Detail result = messageService.createMessage(request); + + // then + assertThat(result.id()).isEqualTo(testMessageId); + assertThat(result.chatRoomId()).isEqualTo(testChatRoomId); + assertThat(result.senderType()).isEqualTo(SenderType.USER); + assertThat(result.content()).isEqualTo("테스트 메시지"); + verify(chatRoomRepository, times(1)).findById(testChatRoomId); + verify(messageRepository, times(1)).save(any(Message.class)); + } + + @Test + @DisplayName("실패: 채팅방을 찾을 수 없음") + void createMessage_Fail_ChatRoomNotFound() { + // given + MessageRequest.Create request = new MessageRequest.Create(testChatRoomId, SenderType.USER, "새 메시지"); + when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.empty()); + + // when & then + assertThrows(NotFoundException.class, () -> messageService.createMessage(request)); + verify(chatRoomRepository, times(1)).findById(testChatRoomId); + verify(messageRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("getMessage 테스트") + class GetMessageTest { + + @Test + @DisplayName("성공: 메시지 조회") + void getMessage_Success() { + // given + when(messageRepository.findById(testMessageId)).thenReturn(Optional.of(testMessage)); + + // when + MessageResponse.Detail result = messageService.getMessage(testMessageId); + + // then + assertThat(result.id()).isEqualTo(testMessageId); + assertThat(result.chatRoomId()).isEqualTo(testChatRoomId); + assertThat(result.senderType()).isEqualTo(SenderType.USER); + assertThat(result.content()).isEqualTo("테스트 메시지"); + verify(messageRepository, times(1)).findById(testMessageId); + } + + @Test + @DisplayName("실패: 메시지를 찾을 수 없음") + void getMessage_Fail_NotFound() { + // given + when(messageRepository.findById(testMessageId)).thenReturn(Optional.empty()); + + // when & then + assertThrows(NotFoundException.class, () -> messageService.getMessage(testMessageId)); + verify(messageRepository, times(1)).findById(testMessageId); + } + } + + @Nested + @DisplayName("getMessagesByChatRoomId 테스트") + class GetMessagesByChatRoomIdTest { + + @Test + @DisplayName("성공: 채팅방별 메시지 조회") + void getMessagesByChatRoomId_Success() { + // given + List messages = Arrays.asList(testMessage); + when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.of(testChatRoom)); + when(messageRepository.findByChatRoomIdOrderByCreatedDateAsc(testChatRoomId)).thenReturn(messages); + + // when + List result = messageService.getMessagesByChatRoomId(testChatRoomId); + + // then (기존 검증) + assertThat(result).hasSize(1); + verify(chatRoomRepository, times(1)).findById(testChatRoomId); + verify(messageRepository, times(1)).findByChatRoomIdOrderByCreatedDateAsc(testChatRoomId); + + // then (개선된 DTO 변환 로직 검증) + assertThat(result.get(0).id()).isEqualTo(testMessageId); + assertThat(result.get(0).chatRoomId()).isEqualTo(testChatRoomId); + assertThat(result.get(0).senderType()).isEqualTo(SenderType.USER); + assertThat(result.get(0).content()).isEqualTo("테스트 메시지"); + } + + @Test + @DisplayName("실패: 채팅방을 찾을 수 없음") + void getMessagesByChatRoomId_Fail_ChatRoomNotFound() { + // given + when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.empty()); + + // when & then + assertThrows(NotFoundException.class, () -> messageService.getMessagesByChatRoomId(testChatRoomId)); + verify(chatRoomRepository, times(1)).findById(testChatRoomId); + verify(messageRepository, never()).findByChatRoomIdOrderByCreatedDateAsc(any()); + } + } + + @Nested + @DisplayName("getMessagesByChatRoomIdWithPaging 테스트") + class GetMessagesByChatRoomIdWithPagingTest { + + @Test + @DisplayName("성공: 페이징된 메시지 조회 (오름차순)") + void getMessagesByChatRoomIdWithPaging_Success_Ascending() { + // given + Pageable pageable = PageRequest.of(0, 10); + List messages = Arrays.asList(testMessage); + Page messagePage = new PageImpl<>(messages, pageable, 1); + + when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.of(testChatRoom)); + when(messageRepository.findByChatRoomIdOrderByCreatedDateAsc(testChatRoomId, pageable)).thenReturn(messagePage); + + // when + Page result = messageService.getMessagesByChatRoomIdWithPaging(testChatRoomId, pageable); + + // then (기존 검증) + assertThat(result.getContent()).hasSize(1); + verify(chatRoomRepository, times(1)).findById(testChatRoomId); + verify(messageRepository, times(1)).findByChatRoomIdOrderByCreatedDateAsc(testChatRoomId, pageable); + + // then (개선된 DTO 변환 로직 검증) + assertThat(result.getContent().get(0).id()).isEqualTo(testMessageId); + assertThat(result.getContent().get(0).chatRoomId()).isEqualTo(testChatRoomId); + assertThat(result.getContent().get(0).senderType()).isEqualTo(SenderType.USER); + assertThat(result.getContent().get(0).content()).isEqualTo("테스트 메시지"); + } + + @Test + @DisplayName("실패: 채팅방을 찾을 수 없음") + void getMessagesByChatRoomIdWithPaging_Fail_ChatRoomNotFound() { + // given + Pageable pageable = PageRequest.of(0, 10); + when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.empty()); + + // when & then + assertThrows(NotFoundException.class, () -> + messageService.getMessagesByChatRoomIdWithPaging(testChatRoomId, pageable)); + verify(chatRoomRepository, times(1)).findById(testChatRoomId); + verify(messageRepository, never()).findByChatRoomIdOrderByCreatedDateAsc(any(), any()); + } + } + + @Nested + @DisplayName("getRecentMessagesByChatRoomId 테스트") + class GetRecentMessagesByChatRoomIdTest { + + @Test + @DisplayName("성공: 최근 메시지 조회 (내림차순)") + void getRecentMessagesByChatRoomId_Success() { + // given + Pageable pageable = PageRequest.of(0, 10); + List messages = Arrays.asList(testMessage); + Page messagePage = new PageImpl<>(messages, pageable, 1); + + when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.of(testChatRoom)); + when(messageRepository.findByChatRoomIdOrderByCreatedDateDesc(testChatRoomId, pageable)).thenReturn(messagePage); + + // when + Page result = messageService.getMessagesRecentFirst(testChatRoomId, pageable); + + // then (기존 검증) + assertThat(result.getContent()).hasSize(1); + verify(chatRoomRepository, times(1)).findById(testChatRoomId); + verify(messageRepository, times(1)).findByChatRoomIdOrderByCreatedDateDesc(testChatRoomId, pageable); + + // then (개선된 DTO 변환 로직 검증) + assertThat(result.getContent().get(0).id()).isEqualTo(testMessageId); + assertThat(result.getContent().get(0).chatRoomId()).isEqualTo(testChatRoomId); + assertThat(result.getContent().get(0).senderType()).isEqualTo(SenderType.USER); + assertThat(result.getContent().get(0).content()).isEqualTo("테스트 메시지"); + } + } + + @Nested + @DisplayName("searchMessages 테스트") + class SearchMessagesTest { + + @Test + @DisplayName("성공: 키워드로 메시지 검색") + void searchMessages_Success() { + // given + String keyword = "테스트"; + List messages = Arrays.asList(testMessage); + when(chatRoomRepository.findById(testChatRoomId)).thenReturn(Optional.of(testChatRoom)); + when(messageRepository.findByChatRoomIdAndContentContaining(testChatRoomId, keyword)) + .thenReturn(messages); + + // when + List result = messageService.searchMessages(testChatRoomId, keyword); + + // then (기존 검증) + assertThat(result).hasSize(1); + verify(chatRoomRepository, times(1)).findById(testChatRoomId); + verify(messageRepository, times(1)).findByChatRoomIdAndContentContaining(testChatRoomId, keyword); + + // then (개선된 DTO 변환 로직 검증) + assertThat(result.get(0).id()).isEqualTo(testMessageId); + assertThat(result.get(0).chatRoomId()).isEqualTo(testChatRoomId); + assertThat(result.get(0).senderType()).isEqualTo(SenderType.USER); + assertThat(result.get(0).content()).isEqualTo("테스트 메시지"); + assertThat(result.get(0).content()).contains(keyword); + } + } + + @Nested + @DisplayName("deleteMessage 테스트") + class DeleteMessageTest { + + @Test + @DisplayName("성공: 메시지 삭제") + void deleteMessage_Success() { + // given + when(messageRepository.findById(testMessageId)).thenReturn(Optional.of(testMessage)); + + // when + messageService.deleteMessage(testMessageId); + + // then + verify(messageRepository, times(1)).findById(testMessageId); + verify(messageRepository, times(1)).delete(testMessage); + } + + @Test + @DisplayName("실패: 삭제할 메시지를 찾을 수 없음") + void deleteMessage_Fail_NotFound() { + // given + when(messageRepository.findById(testMessageId)).thenReturn(Optional.empty()); + + // when & then + assertThrows(NotFoundException.class, () -> messageService.deleteMessage(testMessageId)); + verify(messageRepository, times(1)).findById(testMessageId); + verify(messageRepository, never()).delete(any()); + } + } +} 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 new file mode 100644 index 0000000..b87ef18 --- /dev/null +++ b/chat_service/src/test/java/com/synapse/chat_service/testutil/TestObjectFactory.java @@ -0,0 +1,185 @@ +package com.synapse.chat_service.testutil; + +import com.synapse.chat_service.domain.entity.ChatRoom; +import com.synapse.chat_service.domain.entity.ChatUsage; +import com.synapse.chat_service.domain.entity.Message; +import com.synapse.chat_service.domain.entity.User; +import com.synapse.chat_service.domain.entity.enums.SenderType; +import com.synapse.chat_service.domain.entity.enums.SubscriptionType; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 테스트 객체 생성을 위한 팩토리 클래스 + * 테스트 데이터 생성 로직을 중앙에서 관리하여 유지보수성을 향상시킵니다. + */ +public class TestObjectFactory { + + // ChatRoom 생성 메서드들 + public static ChatRoom createChatRoom(Long userId, String title) { + return ChatRoom.builder() + .userId(userId) + .title(title) + .build(); + } + + public static ChatRoom createDefaultChatRoom() { + return createChatRoom(1L, "테스트 채팅방"); + } + + public static ChatRoom createChatRoomWithUserId(Long userId) { + return createChatRoom(userId, "테스트 채팅방"); + } + + public static ChatRoom createChatRoomWithTitle(String title) { + return createChatRoom(1L, title); + } + + public static ChatRoom createChatRoomWithId(UUID id, Long userId, String title) { + ChatRoom chatRoom = ChatRoom.builder() + .userId(userId) + .title(title) + .build(); + setId(chatRoom, id); + return chatRoom; + } + + public static ChatRoom createChatRoomWithCreatedDate(Long userId, String title, LocalDateTime createdDate) { + ChatRoom chatRoom = createChatRoom(userId, title); + setCreatedDate(chatRoom, createdDate); + return chatRoom; + } + + // Message 생성 메서드들 + public static Message createMessage(ChatRoom chatRoom, SenderType senderType, String content) { + return Message.builder() + .chatRoom(chatRoom) + .senderType(senderType) + .content(content) + .build(); + } + + public static Message createUserMessage(ChatRoom chatRoom, String content) { + return createMessage(chatRoom, SenderType.USER, content); + } + + public static Message createAssistantMessage(ChatRoom chatRoom, String content) { + return createMessage(chatRoom, SenderType.ASSISTANT, content); + } + + public static Message createDefaultUserMessage(ChatRoom chatRoom) { + return createUserMessage(chatRoom, "사용자 테스트 메시지"); + } + + public static Message createDefaultAssistantMessage(ChatRoom chatRoom) { + return createAssistantMessage(chatRoom, "AI 테스트 응답"); + } + + public static Message createMessageWithId(Long id, ChatRoom chatRoom, SenderType senderType, String content) { + Message message = Message.builder() + .chatRoom(chatRoom) + .senderType(senderType) + .content(content) + .build(); + setId(message, id); + return message; + } + + public static Message createUserMessageWithId(Long id, ChatRoom chatRoom, String content) { + return createMessageWithId(id, chatRoom, SenderType.USER, content); + } + + public static Message createAssistantMessageWithId(Long id, ChatRoom chatRoom, String content) { + return createMessageWithId(id, chatRoom, SenderType.ASSISTANT, content); + } + + public static Message createMessageWithCreatedDate(ChatRoom chatRoom, SenderType senderType, String content, LocalDateTime createdDate) { + Message message = createMessage(chatRoom, senderType, content); + setCreatedDate(message, createdDate); + return message; + } + + // ChatUsage 생성 메서드들 + public static ChatUsage createChatUsage(Long userId, SubscriptionType subscriptionType, Integer messageLimit) { + return ChatUsage.builder() + .userId(userId) + .subscriptionType(subscriptionType) + .messageLimit(messageLimit) + .build(); + } + + public static ChatUsage createFreeChatUsage(Long userId) { + return createChatUsage(userId, SubscriptionType.FREE, 100); + } + + public static ChatUsage createProChatUsage(Long userId) { + return createChatUsage(userId, SubscriptionType.PRO, 1000); + } + + public static ChatUsage createDefaultFreeChatUsage() { + return createFreeChatUsage(1L); + } + + public static ChatUsage createDefaultProChatUsage() { + return createProChatUsage(1L); + } + + // User 생성 메서드들 + public static User createUser(Long id, String username, String email) { + return User.builder() + .id(id) + .username(username) + .email(email) + .build(); + } + + public static User createDefaultUser() { + return createUser(1L, "testuser1", "testuser1@example.com"); + } + + public static User createUserWithId(Long id) { + return createUser(id, "testuser" + id, "testuser" + id + "@example.com"); + } + + public static User createUserWithUsername(String username) { + return createUser(1L, username, username + "@example.com"); + } + + public static User createUserWithEmail(String email) { + return createUser(1L, "testuser", email); + } + + // Private 헬퍼 메서드들 + private static void setCreatedDate(Object entity, LocalDateTime createdDate) { + try { + Field createdDateField = entity.getClass().getSuperclass().getDeclaredField("createdDate"); + createdDateField.setAccessible(true); + createdDateField.set(entity, createdDate); + } catch (Exception e) { + throw new RuntimeException("Failed to set createdDate", e); + } + } + + private static void setId(Object entity, Object id) { + try { + Field idField = entity.getClass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } catch (Exception e) { + throw new RuntimeException("Failed to set id", e); + } + } + + // 테스트용 상수들 + public static class TestConstants { + public static final Long DEFAULT_USER_ID = 1L; + public static final Long ANOTHER_USER_ID = 2L; + public static final String DEFAULT_CHAT_ROOM_TITLE = "테스트 채팅방"; + public static final String DEFAULT_USER_MESSAGE = "사용자 테스트 메시지"; + public static final String DEFAULT_ASSISTANT_MESSAGE = "AI 테스트 응답"; + public static final Integer FREE_MESSAGE_LIMIT = 100; + public static final Integer PRO_MESSAGE_LIMIT = 1000; + } +} \ No newline at end of file diff --git a/chat_service/src/test/resources/application-test.yml b/chat_service/src/test/resources/application-test.yml new file mode 100644 index 0000000..94752a0 --- /dev/null +++ b/chat_service/src/test/resources/application-test.yml @@ -0,0 +1,35 @@ +spring: + h2: + console: + enabled: true + datasource: + hikari: + driver-class-name: org.h2.Driver + jdbc-url: jdbc:h2:mem:test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: + + jpa: + database-platform: org.hibernate.dialect.PostgreSQLDialect + properties: + hibernate: + format: + sql: true + highlight: + sql: true + hbm2ddl: + auto: create + dialect: org.hibernate.dialect.PostgreSQLDialect + open-in-view: false + show-sql: true + +logging: + level: + org: + hibernate: + orm: + jdbc: + bind: info + spring: + transaction: + interceptor: info diff --git a/chat_service/src/test/resources/application.yml b/chat_service/src/test/resources/application.yml new file mode 100644 index 0000000..03c30d3 --- /dev/null +++ b/chat_service/src/test/resources/application.yml @@ -0,0 +1,3 @@ +spring: + profiles: + active: test