diff --git a/src/main/java/com/example/RealMatch/chat/presentation/config/ChatCursorConverterConfig.java b/src/main/java/com/example/RealMatch/chat/presentation/config/ChatCursorConverterConfig.java new file mode 100644 index 00000000..e5be5b8b --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/config/ChatCursorConverterConfig.java @@ -0,0 +1,25 @@ +package com.example.RealMatch.chat.presentation.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; + +import com.example.RealMatch.chat.presentation.conversion.MessageCursor; +import com.example.RealMatch.chat.presentation.conversion.RoomCursor; + +/** + * cursor 요청 파라미터를 RoomCursor/MessageCursor로 바인딩하는 설정 + */ +@Configuration +public class ChatCursorConverterConfig { + + @Bean + public Converter roomCursorConverter() { + return RoomCursor::decode; + } + + @Bean + public Converter messageCursorConverter() { + return MessageCursor::decode; + } +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/controller/ChatController.java b/src/main/java/com/example/RealMatch/chat/presentation/controller/ChatController.java new file mode 100644 index 00000000..abe47b3f --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/controller/ChatController.java @@ -0,0 +1,83 @@ +package com.example.RealMatch.chat.presentation.controller; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.example.RealMatch.chat.presentation.controller.fixture.ChatFixtureFactory; +import com.example.RealMatch.chat.presentation.conversion.MessageCursor; +import com.example.RealMatch.chat.presentation.conversion.RoomCursor; +import com.example.RealMatch.chat.presentation.dto.enums.ChatRoomFilterStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatRoomSort; +import com.example.RealMatch.chat.presentation.dto.enums.ChatRoomTab; +import com.example.RealMatch.chat.presentation.dto.request.ChatAttachmentUploadRequest; +import com.example.RealMatch.chat.presentation.dto.request.ChatRoomCreateRequest; +import com.example.RealMatch.chat.presentation.dto.response.ChatAttachmentUploadResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatMessageListResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomCreateResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomDetailResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomListResponse; +import com.example.RealMatch.chat.presentation.swagger.ChatSwagger; +import com.example.RealMatch.global.config.jwt.CustomUserDetails; +import com.example.RealMatch.global.presentation.CustomResponse; + +import jakarta.validation.Valid; + +@RestController +@RequestMapping("/api/chat") +public class ChatController implements ChatSwagger { + + @PostMapping("/rooms") + public CustomResponse createOrGetRoom( + @AuthenticationPrincipal CustomUserDetails user, + @Valid @RequestBody ChatRoomCreateRequest request + ) { + return CustomResponse.ok(ChatFixtureFactory.sampleRoomCreateResponse()); + } + + @GetMapping("/rooms") + public CustomResponse getRoomList( + @AuthenticationPrincipal CustomUserDetails user, + @RequestParam(required = false) ChatRoomTab tab, + @RequestParam(name = "status", required = false) ChatRoomFilterStatus filterStatus, + @RequestParam(required = false) ChatRoomSort sort, + @RequestParam(name = "cursor", required = false) RoomCursor roomCursor, + @RequestParam(defaultValue = "20") int size + ) { + return CustomResponse.ok(ChatFixtureFactory.sampleRoomListResponse()); + } + + @GetMapping("/rooms/{roomId}") + public CustomResponse getRoomDetail( + @AuthenticationPrincipal CustomUserDetails user, + @PathVariable Long roomId + ) { + return CustomResponse.ok(ChatFixtureFactory.sampleRoomDetailResponse(roomId)); + } + + @GetMapping("/rooms/{roomId}/messages") + public CustomResponse getMessages( + @AuthenticationPrincipal CustomUserDetails user, + @PathVariable Long roomId, + @RequestParam(name = "cursor", required = false) MessageCursor messageCursor, + @RequestParam(defaultValue = "20") int size + ) { + return CustomResponse.ok(ChatFixtureFactory.sampleMessageListResponse(roomId)); + } + + @PostMapping("/attachments") + public CustomResponse uploadAttachment( + @AuthenticationPrincipal CustomUserDetails user, + @Valid @RequestPart("request") ChatAttachmentUploadRequest request, + @RequestPart("file") MultipartFile file + ) { + return CustomResponse.ok(ChatFixtureFactory.sampleAttachmentUploadResponse(request, file)); + } +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/controller/fixture/ChatFixtureFactory.java b/src/main/java/com/example/RealMatch/chat/presentation/controller/fixture/ChatFixtureFactory.java new file mode 100644 index 00000000..2b09281c --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/controller/fixture/ChatFixtureFactory.java @@ -0,0 +1,269 @@ +package com.example.RealMatch.chat.presentation.controller.fixture; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.web.multipart.MultipartFile; + +import com.example.RealMatch.chat.presentation.conversion.MessageCursor; +import com.example.RealMatch.chat.presentation.conversion.RoomCursor; +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentType; +import com.example.RealMatch.chat.presentation.dto.enums.ChatMessageType; +import com.example.RealMatch.chat.presentation.dto.enums.ChatProposalDecisionStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatProposalDirection; +import com.example.RealMatch.chat.presentation.dto.enums.ChatProposalStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatRoomTab; +import com.example.RealMatch.chat.presentation.dto.enums.ChatSenderType; +import com.example.RealMatch.chat.presentation.dto.enums.ChatSystemMessageKind; +import com.example.RealMatch.chat.presentation.dto.request.ChatAttachmentUploadRequest; +import com.example.RealMatch.chat.presentation.dto.response.ChatAttachmentInfoResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatAttachmentUploadResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatMatchedCampaignPayloadResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatMessageListResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatMessageResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatProposalActionButtonResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatProposalActionButtonsResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatProposalCardPayloadResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatProposalStatusNoticePayloadResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomCardResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomCreateResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomDetailResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomListResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatSystemMessageResponse; + +/** + * 채팅 API 테스트를 위한 Fixture 응답 DTO를 생성하는 팩토리 (서비스 로직 완성하면 삭제할 것임) + */ +public final class ChatFixtureFactory { + + private ChatFixtureFactory() { + } + + public static ChatRoomCreateResponse sampleRoomCreateResponse() { + return new ChatRoomCreateResponse( + 3001L, + "direct:101:202", + ChatProposalDirection.NONE, + LocalDateTime.of(2025, 1, 1, 0, 0) + ); + } + + public static ChatRoomListResponse sampleRoomListResponse() { + ChatRoomCardResponse card = new ChatRoomCardResponse( + 3001L, + 202L, + "라운드랩", + "https://yt3.googleusercontent.com/ytc/AIdro_lLlKeDBBNPBO1FW7jkxvXpJyyM6CU2AR7NMx2GIjFFxQ=s900-c-k-c0x00ffffff-no-rj", + ChatProposalStatus.REVIEWING, + "안녕하세요!", + ChatMessageType.TEXT, + LocalDateTime.of(2025, 1, 1, 10, 0), + 3, + ChatRoomTab.SENT + ); + return new ChatRoomListResponse( + 5L, + 2L, + 7L, + List.of(card), + RoomCursor.of(LocalDateTime.of(2025, 1, 1, 9, 59, 59), 2999L), + true + ); + } + + public static ChatRoomDetailResponse sampleRoomDetailResponse(Long roomId) { + return new ChatRoomDetailResponse( + roomId, + 202L, + "라운드랩", + "https://yt3.googleusercontent.com/ytc/AIdro_lLlKeDBBNPBO1FW7jkxvXpJyyM6CU2AR7NMx2GIjFFxQ=s900-c-k-c0x00ffffff-no-rj", + List.of("청정자극", "저자극", "심플한 감성"), + ChatProposalStatus.REVIEWING, + ChatProposalStatus.REVIEWING.labelOrNull() + ); + } + + public static ChatMessageListResponse sampleMessageListResponse(Long roomId) { + return new ChatMessageListResponse( + List.of( + sampleMatchedCampaignMessage(roomId), + sampleProposalStatusNoticeMessage(roomId), + sampleSystemMessage(roomId), + sampleFileMessage(roomId), + sampleImageMessage(roomId), + sampleTextMessage(roomId) + ), + MessageCursor.of(6999L), + true + ); + } + + private static ChatMessageResponse sampleTextMessage(Long roomId) { + return new ChatMessageResponse( + 7001L, + roomId, + 202L, + ChatSenderType.USER, + ChatMessageType.TEXT, + "안녕하세요!", + null, + null, + LocalDateTime.of(2025, 1, 1, 10, 2), + "11111111-1111-1111-1111-111111111111" + ); + } + + private static ChatMessageResponse sampleImageMessage(Long roomId) { + ChatAttachmentInfoResponse attachment = new ChatAttachmentInfoResponse( + 9001L, + ChatAttachmentType.IMAGE, + "image/png", + "photo.png", + 204800L, + "https://img.cjnews.cj.net/wp-content/uploads/2020/11/CJ%EC%98%AC%EB%A6%AC%EB%B8%8C%EC%98%81-%EC%98%AC%EB%A6%AC%EB%B8%8C%EC%98%81-%EC%83%88-BI-%EB%A1%9C%EA%B3%A0.jpg", + ChatAttachmentStatus.READY + ); + return new ChatMessageResponse( + 7002L, + roomId, + 202L, + ChatSenderType.USER, + ChatMessageType.IMAGE, + null, + attachment, + null, + LocalDateTime.of(2025, 1, 1, 10, 3), + "22222222-2222-2222-2222-222222222222" + ); + } + + private static ChatMessageResponse sampleFileMessage(Long roomId) { + ChatAttachmentInfoResponse attachment = new ChatAttachmentInfoResponse( + 9002L, + ChatAttachmentType.FILE, + "application/pdf", + "proposal.pdf", + 102400L, + "https://cdn.example.com/attachments/9002.pdf", + ChatAttachmentStatus.READY + ); + return new ChatMessageResponse( + 7003L, + roomId, + 202L, + ChatSenderType.USER, + ChatMessageType.FILE, + null, + attachment, + null, + LocalDateTime.of(2025, 1, 1, 10, 4), + "33333333-3333-3333-3333-333333333333" + ); + } + + private static ChatMessageResponse sampleSystemMessage(Long roomId) { + ChatSystemMessageResponse systemMessage = new ChatSystemMessageResponse( + 1, + ChatSystemMessageKind.PROPOSAL_CARD, + new ChatProposalCardPayloadResponse( + 5001L, + 4001L, + "캠페인 A", + "캠페인 요약 문구", + ChatProposalDecisionStatus.PENDING, + ChatProposalDirection.BRAND_TO_CREATOR, + new ChatProposalActionButtonsResponse( + new ChatProposalActionButtonResponse( + "제안 수락하기", + true + ), + new ChatProposalActionButtonResponse( + "거절하기", + true + ) + ), + null + ) + ); + return new ChatMessageResponse( + 7004L, + roomId, + null, + ChatSenderType.SYSTEM, + ChatMessageType.SYSTEM, + null, + null, + systemMessage, + LocalDateTime.of(2025, 1, 1, 10, 5), + null + ); + } + + private static ChatMessageResponse sampleProposalStatusNoticeMessage(Long roomId) { + ChatSystemMessageResponse systemMessage = new ChatSystemMessageResponse( + 1, + ChatSystemMessageKind.PROPOSAL_STATUS_NOTICE, + new ChatProposalStatusNoticePayloadResponse( + 5001L, + 202L, + LocalDateTime.of(2025, 1, 1, 10, 6) + ) + ); + return new ChatMessageResponse( + 7005L, + roomId, + null, + ChatSenderType.SYSTEM, + ChatMessageType.SYSTEM, + null, + null, + systemMessage, + LocalDateTime.of(2025, 1, 1, 10, 6), + null + ); + } + + private static ChatMessageResponse sampleMatchedCampaignMessage(Long roomId) { + ChatSystemMessageResponse systemMessage = new ChatSystemMessageResponse( + 1, + ChatSystemMessageKind.MATCHED_CAMPAIGN_CARD, + new ChatMatchedCampaignPayloadResponse( + 4001L, + "캠페인 A", + 150000L, + "KRW", + "ORDER-20250101-0001", + "캠페인이 매칭되었습니다. 협업을 시작해 주세요." + ) + ); + return new ChatMessageResponse( + 7006L, + roomId, + null, + ChatSenderType.SYSTEM, + ChatMessageType.SYSTEM, + null, + null, + systemMessage, + LocalDateTime.of(2025, 1, 1, 10, 7), + null + ); + } + + public static ChatAttachmentUploadResponse sampleAttachmentUploadResponse( + ChatAttachmentUploadRequest request, + MultipartFile file + ) { + return new ChatAttachmentUploadResponse( + 9001L, + request.attachmentType(), + file.getContentType(), + file.getOriginalFilename(), + file.getSize(), + "https://cdn.example.com/attachments/9001", + ChatAttachmentStatus.UPLOADED, + LocalDateTime.of(2025, 1, 1, 10, 10) + ); + } +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/conversion/MessageCursor.java b/src/main/java/com/example/RealMatch/chat/presentation/conversion/MessageCursor.java new file mode 100644 index 00000000..322e7226 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/conversion/MessageCursor.java @@ -0,0 +1,28 @@ +package com.example.RealMatch.chat.presentation.conversion; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public record MessageCursor(Long messageId) { + + public static MessageCursor of(Long messageId) { + return new MessageCursor(messageId); + } + + @JsonCreator + public static MessageCursor decode(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return new MessageCursor(Long.valueOf(value)); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException("Message Cursor messageId 형식이 올바르지 않습니다.", ex); + } + } + + @JsonValue + public String encode() { + return messageId.toString(); + } +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/conversion/RoomCursor.java b/src/main/java/com/example/RealMatch/chat/presentation/conversion/RoomCursor.java new file mode 100644 index 00000000..6e6c2ea1 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/conversion/RoomCursor.java @@ -0,0 +1,44 @@ +package com.example.RealMatch.chat.presentation.conversion; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public record RoomCursor(LocalDateTime lastMessageAt, Long roomId) { + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + + public static RoomCursor of(LocalDateTime lastMessageAt, Long roomId) { + return new RoomCursor(lastMessageAt, roomId); + } + + @JsonCreator + public static RoomCursor decode(String value) { + if (value == null || value.isBlank()) { + return null; + } + String[] parts = value.split("\\|", -1); + if (parts.length != 2) { + throw new IllegalArgumentException("Room Cursor 형식이 올바르지 않습니다."); + } + LocalDateTime lastMessageAt; + try { + lastMessageAt = LocalDateTime.parse(parts[0], FORMATTER); + } catch (RuntimeException ex) { + throw new IllegalArgumentException("Room Cursor lastMessageAt 형식이 올바르지 않습니다.", ex); + } + Long roomId; + try { + roomId = Long.valueOf(parts[1]); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException("Room Cursor roomId 형식이 올바르지 않습니다.", ex); + } + return new RoomCursor(lastMessageAt, roomId); + } + + @JsonValue + public String encode() { + return FORMATTER.format(lastMessageAt) + "|" + roomId; + } +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatAttachmentStatus.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatAttachmentStatus.java new file mode 100644 index 00000000..d74827a9 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatAttachmentStatus.java @@ -0,0 +1,7 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatAttachmentStatus { + UPLOADED, + READY, + FAILED +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatAttachmentType.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatAttachmentType.java new file mode 100644 index 00000000..f0753553 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatAttachmentType.java @@ -0,0 +1,6 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatAttachmentType { + IMAGE, + FILE +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatMessageType.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatMessageType.java new file mode 100644 index 00000000..b703d21a --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatMessageType.java @@ -0,0 +1,8 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatMessageType { + TEXT, + IMAGE, + FILE, + SYSTEM +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalDecisionStatus.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalDecisionStatus.java new file mode 100644 index 00000000..79fd0579 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalDecisionStatus.java @@ -0,0 +1,8 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatProposalDecisionStatus { + PENDING, + ACCEPTED, + REJECTED, + EXPIRED +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalDirection.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalDirection.java new file mode 100644 index 00000000..e2912684 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalDirection.java @@ -0,0 +1,7 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatProposalDirection { + NONE, + BRAND_TO_CREATOR, + CREATOR_TO_BRAND +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalStatus.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalStatus.java new file mode 100644 index 00000000..bff1976f --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatProposalStatus.java @@ -0,0 +1,22 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatProposalStatus { + MATCHED("매칭"), + REVIEWING("검토중"), + REJECTED("거절"), + NONE(""); + + private final String label; + + ChatProposalStatus(String label) { + this.label = label; + } + + public String label() { + return label; + } + + public String labelOrNull() { + return label == null || label.isBlank() ? null : label; + } +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomFilterStatus.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomFilterStatus.java new file mode 100644 index 00000000..84cb02e3 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomFilterStatus.java @@ -0,0 +1,8 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatRoomFilterStatus { + MATCHED, + REVIEWING, + REJECTED, + ALL +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomSort.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomSort.java new file mode 100644 index 00000000..00b05ecc --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomSort.java @@ -0,0 +1,5 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatRoomSort { + LATEST +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomTab.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomTab.java new file mode 100644 index 00000000..a9cfd9ee --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatRoomTab.java @@ -0,0 +1,7 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatRoomTab { + SENT, + RECEIVED, + ALL +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSenderType.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSenderType.java new file mode 100644 index 00000000..5532fb7c --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSenderType.java @@ -0,0 +1,6 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatSenderType { + USER, + SYSTEM +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSystemMessageKind.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSystemMessageKind.java new file mode 100644 index 00000000..6f09384f --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSystemMessageKind.java @@ -0,0 +1,7 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatSystemMessageKind { + PROPOSAL_CARD, + PROPOSAL_STATUS_NOTICE, + MATCHED_CAMPAIGN_CARD +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/request/ChatAttachmentUploadRequest.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/request/ChatAttachmentUploadRequest.java new file mode 100644 index 00000000..be7bb41e --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/request/ChatAttachmentUploadRequest.java @@ -0,0 +1,10 @@ +package com.example.RealMatch.chat.presentation.dto.request; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentType; + +import jakarta.validation.constraints.NotNull; + +public record ChatAttachmentUploadRequest( + @NotNull ChatAttachmentType attachmentType +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/request/ChatRoomCreateRequest.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/request/ChatRoomCreateRequest.java new file mode 100644 index 00000000..ccd173ce --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/request/ChatRoomCreateRequest.java @@ -0,0 +1,9 @@ +package com.example.RealMatch.chat.presentation.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record ChatRoomCreateRequest( + @NotNull Long brandId, + @NotNull Long creatorId +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatAttachmentInfoResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatAttachmentInfoResponse.java new file mode 100644 index 00000000..866cf97f --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatAttachmentInfoResponse.java @@ -0,0 +1,15 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentType; + +public record ChatAttachmentInfoResponse( + Long attachmentId, + ChatAttachmentType attachmentType, + String contentType, + String originalName, + Long fileSize, + String accessUrl, + ChatAttachmentStatus status +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatAttachmentUploadResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatAttachmentUploadResponse.java new file mode 100644 index 00000000..863f203b --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatAttachmentUploadResponse.java @@ -0,0 +1,18 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import java.time.LocalDateTime; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentType; + +public record ChatAttachmentUploadResponse( + Long attachmentId, + ChatAttachmentType attachmentType, + String contentType, + String originalName, + Long fileSize, + String accessUrl, + ChatAttachmentStatus status, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMatchedCampaignPayloadResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMatchedCampaignPayloadResponse.java new file mode 100644 index 00000000..ea2e6cf1 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMatchedCampaignPayloadResponse.java @@ -0,0 +1,11 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +public record ChatMatchedCampaignPayloadResponse( + Long campaignId, + String campaignName, + long amount, + String currency, + String orderNumber, + String message +) implements ChatSystemMessagePayload { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMessageListResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMessageListResponse.java new file mode 100644 index 00000000..75dadc5d --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMessageListResponse.java @@ -0,0 +1,12 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import java.util.List; + +import com.example.RealMatch.chat.presentation.conversion.MessageCursor; + +public record ChatMessageListResponse( + List messages, + MessageCursor nextCursor, + boolean hasNext +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMessageResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMessageResponse.java new file mode 100644 index 00000000..258158e3 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatMessageResponse.java @@ -0,0 +1,20 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import java.time.LocalDateTime; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatMessageType; +import com.example.RealMatch.chat.presentation.dto.enums.ChatSenderType; + +public record ChatMessageResponse( + Long messageId, + Long roomId, + Long senderId, + ChatSenderType senderType, + ChatMessageType messageType, + String content, + ChatAttachmentInfoResponse attachment, + ChatSystemMessageResponse systemMessage, + LocalDateTime createdAt, + String clientMessageId +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalActionButtonResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalActionButtonResponse.java new file mode 100644 index 00000000..26e6a633 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalActionButtonResponse.java @@ -0,0 +1,7 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +public record ChatProposalActionButtonResponse( + String label, + boolean enabled +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalActionButtonsResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalActionButtonsResponse.java new file mode 100644 index 00000000..8104bded --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalActionButtonsResponse.java @@ -0,0 +1,7 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +public record ChatProposalActionButtonsResponse( + ChatProposalActionButtonResponse accept, + ChatProposalActionButtonResponse reject +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalCardPayloadResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalCardPayloadResponse.java new file mode 100644 index 00000000..6b34589c --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalCardPayloadResponse.java @@ -0,0 +1,18 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import java.time.LocalDateTime; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatProposalDecisionStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatProposalDirection; + +public record ChatProposalCardPayloadResponse( + Long proposalId, + Long campaignId, + String campaignName, + String campaignSummary, + ChatProposalDecisionStatus proposalStatus, + ChatProposalDirection proposalDirection, + ChatProposalActionButtonsResponse buttons, + LocalDateTime expiresAt +) implements ChatSystemMessagePayload { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalStatusNoticePayloadResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalStatusNoticePayloadResponse.java new file mode 100644 index 00000000..7d3ac0c7 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatProposalStatusNoticePayloadResponse.java @@ -0,0 +1,10 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import java.time.LocalDateTime; + +public record ChatProposalStatusNoticePayloadResponse( + Long proposalId, + Long actorUserId, + LocalDateTime processedAt +) implements ChatSystemMessagePayload { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomCardResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomCardResponse.java new file mode 100644 index 00000000..e8c4ab40 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomCardResponse.java @@ -0,0 +1,21 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import java.time.LocalDateTime; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatMessageType; +import com.example.RealMatch.chat.presentation.dto.enums.ChatProposalStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatRoomTab; + +public record ChatRoomCardResponse( + Long roomId, + Long opponentUserId, + String opponentName, + String opponentProfileImageUrl, + ChatProposalStatus proposalStatus, + String lastMessagePreview, + ChatMessageType lastMessageType, + LocalDateTime lastMessageAt, + int unreadCount, + ChatRoomTab tabCategory +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomCreateResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomCreateResponse.java new file mode 100644 index 00000000..483b3b90 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomCreateResponse.java @@ -0,0 +1,13 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import java.time.LocalDateTime; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatProposalDirection; + +public record ChatRoomCreateResponse( + Long roomId, + String roomKey, + ChatProposalDirection lastProposalDirection, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomDetailResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomDetailResponse.java new file mode 100644 index 00000000..9f330cc7 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomDetailResponse.java @@ -0,0 +1,16 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import java.util.List; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatProposalStatus; + +public record ChatRoomDetailResponse( + Long roomId, + Long opponentUserId, + String opponentName, + String opponentProfileImageUrl, + List opponentTags, + ChatProposalStatus proposalStatus, + String proposalStatusLabel +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomListResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomListResponse.java new file mode 100644 index 00000000..4fc35afd --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatRoomListResponse.java @@ -0,0 +1,15 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import java.util.List; + +import com.example.RealMatch.chat.presentation.conversion.RoomCursor; + +public record ChatRoomListResponse( + long sentTabUnreadCount, + long receivedTabUnreadCount, + Long totalUnreadCount, + List rooms, + RoomCursor nextCursor, + boolean hasNext +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatSystemMessagePayload.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatSystemMessagePayload.java new file mode 100644 index 00000000..0353e8c9 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatSystemMessagePayload.java @@ -0,0 +1,4 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +public interface ChatSystemMessagePayload { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatSystemMessageResponse.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatSystemMessageResponse.java new file mode 100644 index 00000000..cec6619c --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/response/ChatSystemMessageResponse.java @@ -0,0 +1,10 @@ +package com.example.RealMatch.chat.presentation.dto.response; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatSystemMessageKind; + +public record ChatSystemMessageResponse( + int schemaVersion, + ChatSystemMessageKind kind, + ChatSystemMessagePayload payload +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/swagger/ChatSwagger.java b/src/main/java/com/example/RealMatch/chat/presentation/swagger/ChatSwagger.java new file mode 100644 index 00000000..8dbc8478 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/swagger/ChatSwagger.java @@ -0,0 +1,116 @@ +package com.example.RealMatch.chat.presentation.swagger; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +import com.example.RealMatch.chat.presentation.conversion.MessageCursor; +import com.example.RealMatch.chat.presentation.conversion.RoomCursor; +import com.example.RealMatch.chat.presentation.dto.enums.ChatRoomFilterStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatRoomSort; +import com.example.RealMatch.chat.presentation.dto.enums.ChatRoomTab; +import com.example.RealMatch.chat.presentation.dto.request.ChatAttachmentUploadRequest; +import com.example.RealMatch.chat.presentation.dto.request.ChatRoomCreateRequest; +import com.example.RealMatch.chat.presentation.dto.response.ChatAttachmentUploadResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatMessageListResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomCreateResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomDetailResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatRoomListResponse; +import com.example.RealMatch.global.config.jwt.CustomUserDetails; +import com.example.RealMatch.global.presentation.CustomResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "chat", description = "채팅 REST API") +@RequestMapping("/api/chat") +public interface ChatSwagger { + + @Operation(summary = "채팅방 생성/조회 API By 여채현", + description = """ + brandId, creatorId 기준으로 1:1 채팅방을 생성하거나 기존 방을 반환합니다. + 생성 직후 방 정보를 내려주며, 필요 시 상세 헤더는 별도 조회로 보완합니다. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "채팅방 생성/조회 성공"), + @ApiResponse(responseCode = "COMMON401_1", description = "인증이 필요합니다.") + }) + CustomResponse createOrGetRoom( + @AuthenticationPrincipal CustomUserDetails user, + @Valid @RequestBody ChatRoomCreateRequest request + ); + + @Operation(summary = "채팅방 목록 조회 API By 여채현", + description = """ + tab/status/sort 기준으로 채팅방 목록을 조회합니다. + sort=LATEST는 lastMessageAt desc, roomId desc 기준으로 정렬합니다. + cursor는 lastMessageAt|roomId 포맷을 그대로 재사용하세요. + 메시지가 없는 방은 목록에서 제외됩니다. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "채팅방 목록 조회 성공"), + @ApiResponse(responseCode = "COMMON401_1", description = "인증이 필요합니다.") + }) + CustomResponse getRoomList( + @AuthenticationPrincipal CustomUserDetails user, + @RequestParam(required = false) ChatRoomTab tab, + @RequestParam(name = "status", required = false) ChatRoomFilterStatus filterStatus, + @RequestParam(required = false) ChatRoomSort sort, + @RequestParam(name = "cursor", required = false) RoomCursor roomCursor, + @RequestParam(defaultValue = "20") int size + ); + + @Operation(summary = "채팅방 상세 조회 API By 여채현", + description = """ + 채팅방 헤더에 필요한 상대 정보와 상태 값을 반환합니다. + 태그/상태 라벨 등 UI 구성에 필요한 필드를 포함합니다. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "채팅방 상세 조회 성공"), + @ApiResponse(responseCode = "COMMON401_1", description = "인증이 필요합니다.") + }) + CustomResponse getRoomDetail( + @AuthenticationPrincipal CustomUserDetails user, + @PathVariable Long roomId + ); + + @Operation(summary = "채팅 메시지 조회 API By 여채현", + description = """ + messageId desc 기준으로 메시지를 조회합니다. + cursor는 해당 id보다 작은 메시지를 조회하는 기준입니다. + 렌더링 기준은 messageType이며, 타입별 필드는 배타적으로 사용됩니다. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "채팅 메시지 조회 성공"), + @ApiResponse(responseCode = "COMMON401_1", description = "인증이 필요합니다.") + }) + CustomResponse getMessages( + @AuthenticationPrincipal CustomUserDetails user, + @PathVariable Long roomId, + @RequestParam(name = "cursor", required = false) MessageCursor messageCursor, + @RequestParam(defaultValue = "20") int size + ); + + @Operation(summary = "첨부 업로드 API By 여채현", + description = """ + 첨부 파일을 업로드하고 메타 정보를 반환합니다. + UPLOADED 상태여도 accessUrl을 즉시 사용할 수 있습니다. + READY는 내부 상태값입니다. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "첨부 업로드 성공"), + @ApiResponse(responseCode = "COMMON401_1", description = "인증이 필요합니다.") + }) + CustomResponse uploadAttachment( + @AuthenticationPrincipal CustomUserDetails user, + @Valid @RequestPart("request") ChatAttachmentUploadRequest request, + @RequestPart("file") MultipartFile file + ); +} diff --git a/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java b/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java index c1bfe019..09c4535b 100644 --- a/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java +++ b/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java @@ -27,6 +27,7 @@ public class SecurityConfig { private static final String[] PERMIT_ALL_URL_ARRAY = { "/api/test", + "/api/chat/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", "/swagger-ui.html" }; diff --git a/src/test/java/com/example/RealMatch/chat/presentation/controller/ChatControllerTest.java b/src/test/java/com/example/RealMatch/chat/presentation/controller/ChatControllerTest.java new file mode 100644 index 00000000..f24e5e47 --- /dev/null +++ b/src/test/java/com/example/RealMatch/chat/presentation/controller/ChatControllerTest.java @@ -0,0 +1,180 @@ +package com.example.RealMatch.chat.presentation.controller; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +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.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.example.RealMatch.chat.presentation.config.ChatCursorConverterConfig; +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentType; +import com.example.RealMatch.chat.presentation.dto.request.ChatAttachmentUploadRequest; +import com.example.RealMatch.chat.presentation.dto.request.ChatRoomCreateRequest; +import com.example.RealMatch.global.config.jwt.JwtProvider; +import com.fasterxml.jackson.databind.ObjectMapper; +@SuppressWarnings("null") +@WebMvcTest(ChatController.class) +@AutoConfigureMockMvc(addFilters = false) +@Import(ChatCursorConverterConfig.class) +class ChatControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private JwtProvider jwtProvider; + + @Test + @DisplayName("채팅방 생성/조회: 200 + 공통 응답 포맷") + void createOrGetRoom_returnsOk() throws Exception { + ChatRoomCreateRequest request = new ChatRoomCreateRequest(101L, 202L); + + mockMvc.perform(post("/api/chat/rooms") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200_1")) + .andExpect(jsonPath("$.message").value("정상적인 요청입니다.")) + .andExpect(jsonPath("$.result.roomId").value(3001)) + .andExpect(jsonPath("$.result.roomKey").value("direct:101:202")) + .andExpect(jsonPath("$.result.lastProposalDirection").value("NONE")) + .andExpect(jsonPath("$.result.createdAt").value("2025-01-01T00:00:00")); + } + + @Test + @DisplayName("채팅방 목록 조회: 200 + 공통 응답 포맷") + void getRoomList_returnsOk() throws Exception { + mockMvc.perform(get("/api/chat/rooms") + .param("tab", "SENT") + .param("status", "ALL") + .param("sort", "LATEST") + .param("cursor", "2025-01-01T10:00:00|3001") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200_1")) + .andExpect(jsonPath("$.message").value("정상적인 요청입니다.")) + .andExpect(jsonPath("$.result.sentTabUnreadCount").value(5)) + .andExpect(jsonPath("$.result.receivedTabUnreadCount").value(2)) + .andExpect(jsonPath("$.result.totalUnreadCount").value(7)) + .andExpect(jsonPath("$.result.rooms", hasSize(1))) + .andExpect(jsonPath("$.result.rooms[0].roomId").value(3001)) + .andExpect(jsonPath("$.result.rooms[0].opponentUserId").value(202)) + .andExpect(jsonPath("$.result.rooms[0].opponentName").value("라운드랩")) + .andExpect(jsonPath("$.result.rooms[0].proposalStatus").value("REVIEWING")) + .andExpect(jsonPath("$.result.rooms[0].lastMessageType").value("TEXT")) + .andExpect(jsonPath("$.result.rooms[0].tabCategory").value("SENT")) + .andExpect(jsonPath("$.result.rooms[0].unreadCount").value(3)) + .andExpect(jsonPath("$.result.nextCursor").value("2025-01-01T09:59:59|2999")) + .andExpect(jsonPath("$.result.hasNext", is(true))); + } + + @Test + @DisplayName("채팅방 상세 조회: 200 + 공통 응답 포맷") + void getRoomDetail_returnsOk() throws Exception { + mockMvc.perform(get("/api/chat/rooms/{roomId}", 3001L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200_1")) + .andExpect(jsonPath("$.message").value("정상적인 요청입니다.")) + .andExpect(jsonPath("$.result.roomId").value(3001)) + .andExpect(jsonPath("$.result.opponentUserId").value(202)) + .andExpect(jsonPath("$.result.opponentName").value("라운드랩")) + .andExpect(jsonPath("$.result.opponentTags", hasSize(3))) + .andExpect(jsonPath("$.result.proposalStatus").value("REVIEWING")) + .andExpect(jsonPath("$.result.proposalStatusLabel").value("검토중")); + } + + @Test + @DisplayName("채팅 메시지 조회: 200 + 공통 응답 포맷") + void getMessages_returnsOk() throws Exception { + mockMvc.perform(get("/api/chat/rooms/{roomId}/messages", 3001L) + .param("cursor", "7001") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200_1")) + .andExpect(jsonPath("$.message").value("정상적인 요청입니다.")) + .andExpect(jsonPath("$.result.messages", hasSize(6))) + .andExpect(jsonPath("$.result.messages[0].messageId").value(7006)) + .andExpect(jsonPath("$.result.messages[0].senderType").value("SYSTEM")) + .andExpect(jsonPath("$.result.messages[0].messageType").value("SYSTEM")) + .andExpect(jsonPath("$.result.messages[0].attachment").doesNotExist()) + .andExpect(jsonPath("$.result.messages[0].systemMessage.kind").value("MATCHED_CAMPAIGN_CARD")) + .andExpect(jsonPath("$.result.messages[1].messageId").value(7005)) + .andExpect(jsonPath("$.result.messages[1].senderType").value("SYSTEM")) + .andExpect(jsonPath("$.result.messages[1].messageType").value("SYSTEM")) + .andExpect(jsonPath("$.result.messages[1].attachment").doesNotExist()) + .andExpect(jsonPath("$.result.messages[1].systemMessage.kind").value("PROPOSAL_STATUS_NOTICE")) + .andExpect(jsonPath("$.result.messages[2].messageId").value(7004)) + .andExpect(jsonPath("$.result.messages[2].senderType").value("SYSTEM")) + .andExpect(jsonPath("$.result.messages[2].messageType").value("SYSTEM")) + .andExpect(jsonPath("$.result.messages[2].attachment").doesNotExist()) + .andExpect(jsonPath("$.result.messages[2].systemMessage.kind").value("PROPOSAL_CARD")) + .andExpect(jsonPath("$.result.messages[3].messageId").value(7003)) + .andExpect(jsonPath("$.result.messages[3].senderType").value("USER")) + .andExpect(jsonPath("$.result.messages[3].messageType").value("FILE")) + .andExpect(jsonPath("$.result.messages[3].attachment.attachmentId").value(9002)) + .andExpect(jsonPath("$.result.messages[3].systemMessage").doesNotExist()) + .andExpect(jsonPath("$.result.messages[4].messageId").value(7002)) + .andExpect(jsonPath("$.result.messages[4].senderType").value("USER")) + .andExpect(jsonPath("$.result.messages[4].messageType").value("IMAGE")) + .andExpect(jsonPath("$.result.messages[4].attachment.attachmentId").value(9001)) + .andExpect(jsonPath("$.result.messages[4].systemMessage").doesNotExist()) + .andExpect(jsonPath("$.result.messages[5].messageId").value(7001)) + .andExpect(jsonPath("$.result.messages[5].senderType").value("USER")) + .andExpect(jsonPath("$.result.messages[5].messageType").value("TEXT")) + .andExpect(jsonPath("$.result.messages[5].attachment").doesNotExist()) + .andExpect(jsonPath("$.result.messages[5].systemMessage").doesNotExist()) + .andExpect(jsonPath("$.result.nextCursor").value("6999")) + .andExpect(jsonPath("$.result.hasNext", is(true))); + } + + @Test + @DisplayName("첨부 업로드: 200 + 공통 응답 포맷") + void uploadAttachment_returnsOk() throws Exception { + ChatAttachmentUploadRequest request = new ChatAttachmentUploadRequest( + ChatAttachmentType.IMAGE); + + MockMultipartFile jsonPart = new MockMultipartFile( + "request", + "request", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + MockMultipartFile filePart = new MockMultipartFile( + "file", + "photo.png", + MediaType.IMAGE_PNG_VALUE, + "dummy".getBytes() + ); + + mockMvc.perform(multipart("/api/chat/attachments") + .file(jsonPart) + .file(filePart)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200_1")) + .andExpect(jsonPath("$.message").value("정상적인 요청입니다.")) + .andExpect(jsonPath("$.result.attachmentId").value(9001)) + .andExpect(jsonPath("$.result.attachmentType").value("IMAGE")) + .andExpect(jsonPath("$.result.status").value("UPLOADED")); + } +}