From f877b4a215d3a6b01b624cfcc7a36d37e07b3796 Mon Sep 17 00:00:00 2001 From: haerxeong Date: Tue, 8 Sep 2026 19:10:08 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=B0=A8=EB=8B=A8=20=EB=B0=8F=20=EC=8B=A0?= =?UTF-8?q?=EA=B3=A0=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chat/code/ChatErrorCode.java | 4 +- .../chat/controller/ChatRoomController.java | 35 +++ .../swagger/BlockChatPartnerSwagger.java | 65 ++++++ .../swagger/ReportChatPartnerSwagger.java | 80 +++++++ .../dto/request/ReportChatPartnerRequest.java | 15 ++ .../domain/chat/entity/ChatUserBlock.java | 53 +++++ .../domain/chat/entity/ChatUserBlockId.java | 27 +++ .../domain/chat/entity/ChatUserReport.java | 61 +++++ .../repository/ChatRoomMemberRepository.java | 11 + .../repository/ChatUserBlockRepository.java | 33 +++ .../repository/ChatUserReportRepository.java | 7 + .../service/ChatMessageCommandService.java | 11 + .../chat/service/ChatReportMailSender.java | 76 +++++++ .../chat/service/ChatUserBlockService.java | 44 ++++ .../service/ChatUserReportCommandService.java | 28 +++ .../chat/service/ChatUserReportService.java | 73 ++++++ .../buddys/global/mail/MailProperties.java | 3 +- src/main/resources/application.yaml | 1 + ...eate_chat_user_block_and_report_tables.sql | 22 ++ .../controller/ChatRoomControllerTest.java | 80 ++++++- .../ChatUserReportRepositoryTest.java | 96 ++++++++ .../ChatMessageCommandServiceTest.java | 157 +++++++++++++ .../service/ChatUserBlockServiceTest.java | 143 ++++++++++++ .../service/ChatUserReportServiceTest.java | 212 ++++++++++++++++++ .../domain/user/service/UserServiceTest.java | 7 +- src/test/resources/application-test.yaml | 1 + 26 files changed, 1338 insertions(+), 7 deletions(-) create mode 100644 src/main/java/org/sopt/buddys/domain/chat/controller/swagger/BlockChatPartnerSwagger.java create mode 100644 src/main/java/org/sopt/buddys/domain/chat/controller/swagger/ReportChatPartnerSwagger.java create mode 100644 src/main/java/org/sopt/buddys/domain/chat/dto/request/ReportChatPartnerRequest.java create mode 100644 src/main/java/org/sopt/buddys/domain/chat/entity/ChatUserBlock.java create mode 100644 src/main/java/org/sopt/buddys/domain/chat/entity/ChatUserBlockId.java create mode 100644 src/main/java/org/sopt/buddys/domain/chat/entity/ChatUserReport.java create mode 100644 src/main/java/org/sopt/buddys/domain/chat/repository/ChatUserBlockRepository.java create mode 100644 src/main/java/org/sopt/buddys/domain/chat/repository/ChatUserReportRepository.java create mode 100644 src/main/java/org/sopt/buddys/domain/chat/service/ChatReportMailSender.java create mode 100644 src/main/java/org/sopt/buddys/domain/chat/service/ChatUserBlockService.java create mode 100644 src/main/java/org/sopt/buddys/domain/chat/service/ChatUserReportCommandService.java create mode 100644 src/main/java/org/sopt/buddys/domain/chat/service/ChatUserReportService.java create mode 100644 src/main/resources/db/migration/V27__create_chat_user_block_and_report_tables.sql create mode 100644 src/test/java/org/sopt/buddys/domain/chat/repository/ChatUserReportRepositoryTest.java create mode 100644 src/test/java/org/sopt/buddys/domain/chat/service/ChatMessageCommandServiceTest.java create mode 100644 src/test/java/org/sopt/buddys/domain/chat/service/ChatUserBlockServiceTest.java create mode 100644 src/test/java/org/sopt/buddys/domain/chat/service/ChatUserReportServiceTest.java diff --git a/src/main/java/org/sopt/buddys/domain/chat/code/ChatErrorCode.java b/src/main/java/org/sopt/buddys/domain/chat/code/ChatErrorCode.java index 5b195fd6..ce7f7821 100644 --- a/src/main/java/org/sopt/buddys/domain/chat/code/ChatErrorCode.java +++ b/src/main/java/org/sopt/buddys/domain/chat/code/ChatErrorCode.java @@ -10,7 +10,9 @@ public enum ChatErrorCode implements ErrorCode { CANNOT_CREATE_CHAT_ROOM_WITH_SELF("CHAT-E001", HttpStatus.BAD_REQUEST, "자기 자신과는 채팅방을 생성할 수 없습니다."), - CHAT_ROOM_NOT_FOUND("CHAT-E002", HttpStatus.NOT_FOUND, "채팅방을 찾을 수 없습니다."); + CHAT_ROOM_NOT_FOUND("CHAT-E002", HttpStatus.NOT_FOUND, "채팅방을 찾을 수 없습니다."), + BLOCKED_CHAT_PARTNER("CHAT-E003", HttpStatus.FORBIDDEN, "차단된 상대방에게는 메시지를 보낼 수 없습니다."), + REPORT_MAIL_SEND_FAILED("CHAT-E004", HttpStatus.INTERNAL_SERVER_ERROR, "신고 접수 메일 발송에 실패했습니다."); private final String code; private final HttpStatus httpStatus; diff --git a/src/main/java/org/sopt/buddys/domain/chat/controller/ChatRoomController.java b/src/main/java/org/sopt/buddys/domain/chat/controller/ChatRoomController.java index 2da2e02f..c81ac745 100644 --- a/src/main/java/org/sopt/buddys/domain/chat/controller/ChatRoomController.java +++ b/src/main/java/org/sopt/buddys/domain/chat/controller/ChatRoomController.java @@ -7,16 +7,21 @@ import jakarta.validation.constraints.Min; import java.time.OffsetDateTime; import lombok.RequiredArgsConstructor; +import org.sopt.buddys.domain.chat.controller.swagger.BlockChatPartnerSwagger; import org.sopt.buddys.domain.chat.controller.swagger.CreateChatRoomSwagger; import org.sopt.buddys.domain.chat.controller.swagger.GetChatMessagesSwagger; import org.sopt.buddys.domain.chat.controller.swagger.GetChatRoomsSwagger; import org.sopt.buddys.domain.chat.controller.swagger.GetChatRoomSwagger; +import org.sopt.buddys.domain.chat.controller.swagger.ReportChatPartnerSwagger; import org.sopt.buddys.domain.chat.dto.request.CreateChatRoomRequest; +import org.sopt.buddys.domain.chat.dto.request.ReportChatPartnerRequest; import org.sopt.buddys.domain.chat.dto.response.ChatMessageListResponse; import org.sopt.buddys.domain.chat.dto.response.ChatRoomListResponse; import org.sopt.buddys.domain.chat.dto.response.ChatRoomResponse; import org.sopt.buddys.domain.chat.service.ChatMessageService; import org.sopt.buddys.domain.chat.service.ChatRoomService; +import org.sopt.buddys.domain.chat.service.ChatUserBlockService; +import org.sopt.buddys.domain.chat.service.ChatUserReportService; import org.sopt.buddys.domain.chat.util.ChatTimeConverter; import org.sopt.buddys.global.common.code.GlobalSuccessCode; import org.sopt.buddys.global.response.BaseResponse; @@ -40,6 +45,8 @@ public class ChatRoomController { private final ChatRoomService chatRoomService; private final ChatMessageService chatMessageService; + private final ChatUserBlockService chatUserBlockService; + private final ChatUserReportService chatUserReportService; @CreateChatRoomSwagger @PostMapping @@ -119,4 +126,32 @@ public BaseResponse getMessages( ) ); } + + @BlockChatPartnerSwagger + @PostMapping("/{chatRoomId}/block") + public BaseResponse blockChatPartner( + @Parameter(hidden = true) + @LoginUser Long userId, + @Parameter(description = "채팅방 ID", example = "1") + @PathVariable Long chatRoomId + ) { + + chatUserBlockService.blockChatPartner(userId, chatRoomId); + return BaseResponse.success(GlobalSuccessCode.OK); + } + + @ReportChatPartnerSwagger + @PostMapping("/{chatRoomId}/report") + public BaseResponse reportChatPartner( + @Parameter(hidden = true) + @LoginUser Long userId, + @Parameter(description = "채팅방 ID", example = "1") + @PathVariable Long chatRoomId, + @RequestBody(required = false) @Valid ReportChatPartnerRequest request + ) { + + String reason = request != null ? request.reason() : null; + chatUserReportService.reportChatPartner(userId, chatRoomId, reason); + return BaseResponse.success(GlobalSuccessCode.OK); + } } diff --git a/src/main/java/org/sopt/buddys/domain/chat/controller/swagger/BlockChatPartnerSwagger.java b/src/main/java/org/sopt/buddys/domain/chat/controller/swagger/BlockChatPartnerSwagger.java new file mode 100644 index 00000000..acbbe248 --- /dev/null +++ b/src/main/java/org/sopt/buddys/domain/chat/controller/swagger/BlockChatPartnerSwagger.java @@ -0,0 +1,65 @@ +package org.sopt.buddys.domain.chat.controller.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.sopt.buddys.global.response.BaseResponse; +import org.sopt.buddys.global.swagger.CommonErrorResponses; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Operation( + summary = "채팅 상대방 차단", + description = """ + 해당 채팅방의 상대방을 차단합니다. + + - 차단 후에는 양쪽 모두 해당 상대방과 서로 메시지를 보낼 수 없습니다. + - 기존 채팅 내역은 삭제되지 않고 그대로 유지됩니다. + - 이미 차단한 상대방을 다시 차단해도 오류 없이 처리됩니다(멱등). + """ +) +@ApiResponses({ + @ApiResponse(responseCode = "200", description = "차단 성공"), + @ApiResponse( + responseCode = "403", + description = "채팅방 접근 권한 없음", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BaseResponse.class), + examples = @ExampleObject(value = """ + { + "success": false, + "code": "GLB-E003", + "message": "접근 권한이 없습니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "채팅방을 찾을 수 없음", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BaseResponse.class), + examples = @ExampleObject(value = """ + { + "success": false, + "code": "CHAT-E002", + "message": "채팅방을 찾을 수 없습니다.", + "data": null + } + """) + ) + ) +}) +@CommonErrorResponses +public @interface BlockChatPartnerSwagger { +} diff --git a/src/main/java/org/sopt/buddys/domain/chat/controller/swagger/ReportChatPartnerSwagger.java b/src/main/java/org/sopt/buddys/domain/chat/controller/swagger/ReportChatPartnerSwagger.java new file mode 100644 index 00000000..900c839d --- /dev/null +++ b/src/main/java/org/sopt/buddys/domain/chat/controller/swagger/ReportChatPartnerSwagger.java @@ -0,0 +1,80 @@ +package org.sopt.buddys.domain.chat.controller.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.sopt.buddys.global.response.BaseResponse; +import org.sopt.buddys.global.swagger.CommonErrorResponses; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Operation( + summary = "채팅 상대방 신고", + description = """ + 해당 채팅방의 상대방을 신고합니다. + + - 신고 사유(reason)는 선택 입력이며, 생략하면 사유 없이 즉시 접수됩니다. + - 신고 접수 시 신고자·신고 대상자 정보(및 입력된 사유)를 포함한 메일이 운영팀으로 발송됩니다. + """ +) +@ApiResponses({ + @ApiResponse(responseCode = "200", description = "신고 접수 성공"), + @ApiResponse( + responseCode = "403", + description = "채팅방 접근 권한 없음", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BaseResponse.class), + examples = @ExampleObject(value = """ + { + "success": false, + "code": "GLB-E003", + "message": "접근 권한이 없습니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "채팅방을 찾을 수 없음", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BaseResponse.class), + examples = @ExampleObject(value = """ + { + "success": false, + "code": "CHAT-E002", + "message": "채팅방을 찾을 수 없습니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "500", + description = "신고 접수 메일 발송 실패", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BaseResponse.class), + examples = @ExampleObject(value = """ + { + "success": false, + "code": "CHAT-E004", + "message": "신고 접수 메일 발송에 실패했습니다.", + "data": null + } + """) + ) + ) +}) +@CommonErrorResponses +public @interface ReportChatPartnerSwagger { +} diff --git a/src/main/java/org/sopt/buddys/domain/chat/dto/request/ReportChatPartnerRequest.java b/src/main/java/org/sopt/buddys/domain/chat/dto/request/ReportChatPartnerRequest.java new file mode 100644 index 00000000..e00d006f --- /dev/null +++ b/src/main/java/org/sopt/buddys/domain/chat/dto/request/ReportChatPartnerRequest.java @@ -0,0 +1,15 @@ +package org.sopt.buddys.domain.chat.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; + +public record ReportChatPartnerRequest( + @Schema( + description = "신고 사유. 현재는 별도 사유 입력 없이 신고할 수 있어 생략 가능합니다.", + example = "부적절한 언행", + nullable = true + ) + @Size(max = 500) + String reason +) { +} diff --git a/src/main/java/org/sopt/buddys/domain/chat/entity/ChatUserBlock.java b/src/main/java/org/sopt/buddys/domain/chat/entity/ChatUserBlock.java new file mode 100644 index 00000000..7c8a22a0 --- /dev/null +++ b/src/main/java/org/sopt/buddys/domain/chat/entity/ChatUserBlock.java @@ -0,0 +1,53 @@ +package org.sopt.buddys.domain.chat.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.sopt.buddys.domain.chat.util.ChatTimeConverter; +import org.sopt.buddys.domain.user.entity.User; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "chat_user_block") +public class ChatUserBlock { + + @EmbeddedId + private ChatUserBlockId id; + + @MapsId("blockerId") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "blocker_id", nullable = false) + private User blocker; + + @MapsId("blockedId") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "blocked_id", nullable = false) + private User blocked; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + public ChatUserBlock(User blocker, User blocked) { + this.blocker = blocker; + this.blocked = blocked; + this.id = new ChatUserBlockId(blocker.getId(), blocked.getId()); + } + + @PrePersist + private void prePersist() { + if (createdAt == null) { + createdAt = ChatTimeConverter.now(); + } + } +} diff --git a/src/main/java/org/sopt/buddys/domain/chat/entity/ChatUserBlockId.java b/src/main/java/org/sopt/buddys/domain/chat/entity/ChatUserBlockId.java new file mode 100644 index 00000000..73d135ae --- /dev/null +++ b/src/main/java/org/sopt/buddys/domain/chat/entity/ChatUserBlockId.java @@ -0,0 +1,27 @@ +package org.sopt.buddys.domain.chat.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.io.Serializable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatUserBlockId implements Serializable { + + @Column(name = "blocker_id") + private Long blockerId; + + @Column(name = "blocked_id") + private Long blockedId; + + public ChatUserBlockId(Long blockerId, Long blockedId) { + this.blockerId = blockerId; + this.blockedId = blockedId; + } +} diff --git a/src/main/java/org/sopt/buddys/domain/chat/entity/ChatUserReport.java b/src/main/java/org/sopt/buddys/domain/chat/entity/ChatUserReport.java new file mode 100644 index 00000000..cb3bc0c9 --- /dev/null +++ b/src/main/java/org/sopt/buddys/domain/chat/entity/ChatUserReport.java @@ -0,0 +1,61 @@ +package org.sopt.buddys.domain.chat.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.sopt.buddys.domain.chat.util.ChatTimeConverter; +import org.sopt.buddys.domain.user.entity.User; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "chat_user_report") +public class ChatUserReport { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "chat_room_id", nullable = false) + private ChatRoom chatRoom; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "reporter_id", nullable = false) + private User reporter; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "reported_id", nullable = false) + private User reported; + + @Column(length = 500) + private String reason; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + public ChatUserReport(ChatRoom chatRoom, User reporter, User reported, String reason) { + this.chatRoom = chatRoom; + this.reporter = reporter; + this.reported = reported; + this.reason = reason; + } + + @PrePersist + private void prePersist() { + if (createdAt == null) { + createdAt = ChatTimeConverter.now(); + } + } +} diff --git a/src/main/java/org/sopt/buddys/domain/chat/repository/ChatRoomMemberRepository.java b/src/main/java/org/sopt/buddys/domain/chat/repository/ChatRoomMemberRepository.java index a4629228..f0710a35 100644 --- a/src/main/java/org/sopt/buddys/domain/chat/repository/ChatRoomMemberRepository.java +++ b/src/main/java/org/sopt/buddys/domain/chat/repository/ChatRoomMemberRepository.java @@ -34,6 +34,17 @@ Optional findParticipantLastReadMessageId( @Param("userId") Long userId ); + @Query(""" + select participantMember.user.id + from ChatRoomMember participantMember + where participantMember.chatRoom.id = :chatRoomId + and participantMember.user.id <> :userId + """) + Optional findOtherMemberUserId( + @Param("chatRoomId") Long chatRoomId, + @Param("userId") Long userId + ); + @Query(""" select myMember.lastReadMessageId from ChatRoomMember myMember diff --git a/src/main/java/org/sopt/buddys/domain/chat/repository/ChatUserBlockRepository.java b/src/main/java/org/sopt/buddys/domain/chat/repository/ChatUserBlockRepository.java new file mode 100644 index 00000000..a721ac21 --- /dev/null +++ b/src/main/java/org/sopt/buddys/domain/chat/repository/ChatUserBlockRepository.java @@ -0,0 +1,33 @@ +package org.sopt.buddys.domain.chat.repository; + +import org.sopt.buddys.domain.chat.entity.ChatUserBlock; +import org.sopt.buddys.domain.chat.entity.ChatUserBlockId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ChatUserBlockRepository extends JpaRepository { + + @Modifying + @Query(value = """ + INSERT INTO chat_user_block (blocker_id, blocked_id, created_at) + VALUES (:blockerId, :blockedId, CURRENT_TIMESTAMP(6)) + ON DUPLICATE KEY UPDATE created_at = created_at + """, nativeQuery = true) + int insertOrKeep( + @Param("blockerId") Long blockerId, + @Param("blockedId") Long blockedId + ); + + @Query(""" + select case when count(b) > 0 then true else false end + from ChatUserBlock b + where (b.blocker.id = :userId1 and b.blocked.id = :userId2) + or (b.blocker.id = :userId2 and b.blocked.id = :userId1) + """) + boolean existsBlockBetween( + @Param("userId1") Long userId1, + @Param("userId2") Long userId2 + ); +} diff --git a/src/main/java/org/sopt/buddys/domain/chat/repository/ChatUserReportRepository.java b/src/main/java/org/sopt/buddys/domain/chat/repository/ChatUserReportRepository.java new file mode 100644 index 00000000..d254fc44 --- /dev/null +++ b/src/main/java/org/sopt/buddys/domain/chat/repository/ChatUserReportRepository.java @@ -0,0 +1,7 @@ +package org.sopt.buddys.domain.chat.repository; + +import org.sopt.buddys.domain.chat.entity.ChatUserReport; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChatUserReportRepository extends JpaRepository { +} diff --git a/src/main/java/org/sopt/buddys/domain/chat/service/ChatMessageCommandService.java b/src/main/java/org/sopt/buddys/domain/chat/service/ChatMessageCommandService.java index 625c4e98..5b024cc2 100644 --- a/src/main/java/org/sopt/buddys/domain/chat/service/ChatMessageCommandService.java +++ b/src/main/java/org/sopt/buddys/domain/chat/service/ChatMessageCommandService.java @@ -8,6 +8,7 @@ import org.sopt.buddys.domain.chat.repository.ChatMessageRepository; import org.sopt.buddys.domain.chat.repository.ChatRoomMemberRepository; import org.sopt.buddys.domain.chat.repository.ChatRoomRepository; +import org.sopt.buddys.domain.chat.repository.ChatUserBlockRepository; import org.sopt.buddys.domain.chat.service.result.ChatMessageSendResult; import org.sopt.buddys.domain.user.code.UserErrorCode; import org.sopt.buddys.domain.user.entity.User; @@ -24,6 +25,7 @@ public class ChatMessageCommandService { private final ChatMessageRepository chatMessageRepository; private final ChatRoomRepository chatRoomRepository; private final ChatRoomMemberRepository chatRoomMemberRepository; + private final ChatUserBlockRepository chatUserBlockRepository; private final UserRepository userRepository; @Transactional @@ -35,6 +37,7 @@ public ChatMessageSendResult sendMessage( User sender = getActiveUser(userId); ChatRoom chatRoom = getAccessibleChatRoom(userId, chatRoomId); + validateNotBlocked(userId, chatRoomId); ChatMessage message = chatMessageRepository.save( new ChatMessage(chatRoom, sender, content) ); @@ -42,6 +45,14 @@ public ChatMessageSendResult sendMessage( return new ChatMessageSendResult(message); } + private void validateNotBlocked(Long userId, Long chatRoomId) { + chatRoomMemberRepository.findOtherMemberUserId(chatRoomId, userId) + .filter(partnerId -> chatUserBlockRepository.existsBlockBetween(userId, partnerId)) + .ifPresent(partnerId -> { + throw new BaseException(ChatErrorCode.BLOCKED_CHAT_PARTNER); + }); + } + private User getActiveUser(Long userId) { return userRepository.findByIdAndDeletedAtIsNull(userId) .orElseThrow(() -> new BaseException(UserErrorCode.USER_NOT_FOUND)); diff --git a/src/main/java/org/sopt/buddys/domain/chat/service/ChatReportMailSender.java b/src/main/java/org/sopt/buddys/domain/chat/service/ChatReportMailSender.java new file mode 100644 index 00000000..70fa810f --- /dev/null +++ b/src/main/java/org/sopt/buddys/domain/chat/service/ChatReportMailSender.java @@ -0,0 +1,76 @@ +package org.sopt.buddys.domain.chat.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.buddys.domain.chat.code.ChatErrorCode; +import org.sopt.buddys.domain.chat.entity.ChatUserReport; +import org.sopt.buddys.domain.user.entity.User; +import org.sopt.buddys.global.exception.BaseException; +import org.sopt.buddys.global.mail.MailProperties; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.sesv2.SesV2Client; +import software.amazon.awssdk.services.sesv2.model.Body; +import software.amazon.awssdk.services.sesv2.model.Content; +import software.amazon.awssdk.services.sesv2.model.Destination; +import software.amazon.awssdk.services.sesv2.model.EmailContent; +import software.amazon.awssdk.services.sesv2.model.Message; +import software.amazon.awssdk.services.sesv2.model.SendEmailRequest; +import software.amazon.awssdk.services.sesv2.model.SesV2Exception; + +@Component +@RequiredArgsConstructor +public class ChatReportMailSender { + + private static final String CHARSET = "UTF-8"; + + private final SesV2Client sesV2Client; + private final MailProperties mailProperties; + + public void send(ChatUserReport report) { + SendEmailRequest request = SendEmailRequest.builder() + .fromEmailAddress(mailProperties.sender()) + .destination(Destination.builder().toAddresses(mailProperties.operationsRecipient()).build()) + .content(EmailContent.builder() + .simple(Message.builder() + .subject(Content.builder() + .charset(CHARSET) + .data("[Buddys] 채팅 사용자 신고 접수 (채팅방 #%d)".formatted(report.getChatRoom().getId())) + .build()) + .body(Body.builder() + .html(Content.builder() + .charset(CHARSET) + .data(buildHtmlBody(report)) + .build()) + .build()) + .build()) + .build()) + .build(); + + try { + sesV2Client.sendEmail(request); + } catch (SesV2Exception e) { + throw new BaseException(ChatErrorCode.REPORT_MAIL_SEND_FAILED, e); + } + } + + private String buildHtmlBody(ChatUserReport report) { + User reporter = report.getReporter(); + User reported = report.getReported(); + String reason = report.getReason() != null ? report.getReason() : "(사유 미입력)"; + return """ +
+

채팅 중 사용자 신고가 접수되었습니다.

+

채팅방 ID: %d

+

신고자: %s (userId: %d, %s)

+

신고 대상: %s (userId: %d, %s)

+

신고 사유: %s

+

접수 시각: %s

+
+ """.formatted( + report.getChatRoom().getId(), + reporter.getNickname(), reporter.getId(), reporter.getEmail(), + reported.getNickname(), reported.getId(), reported.getEmail(), + reason, + report.getCreatedAt() + ); + } +} diff --git a/src/main/java/org/sopt/buddys/domain/chat/service/ChatUserBlockService.java b/src/main/java/org/sopt/buddys/domain/chat/service/ChatUserBlockService.java new file mode 100644 index 00000000..e2acac48 --- /dev/null +++ b/src/main/java/org/sopt/buddys/domain/chat/service/ChatUserBlockService.java @@ -0,0 +1,44 @@ +package org.sopt.buddys.domain.chat.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.buddys.domain.chat.code.ChatErrorCode; +import org.sopt.buddys.domain.chat.entity.ChatRoomMemberId; +import org.sopt.buddys.domain.chat.repository.ChatRoomMemberRepository; +import org.sopt.buddys.domain.chat.repository.ChatRoomRepository; +import org.sopt.buddys.domain.chat.repository.ChatUserBlockRepository; +import org.sopt.buddys.global.common.code.GlobalErrorCode; +import org.sopt.buddys.global.exception.BaseException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatUserBlockService { + + private final ChatRoomRepository chatRoomRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + private final ChatUserBlockRepository chatUserBlockRepository; + + @Transactional + public void blockChatPartner(Long userId, Long chatRoomId) { + Long partnerId = getChatPartnerId(userId, chatRoomId); + chatUserBlockRepository.insertOrKeep(userId, partnerId); + } + + private Long getChatPartnerId(Long userId, Long chatRoomId) { + if (!chatRoomMemberRepository.existsById(new ChatRoomMemberId(chatRoomId, userId))) { + throw chatRoomAccessException(chatRoomId); + } + + return chatRoomMemberRepository.findOtherMemberUserId(chatRoomId, userId) + .orElseThrow(() -> chatRoomAccessException(chatRoomId)); + } + + private BaseException chatRoomAccessException(Long chatRoomId) { + if (!chatRoomRepository.existsById(chatRoomId)) { + return new BaseException(ChatErrorCode.CHAT_ROOM_NOT_FOUND); + } + return new BaseException(GlobalErrorCode.FORBIDDEN); + } +} diff --git a/src/main/java/org/sopt/buddys/domain/chat/service/ChatUserReportCommandService.java b/src/main/java/org/sopt/buddys/domain/chat/service/ChatUserReportCommandService.java new file mode 100644 index 00000000..4b3cafa1 --- /dev/null +++ b/src/main/java/org/sopt/buddys/domain/chat/service/ChatUserReportCommandService.java @@ -0,0 +1,28 @@ +package org.sopt.buddys.domain.chat.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.buddys.domain.chat.entity.ChatRoom; +import org.sopt.buddys.domain.chat.entity.ChatUserReport; +import org.sopt.buddys.domain.chat.repository.ChatUserReportRepository; +import org.sopt.buddys.domain.user.entity.User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +class ChatUserReportCommandService { + + private final ChatUserReportRepository chatUserReportRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public ChatUserReport save( + ChatRoom chatRoom, + User reporter, + User reported, + String reason + ) { + + return chatUserReportRepository.save(new ChatUserReport(chatRoom, reporter, reported, reason)); + } +} diff --git a/src/main/java/org/sopt/buddys/domain/chat/service/ChatUserReportService.java b/src/main/java/org/sopt/buddys/domain/chat/service/ChatUserReportService.java new file mode 100644 index 00000000..e2093135 --- /dev/null +++ b/src/main/java/org/sopt/buddys/domain/chat/service/ChatUserReportService.java @@ -0,0 +1,73 @@ +package org.sopt.buddys.domain.chat.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sopt.buddys.domain.chat.code.ChatErrorCode; +import org.sopt.buddys.domain.chat.entity.ChatRoom; +import org.sopt.buddys.domain.chat.entity.ChatUserReport; +import org.sopt.buddys.domain.chat.repository.ChatRoomMemberRepository; +import org.sopt.buddys.domain.chat.repository.ChatRoomRepository; +import org.sopt.buddys.domain.user.code.UserErrorCode; +import org.sopt.buddys.domain.user.entity.User; +import org.sopt.buddys.domain.user.repository.UserRepository; +import org.sopt.buddys.global.common.code.GlobalErrorCode; +import org.sopt.buddys.global.exception.BaseException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatUserReportService { + + private final ChatRoomRepository chatRoomRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + private final ChatUserReportCommandService chatUserReportCommandService; + private final UserRepository userRepository; + private final ChatReportMailSender chatReportMailSender; + + public void reportChatPartner(Long userId, Long chatRoomId, String reason) { + User reporter = getActiveUser(userId); + ChatRoomMemberRepository.ChatRoomDetailProjection chatRoomDetail = + chatRoomMemberRepository.findChatRoomDetailByIdAndUserId(chatRoomId, userId) + .orElseThrow(() -> chatRoomAccessException(chatRoomId)); + + ChatRoom chatRoom = chatRoomDetail.getChatRoom(); + User reported = chatRoomDetail.getParticipant(); + + // 신고 기록은 먼저 별도 트랜잭션으로 커밋한다. 이후 운영 메일 발송이 실패하더라도 + // (예: SES 일시 장애) 신고 접수 자체는 롤백되지 않고 남아 있어야 한다. + ChatUserReport report = chatUserReportCommandService.save(chatRoom, reporter, reported, reason); + + sendReportMail(report); + } + + private void sendReportMail(ChatUserReport report) { + try { + chatReportMailSender.send(report); + } catch (BaseException e) { + log.error( + "[ChatReportMailSendFailed] reportId={}, chatRoomId={}, reporterId={}, reportedId={}, code={}", + report.getId(), + report.getChatRoom().getId(), + report.getReporter().getId(), + report.getReported().getId(), + e.getErrorCode().getCode(), + e + ); + } + } + + private User getActiveUser(Long userId) { + return userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BaseException(UserErrorCode.USER_NOT_FOUND)); + } + + private BaseException chatRoomAccessException(Long chatRoomId) { + if (!chatRoomRepository.existsById(chatRoomId)) { + return new BaseException(ChatErrorCode.CHAT_ROOM_NOT_FOUND); + } + return new BaseException(GlobalErrorCode.FORBIDDEN); + } +} diff --git a/src/main/java/org/sopt/buddys/global/mail/MailProperties.java b/src/main/java/org/sopt/buddys/global/mail/MailProperties.java index b37b5fb1..a30c7a5c 100644 --- a/src/main/java/org/sopt/buddys/global/mail/MailProperties.java +++ b/src/main/java/org/sopt/buddys/global/mail/MailProperties.java @@ -7,5 +7,6 @@ @Validated @ConfigurationProperties(prefix = "mail") public record MailProperties( - @NotBlank String sender + @NotBlank String sender, + @NotBlank String operationsRecipient ) {} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 9151d1c4..ee2d855a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -62,6 +62,7 @@ cloud: mail: sender: ${MAIL_SENDER:no-reply@buddys.co.kr} + operations-recipient: ${MAIL_OPERATIONS_RECIPIENT:ops@buddys.co.kr} verification: university-email: diff --git a/src/main/resources/db/migration/V27__create_chat_user_block_and_report_tables.sql b/src/main/resources/db/migration/V27__create_chat_user_block_and_report_tables.sql new file mode 100644 index 00000000..60e9b099 --- /dev/null +++ b/src/main/resources/db/migration/V27__create_chat_user_block_and_report_tables.sql @@ -0,0 +1,22 @@ +CREATE TABLE chat_user_block +( + blocker_id BIGINT NOT NULL, + blocked_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (blocker_id, blocked_id), + FOREIGN KEY (blocker_id) REFERENCES `user` (id), + FOREIGN KEY (blocked_id) REFERENCES `user` (id) +); + +CREATE TABLE chat_user_report +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + chat_room_id BIGINT NOT NULL, + reporter_id BIGINT NOT NULL, + reported_id BIGINT NOT NULL, + reason VARCHAR(500) NULL, + created_at DATETIME(6) NOT NULL, + FOREIGN KEY (chat_room_id) REFERENCES chat_room (id), + FOREIGN KEY (reporter_id) REFERENCES `user` (id), + FOREIGN KEY (reported_id) REFERENCES `user` (id) +); diff --git a/src/test/java/org/sopt/buddys/domain/chat/controller/ChatRoomControllerTest.java b/src/test/java/org/sopt/buddys/domain/chat/controller/ChatRoomControllerTest.java index c68c04b1..a21725be 100644 --- a/src/test/java/org/sopt/buddys/domain/chat/controller/ChatRoomControllerTest.java +++ b/src/test/java/org/sopt/buddys/domain/chat/controller/ChatRoomControllerTest.java @@ -4,8 +4,10 @@ import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -18,12 +20,16 @@ import org.sopt.buddys.domain.chat.entity.ChatRoom; import org.sopt.buddys.domain.chat.service.ChatMessageService; import org.sopt.buddys.domain.chat.service.ChatRoomService; +import org.sopt.buddys.domain.chat.service.ChatUserBlockService; +import org.sopt.buddys.domain.chat.service.ChatUserReportService; import org.sopt.buddys.domain.chat.service.result.ChatMessageListResult; import org.sopt.buddys.domain.chat.service.result.ChatMessageListResult.ChatMessageResult; import org.sopt.buddys.domain.user.entity.AuthProvider; import org.sopt.buddys.domain.user.entity.User; +import org.sopt.buddys.global.exception.GlobalExceptionHandler; import org.sopt.buddys.global.security.annotation.LoginUser; import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; @@ -38,16 +44,23 @@ class ChatRoomControllerTest { private static final long LOGIN_USER_ID = 1L; private ChatMessageService chatMessageService; + private ChatUserBlockService chatUserBlockService; + private ChatUserReportService chatUserReportService; private MockMvc mockMvc; @BeforeEach void setUp() { ChatRoomService chatRoomService = mock(ChatRoomService.class); chatMessageService = mock(ChatMessageService.class); + chatUserBlockService = mock(ChatUserBlockService.class); + chatUserReportService = mock(ChatUserReportService.class); mockMvc = MockMvcBuilders - .standaloneSetup(new ChatRoomController(chatRoomService, chatMessageService)) + .standaloneSetup(new ChatRoomController( + chatRoomService, chatMessageService, chatUserBlockService, chatUserReportService + )) .setCustomArgumentResolvers(new TestLoginUserArgumentResolver()) + .setControllerAdvice(new GlobalExceptionHandler()) .build(); } @@ -133,6 +146,71 @@ void getMessages_withoutCursor_passesNullCursorToService() throws Exception { ); } + @DisplayName("채팅방 차단 요청이 오면 서비스에 위임한다") + @Test + void blockChatPartner_delegatesToService() throws Exception { + // given + Long chatRoomId = 1L; + + // when + ResultActions result = mockMvc.perform(post("/api/v1/chat-rooms/{chatRoomId}/block", chatRoomId)); + + // then + result.andExpect(status().isOk()); + verify(chatUserBlockService).blockChatPartner(LOGIN_USER_ID, chatRoomId); + } + + @DisplayName("신고 요청에 바디가 없으면 사유 없이 서비스에 위임한다") + @Test + void reportChatPartner_withoutBody_delegatesWithNullReason() throws Exception { + // given + Long chatRoomId = 1L; + + // when + ResultActions result = mockMvc.perform(post("/api/v1/chat-rooms/{chatRoomId}/report", chatRoomId)); + + // then + result.andExpect(status().isOk()); + verify(chatUserReportService).reportChatPartner(LOGIN_USER_ID, chatRoomId, null); + } + + @DisplayName("신고 요청에 사유가 포함되면 그 사유를 그대로 서비스에 전달한다") + @Test + void reportChatPartner_withReason_delegatesWithReason() throws Exception { + // given + Long chatRoomId = 1L; + + // when + ResultActions result = mockMvc.perform(post("/api/v1/chat-rooms/{chatRoomId}/report", chatRoomId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"reason": "부적절한 언행"} + """)); + + // then + result.andExpect(status().isOk()); + verify(chatUserReportService).reportChatPartner(LOGIN_USER_ID, chatRoomId, "부적절한 언행"); + } + + @DisplayName("신고 사유가 500자를 초과하면 400을 반환하고 서비스는 호출되지 않는다") + @Test + void reportChatPartner_reasonTooLong_returnsBadRequest() throws Exception { + // given + Long chatRoomId = 1L; + String tooLongReason = "a".repeat(501); + + // when + ResultActions result = mockMvc.perform(post("/api/v1/chat-rooms/{chatRoomId}/report", chatRoomId) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\": \"" + tooLongReason + "\"}")); + + // then + result + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("GLB-E001")); + verifyNoInteractions(chatUserReportService); + } + private ChatMessage createMessage( Long chatRoomId, Long messageId, diff --git a/src/test/java/org/sopt/buddys/domain/chat/repository/ChatUserReportRepositoryTest.java b/src/test/java/org/sopt/buddys/domain/chat/repository/ChatUserReportRepositoryTest.java new file mode 100644 index 00000000..7725512b --- /dev/null +++ b/src/test/java/org/sopt/buddys/domain/chat/repository/ChatUserReportRepositoryTest.java @@ -0,0 +1,96 @@ +package org.sopt.buddys.domain.chat.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.sopt.buddys.domain.chat.entity.ChatRoom; +import org.sopt.buddys.domain.chat.entity.ChatUserReport; +import org.sopt.buddys.domain.user.entity.AuthProvider; +import org.sopt.buddys.domain.user.entity.User; +import org.sopt.buddys.domain.user.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@ActiveProfiles("test") +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Testcontainers +class ChatUserReportRepositoryTest { + + @Container + @ServiceConnection + static MySQLContainer mysql = new MySQLContainer<>("mysql:8.0"); + + @Autowired + private ChatUserReportRepository chatUserReportRepository; + + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Autowired + private UserRepository userRepository; + + @AfterEach + void tearDown() { + chatUserReportRepository.deleteAllInBatch(); + chatRoomRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); + } + + @DisplayName("사유를 포함해 신고를 저장하고 다시 조회하면 사유를 포함한 모든 필드가 그대로 유지된다") + @Test + void save_withReason_roundTripsAllFields() { + // given + User reporter = userRepository.save(createUser("reporter@test.com", "provider-reporter", "신고자")); + User reported = userRepository.save(createUser("reported@test.com", "provider-reported", "신고대상")); + ChatRoom chatRoom = chatRoomRepository.save(ChatRoom.createDirect("1:2")); + + // when + ChatUserReport saved = chatUserReportRepository.save( + new ChatUserReport(chatRoom, reporter, reported, "부적절한 언행") + ); + ChatUserReport found = chatUserReportRepository.findById(saved.getId()).orElseThrow(); + + // then + assertThat(found.getReason()).isEqualTo("부적절한 언행"); + assertThat(found.getReporter().getId()).isEqualTo(reporter.getId()); + assertThat(found.getReported().getId()).isEqualTo(reported.getId()); + assertThat(found.getChatRoom().getId()).isEqualTo(chatRoom.getId()); + assertThat(found.getCreatedAt()).isNotNull(); + } + + @DisplayName("사유 없이 신고를 저장해도 예외 없이 저장되고 reason은 null로 조회된다") + @Test + void save_withoutReason_persistsNullReason() { + // given + User reporter = userRepository.save(createUser("reporter2@test.com", "provider-reporter2", "신고자2")); + User reported = userRepository.save(createUser("reported2@test.com", "provider-reported2", "신고대상2")); + ChatRoom chatRoom = chatRoomRepository.save(ChatRoom.createDirect("3:4")); + + // when + ChatUserReport saved = chatUserReportRepository.save( + new ChatUserReport(chatRoom, reporter, reported, null) + ); + ChatUserReport found = chatUserReportRepository.findById(saved.getId()).orElseThrow(); + + // then + assertThat(found.getReason()).isNull(); + } + + private User createUser(String email, String providerId, String nickname) { + return User.builder() + .email(email) + .provider(AuthProvider.KAKAO) + .providerId(providerId) + .nickname(nickname) + .build(); + } +} diff --git a/src/test/java/org/sopt/buddys/domain/chat/service/ChatMessageCommandServiceTest.java b/src/test/java/org/sopt/buddys/domain/chat/service/ChatMessageCommandServiceTest.java new file mode 100644 index 00000000..3461ed83 --- /dev/null +++ b/src/test/java/org/sopt/buddys/domain/chat/service/ChatMessageCommandServiceTest.java @@ -0,0 +1,157 @@ +package org.sopt.buddys.domain.chat.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.sopt.buddys.domain.chat.code.ChatErrorCode; +import org.sopt.buddys.domain.chat.entity.ChatRoom; +import org.sopt.buddys.domain.chat.repository.ChatMessageRepository; +import org.sopt.buddys.domain.chat.repository.ChatRoomMemberRepository; +import org.sopt.buddys.domain.chat.repository.ChatRoomRepository; +import org.sopt.buddys.domain.chat.repository.ChatUserBlockRepository; +import org.sopt.buddys.domain.chat.service.result.ChatMessageSendResult; +import org.sopt.buddys.domain.user.entity.AuthProvider; +import org.sopt.buddys.domain.user.entity.User; +import org.sopt.buddys.domain.user.repository.UserRepository; +import org.sopt.buddys.global.exception.BaseException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest +@ActiveProfiles("test") +@Testcontainers +class ChatMessageCommandServiceTest { + + @Container + @ServiceConnection + static MySQLContainer mysql = new MySQLContainer<>("mysql:8.0"); + + @Autowired + private ChatMessageCommandService chatMessageCommandService; + + @Autowired + private ChatUserBlockService chatUserBlockService; + + @Autowired + private ChatRoomService chatRoomService; + + @Autowired + private ChatMessageRepository chatMessageRepository; + + @Autowired + private ChatUserBlockRepository chatUserBlockRepository; + + @Autowired + private ChatRoomMemberRepository chatRoomMemberRepository; + + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Autowired + private UserRepository userRepository; + + @BeforeEach + void setUp() { + cleanUp(); + } + + @AfterEach + void tearDown() { + cleanUp(); + } + + @DisplayName("차단 관계가 없으면 정상적으로 메시지를 보낼 수 있다") + @Test + void sendMessage_noBlock_succeeds() { + // given + User user = userRepository.save(createUser("user@test.com", "provider-user", "사용자")); + User partner = userRepository.save(createUser("partner@test.com", "provider-partner", "상대방")); + ChatRoom chatRoom = chatRoomService.createOrGetChatRoom(user.getId(), partner.getId()).chatRoom(); + + // when + ChatMessageSendResult result = chatMessageCommandService.sendMessage( + user.getId(), chatRoom.getId(), "안녕하세요" + ); + + // then + assertThat(result.message().getMessage()).isEqualTo("안녕하세요"); + } + + @DisplayName("내가 상대방을 차단했으면 그 상대방과의 채팅방에 메시지를 보낼 수 없다") + @Test + void sendMessage_blockedByMe_throwsBlockedChatPartner() { + // given + User user = userRepository.save(createUser("user@test.com", "provider-user", "사용자")); + User partner = userRepository.save(createUser("partner@test.com", "provider-partner", "상대방")); + ChatRoom chatRoom = chatRoomService.createOrGetChatRoom(user.getId(), partner.getId()).chatRoom(); + chatUserBlockService.blockChatPartner(user.getId(), chatRoom.getId()); + + // when, then + assertThatThrownBy(() -> + chatMessageCommandService.sendMessage(user.getId(), chatRoom.getId(), "안녕하세요") + ) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(ChatErrorCode.BLOCKED_CHAT_PARTNER); + } + + @DisplayName("상대방이 나를 차단했으면 그 상대방에게 메시지를 보낼 수 없다") + @Test + void sendMessage_blockedByPartner_throwsBlockedChatPartner() { + // given + User user = userRepository.save(createUser("user@test.com", "provider-user", "사용자")); + User partner = userRepository.save(createUser("partner@test.com", "provider-partner", "상대방")); + ChatRoom chatRoom = chatRoomService.createOrGetChatRoom(user.getId(), partner.getId()).chatRoom(); + chatUserBlockService.blockChatPartner(partner.getId(), chatRoom.getId()); + + // when, then + assertThatThrownBy(() -> + chatMessageCommandService.sendMessage(user.getId(), chatRoom.getId(), "안녕하세요") + ) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(ChatErrorCode.BLOCKED_CHAT_PARTNER); + } + + @DisplayName("기존 채팅 내역은 차단 이후에도 그대로 유지된다") + @Test + void sendMessage_afterBlock_existingHistoryIsPreserved() { + // given + User user = userRepository.save(createUser("user@test.com", "provider-user", "사용자")); + User partner = userRepository.save(createUser("partner@test.com", "provider-partner", "상대방")); + ChatRoom chatRoom = chatRoomService.createOrGetChatRoom(user.getId(), partner.getId()).chatRoom(); + chatMessageCommandService.sendMessage(user.getId(), chatRoom.getId(), "차단 전 메시지"); + + // when + chatUserBlockService.blockChatPartner(user.getId(), chatRoom.getId()); + + // then + assertThat(chatMessageRepository.count()).isOne(); + } + + private User createUser(String email, String providerId, String nickname) { + return User.builder() + .email(email) + .provider(AuthProvider.KAKAO) + .providerId(providerId) + .nickname(nickname) + .build(); + } + + private void cleanUp() { + chatUserBlockRepository.deleteAllInBatch(); + chatMessageRepository.deleteAllInBatch(); + chatRoomMemberRepository.deleteAllInBatch(); + chatRoomRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); + } +} diff --git a/src/test/java/org/sopt/buddys/domain/chat/service/ChatUserBlockServiceTest.java b/src/test/java/org/sopt/buddys/domain/chat/service/ChatUserBlockServiceTest.java new file mode 100644 index 00000000..af191c9b --- /dev/null +++ b/src/test/java/org/sopt/buddys/domain/chat/service/ChatUserBlockServiceTest.java @@ -0,0 +1,143 @@ +package org.sopt.buddys.domain.chat.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.sopt.buddys.domain.chat.code.ChatErrorCode; +import org.sopt.buddys.domain.chat.entity.ChatRoom; +import org.sopt.buddys.domain.chat.repository.ChatRoomMemberRepository; +import org.sopt.buddys.domain.chat.repository.ChatRoomRepository; +import org.sopt.buddys.domain.chat.repository.ChatUserBlockRepository; +import org.sopt.buddys.domain.user.entity.AuthProvider; +import org.sopt.buddys.domain.user.entity.User; +import org.sopt.buddys.domain.user.repository.UserRepository; +import org.sopt.buddys.global.common.code.GlobalErrorCode; +import org.sopt.buddys.global.exception.BaseException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest +@ActiveProfiles("test") +@Testcontainers +class ChatUserBlockServiceTest { + + @Container + @ServiceConnection + static MySQLContainer mysql = new MySQLContainer<>("mysql:8.0"); + + @Autowired + private ChatUserBlockService chatUserBlockService; + + @Autowired + private ChatRoomService chatRoomService; + + @Autowired + private ChatUserBlockRepository chatUserBlockRepository; + + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Autowired + private ChatRoomMemberRepository chatRoomMemberRepository; + + @Autowired + private UserRepository userRepository; + + @BeforeEach + void setUp() { + cleanUp(); + } + + @AfterEach + void tearDown() { + cleanUp(); + } + + @DisplayName("채팅방 상대방을 차단하면 차단 관계가 저장된다") + @Test + void blockChatPartner_savesBlockRelationship() { + // given + User user = userRepository.save(createUser("user@test.com", "provider-user", "사용자")); + User partner = userRepository.save(createUser("partner@test.com", "provider-partner", "상대방")); + ChatRoom chatRoom = chatRoomService.createOrGetChatRoom(user.getId(), partner.getId()).chatRoom(); + + // when + chatUserBlockService.blockChatPartner(user.getId(), chatRoom.getId()); + + // then + assertThat(chatUserBlockRepository.existsBlockBetween(user.getId(), partner.getId())).isTrue(); + } + + @DisplayName("이미 차단한 상대방을 다시 차단해도 예외 없이 멱등하게 처리된다") + @Test + void blockChatPartner_repeatedCalls_areIdempotent() { + // given + User user = userRepository.save(createUser("user@test.com", "provider-user", "사용자")); + User partner = userRepository.save(createUser("partner@test.com", "provider-partner", "상대방")); + ChatRoom chatRoom = chatRoomService.createOrGetChatRoom(user.getId(), partner.getId()).chatRoom(); + + // when + assertThatCode(() -> { + chatUserBlockService.blockChatPartner(user.getId(), chatRoom.getId()); + chatUserBlockService.blockChatPartner(user.getId(), chatRoom.getId()); + }).doesNotThrowAnyException(); + + // then + assertThat(chatUserBlockRepository.count()).isOne(); + } + + @DisplayName("존재하지 않는 채팅방을 차단하려 하면 CHAT_ROOM_NOT_FOUND 예외가 발생한다") + @Test + void blockChatPartner_notFoundChatRoom_throwsChatRoomNotFound() { + // given + User user = userRepository.save(createUser("user@test.com", "provider-user", "사용자")); + + // when, then + assertThatThrownBy(() -> chatUserBlockService.blockChatPartner(user.getId(), 999_999L)) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(ChatErrorCode.CHAT_ROOM_NOT_FOUND); + } + + @DisplayName("채팅방 멤버가 아닌 사용자가 차단을 시도하면 FORBIDDEN 예외가 발생한다") + @Test + void blockChatPartner_notMember_throwsForbidden() { + // given + User user = userRepository.save(createUser("user@test.com", "provider-user", "사용자")); + User partner = userRepository.save(createUser("partner@test.com", "provider-partner", "상대방")); + User outsider = userRepository.save(createUser("outsider@test.com", "provider-outsider", "제3자")); + ChatRoom chatRoom = chatRoomService.createOrGetChatRoom(user.getId(), partner.getId()).chatRoom(); + + // when, then + assertThatThrownBy(() -> chatUserBlockService.blockChatPartner(outsider.getId(), chatRoom.getId())) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(GlobalErrorCode.FORBIDDEN); + } + + private User createUser(String email, String providerId, String nickname) { + return User.builder() + .email(email) + .provider(AuthProvider.KAKAO) + .providerId(providerId) + .nickname(nickname) + .build(); + } + + private void cleanUp() { + chatUserBlockRepository.deleteAllInBatch(); + chatRoomMemberRepository.deleteAllInBatch(); + chatRoomRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); + } +} diff --git a/src/test/java/org/sopt/buddys/domain/chat/service/ChatUserReportServiceTest.java b/src/test/java/org/sopt/buddys/domain/chat/service/ChatUserReportServiceTest.java new file mode 100644 index 00000000..ef4ff5cb --- /dev/null +++ b/src/test/java/org/sopt/buddys/domain/chat/service/ChatUserReportServiceTest.java @@ -0,0 +1,212 @@ +package org.sopt.buddys.domain.chat.service; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; + +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.buddys.domain.chat.code.ChatErrorCode; +import org.sopt.buddys.domain.chat.entity.ChatRoom; +import org.sopt.buddys.domain.chat.entity.ChatUserReport; +import org.sopt.buddys.domain.chat.repository.ChatRoomMemberRepository; +import org.sopt.buddys.domain.chat.repository.ChatRoomMemberRepository.ChatRoomDetailProjection; +import org.sopt.buddys.domain.chat.repository.ChatRoomRepository; +import org.sopt.buddys.domain.user.code.UserErrorCode; +import org.sopt.buddys.domain.user.entity.AuthProvider; +import org.sopt.buddys.domain.user.entity.User; +import org.sopt.buddys.domain.user.repository.UserRepository; +import org.sopt.buddys.global.common.code.GlobalErrorCode; +import org.sopt.buddys.global.exception.BaseException; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class ChatUserReportServiceTest { + + private static final Long USER_ID = 1L; + private static final Long PARTNER_ID = 2L; + private static final Long CHAT_ROOM_ID = 10L; + + @Mock + private ChatRoomRepository chatRoomRepository; + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @Mock + private ChatUserReportCommandService chatUserReportCommandService; + + @Mock + private UserRepository userRepository; + + @Mock + private ChatReportMailSender chatReportMailSender; + + @Mock + private ChatRoomDetailProjection chatRoomDetailProjection; + + private ChatUserReportService chatUserReportService; + + @BeforeEach + void setUp() { + chatUserReportService = new ChatUserReportService( + chatRoomRepository, + chatRoomMemberRepository, + chatUserReportCommandService, + userRepository, + chatReportMailSender + ); + } + + @DisplayName("사유 없이 신고하면 신고 내역이 저장되고 운영 메일이 발송된다") + @Test + void reportChatPartner_withoutReason_savesReportAndSendsMail() { + // given + User reporter = createUser(USER_ID, "신고자"); + User reported = createUser(PARTNER_ID, "신고대상"); + ChatRoom chatRoom = createChatRoom(CHAT_ROOM_ID); + ChatUserReport report = createReport(chatRoom, reporter, reported, null); + + given(userRepository.findByIdAndDeletedAtIsNull(USER_ID)).willReturn(Optional.of(reporter)); + given(chatRoomMemberRepository.findChatRoomDetailByIdAndUserId(CHAT_ROOM_ID, USER_ID)) + .willReturn(Optional.of(chatRoomDetailProjection)); + given(chatRoomDetailProjection.getChatRoom()).willReturn(chatRoom); + given(chatRoomDetailProjection.getParticipant()).willReturn(reported); + given(chatUserReportCommandService.save(chatRoom, reporter, reported, null)).willReturn(report); + + // when + chatUserReportService.reportChatPartner(USER_ID, CHAT_ROOM_ID, null); + + // then + then(chatUserReportCommandService).should().save(chatRoom, reporter, reported, null); + then(chatReportMailSender).should().send(report); + } + + @DisplayName("사유를 입력해서 신고하면 그 사유가 그대로 커맨드 서비스에 전달된다") + @Test + void reportChatPartner_withReason_passesReasonThrough() { + // given + User reporter = createUser(USER_ID, "신고자"); + User reported = createUser(PARTNER_ID, "신고대상"); + ChatRoom chatRoom = createChatRoom(CHAT_ROOM_ID); + String reason = "부적절한 언행"; + ChatUserReport report = createReport(chatRoom, reporter, reported, reason); + + given(userRepository.findByIdAndDeletedAtIsNull(USER_ID)).willReturn(Optional.of(reporter)); + given(chatRoomMemberRepository.findChatRoomDetailByIdAndUserId(CHAT_ROOM_ID, USER_ID)) + .willReturn(Optional.of(chatRoomDetailProjection)); + given(chatRoomDetailProjection.getChatRoom()).willReturn(chatRoom); + given(chatRoomDetailProjection.getParticipant()).willReturn(reported); + given(chatUserReportCommandService.save(chatRoom, reporter, reported, reason)).willReturn(report); + + // when + chatUserReportService.reportChatPartner(USER_ID, CHAT_ROOM_ID, reason); + + // then + then(chatUserReportCommandService).should().save(chatRoom, reporter, reported, reason); + } + + @DisplayName("메일 발송이 실패해도 이미 커밋된 신고 기록은 유지되고 예외가 전파되지 않는다") + @Test + void reportChatPartner_mailSendFails_reportStillSavedAndNoExceptionPropagates() { + // given + User reporter = createUser(USER_ID, "신고자"); + User reported = createUser(PARTNER_ID, "신고대상"); + ChatRoom chatRoom = createChatRoom(CHAT_ROOM_ID); + ChatUserReport report = createReport(chatRoom, reporter, reported, null); + + given(userRepository.findByIdAndDeletedAtIsNull(USER_ID)).willReturn(Optional.of(reporter)); + given(chatRoomMemberRepository.findChatRoomDetailByIdAndUserId(CHAT_ROOM_ID, USER_ID)) + .willReturn(Optional.of(chatRoomDetailProjection)); + given(chatRoomDetailProjection.getChatRoom()).willReturn(chatRoom); + given(chatRoomDetailProjection.getParticipant()).willReturn(reported); + given(chatUserReportCommandService.save(chatRoom, reporter, reported, null)).willReturn(report); + willThrow(new BaseException(ChatErrorCode.REPORT_MAIL_SEND_FAILED)) + .given(chatReportMailSender).send(report); + + // when, then: 커맨드 서비스의 save()는 이미 별도 트랜잭션으로 커밋되었으므로, + // 이후 메일 발송이 실패해도 그 실패가 신고 접수 자체를 취소시키지 않는다. + assertThatCode(() -> chatUserReportService.reportChatPartner(USER_ID, CHAT_ROOM_ID, null)) + .doesNotThrowAnyException(); + then(chatUserReportCommandService).should().save(chatRoom, reporter, reported, null); + } + + @DisplayName("탈퇴한 사용자가 신고하면 USER_NOT_FOUND 예외가 발생한다") + @Test + void reportChatPartner_deletedUser_throwsUserNotFound() { + // given + given(userRepository.findByIdAndDeletedAtIsNull(USER_ID)).willReturn(Optional.empty()); + + // when, then + assertThatThrownBy(() -> chatUserReportService.reportChatPartner(USER_ID, CHAT_ROOM_ID, null)) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(UserErrorCode.USER_NOT_FOUND); + then(chatReportMailSender).should(never()).send(any()); + } + + @DisplayName("존재하지 않는 채팅방을 신고하면 CHAT_ROOM_NOT_FOUND 예외가 발생한다") + @Test + void reportChatPartner_notFoundChatRoom_throwsChatRoomNotFound() { + // given + User reporter = createUser(USER_ID, "신고자"); + given(userRepository.findByIdAndDeletedAtIsNull(USER_ID)).willReturn(Optional.of(reporter)); + given(chatRoomMemberRepository.findChatRoomDetailByIdAndUserId(CHAT_ROOM_ID, USER_ID)) + .willReturn(Optional.empty()); + given(chatRoomRepository.existsById(CHAT_ROOM_ID)).willReturn(false); + + // when, then + assertThatThrownBy(() -> chatUserReportService.reportChatPartner(USER_ID, CHAT_ROOM_ID, null)) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(ChatErrorCode.CHAT_ROOM_NOT_FOUND); + } + + @DisplayName("채팅방 멤버가 아닌 사용자가 신고하면 FORBIDDEN 예외가 발생한다") + @Test + void reportChatPartner_notMember_throwsForbidden() { + // given + User reporter = createUser(USER_ID, "신고자"); + given(userRepository.findByIdAndDeletedAtIsNull(USER_ID)).willReturn(Optional.of(reporter)); + given(chatRoomMemberRepository.findChatRoomDetailByIdAndUserId(CHAT_ROOM_ID, USER_ID)) + .willReturn(Optional.empty()); + given(chatRoomRepository.existsById(CHAT_ROOM_ID)).willReturn(true); + + // when, then + assertThatThrownBy(() -> chatUserReportService.reportChatPartner(USER_ID, CHAT_ROOM_ID, null)) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(GlobalErrorCode.FORBIDDEN); + } + + private User createUser(Long id, String nickname) { + return User.builder() + .id(id) + .email(nickname + "@test.com") + .provider(AuthProvider.KAKAO) + .providerId("provider-" + id) + .nickname(nickname) + .build(); + } + + private ChatRoom createChatRoom(Long id) { + ChatRoom chatRoom = ChatRoom.createDirect("direct-chat-key"); + ReflectionTestUtils.setField(chatRoom, "id", id); + return chatRoom; + } + + private ChatUserReport createReport(ChatRoom chatRoom, User reporter, User reported, String reason) { + ChatUserReport report = new ChatUserReport(chatRoom, reporter, reported, reason); + ReflectionTestUtils.setField(report, "id", 100L); + return report; + } +} diff --git a/src/test/java/org/sopt/buddys/domain/user/service/UserServiceTest.java b/src/test/java/org/sopt/buddys/domain/user/service/UserServiceTest.java index 454e11bb..e2c908c1 100644 --- a/src/test/java/org/sopt/buddys/domain/user/service/UserServiceTest.java +++ b/src/test/java/org/sopt/buddys/domain/user/service/UserServiceTest.java @@ -152,7 +152,7 @@ void getPublicPosts_deletedUser_returnsPosts() { void getCourses_returnsCourses() { // given Long userId = 1L; - Course course = createCourse(userId, 10L, "https://example.com/thumbnail.jpg"); + Course course = createCourse(userId, 10L); PageRequest pageable = PageRequest.of(0, 12); given(userRepository.existsByIdAndDeletedAtIsNull(userId)).willReturn(true); @@ -185,7 +185,7 @@ void getCourses_returnsCourses() { void getPublicCourses_deletedUser_returnsCourses() { // given Long userId = 1L; - Course course = createCourse(userId, 10L, null); + Course course = createCourse(userId, 10L); PageRequest pageable = PageRequest.of(0, 12); given(userRepository.existsById(userId)).willReturn(true); @@ -431,12 +431,11 @@ private User createOnboardedProfileUser(Long userId) { .build(); } - private Course createCourse(Long authorId, Long courseId, String thumbnailImageUrl) { + private Course createCourse(Long authorId, Long courseId) { Course course = new Course( baseUserBuilder(authorId).build(), "파리 미술관 코스", null, - thumbnailImageUrl, LocalDate.of(2026, 9, 1), LocalDate.of(2026, 9, 5) ); diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index f80ee2e1..97cf311d 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -41,6 +41,7 @@ cloud: mail: sender: no-reply@buddys.co.kr + operations-recipient: ops-test@buddys.co.kr verification: university-email: From 37be0caf0cfe7bffdb2efd250ece61020b7efce4 Mon Sep 17 00:00:00 2001 From: haerxeong Date: Wed, 9 Sep 2026 10:16:23 +0900 Subject: [PATCH 02/12] =?UTF-8?q?fix:=20=EB=A9=94=EC=9D=BC=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index ee2d855a..0bdc9e6c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -62,7 +62,7 @@ cloud: mail: sender: ${MAIL_SENDER:no-reply@buddys.co.kr} - operations-recipient: ${MAIL_OPERATIONS_RECIPIENT:ops@buddys.co.kr} + operations-recipient: ${MAIL_OPERATIONS_RECIPIENT:buddys.co.kr@gmail.com} verification: university-email: From e5ddd1236370f223f1ade55495ef7f19d03e588b Mon Sep 17 00:00:00 2001 From: haerxeong Date: Tue, 22 Sep 2026 10:24:43 +0900 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=20=EC=8B=A0?= =?UTF-8?q?=EA=B3=A0=20=EB=A9=94=EC=9D=BC=20=EB=B3=B8=EB=AC=B8=EC=97=90=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9E=85=EB=A0=A5=EA=B0=92=20HTM?= =?UTF-8?q?L=20=EC=9D=B4=EC=8A=A4=EC=BC=80=EC=9D=B4=ED=94=84=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/service/ChatReportMailSender.java | 7 +- .../service/ChatReportMailSenderTest.java | 76 +++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 src/test/java/org/sopt/buddys/domain/chat/service/ChatReportMailSenderTest.java diff --git a/src/main/java/org/sopt/buddys/domain/chat/service/ChatReportMailSender.java b/src/main/java/org/sopt/buddys/domain/chat/service/ChatReportMailSender.java index 70fa810f..0056a4e5 100644 --- a/src/main/java/org/sopt/buddys/domain/chat/service/ChatReportMailSender.java +++ b/src/main/java/org/sopt/buddys/domain/chat/service/ChatReportMailSender.java @@ -7,6 +7,7 @@ import org.sopt.buddys.global.exception.BaseException; import org.sopt.buddys.global.mail.MailProperties; import org.springframework.stereotype.Component; +import org.springframework.web.util.HtmlUtils; import software.amazon.awssdk.services.sesv2.SesV2Client; import software.amazon.awssdk.services.sesv2.model.Body; import software.amazon.awssdk.services.sesv2.model.Content; @@ -67,9 +68,9 @@ private String buildHtmlBody(ChatUserReport report) { """.formatted( report.getChatRoom().getId(), - reporter.getNickname(), reporter.getId(), reporter.getEmail(), - reported.getNickname(), reported.getId(), reported.getEmail(), - reason, + HtmlUtils.htmlEscape(reporter.getNickname()), reporter.getId(), HtmlUtils.htmlEscape(reporter.getEmail()), + HtmlUtils.htmlEscape(reported.getNickname()), reported.getId(), HtmlUtils.htmlEscape(reported.getEmail()), + HtmlUtils.htmlEscape(reason), report.getCreatedAt() ); } diff --git a/src/test/java/org/sopt/buddys/domain/chat/service/ChatReportMailSenderTest.java b/src/test/java/org/sopt/buddys/domain/chat/service/ChatReportMailSenderTest.java new file mode 100644 index 00000000..99fa57f2 --- /dev/null +++ b/src/test/java/org/sopt/buddys/domain/chat/service/ChatReportMailSenderTest.java @@ -0,0 +1,76 @@ +package org.sopt.buddys.domain.chat.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.buddys.domain.chat.entity.ChatRoom; +import org.sopt.buddys.domain.chat.entity.ChatUserReport; +import org.sopt.buddys.domain.user.entity.AuthProvider; +import org.sopt.buddys.domain.user.entity.User; +import org.sopt.buddys.global.mail.MailProperties; +import org.springframework.test.util.ReflectionTestUtils; +import software.amazon.awssdk.services.sesv2.SesV2Client; +import software.amazon.awssdk.services.sesv2.model.SendEmailRequest; +import software.amazon.awssdk.services.sesv2.model.SendEmailResponse; + +@ExtendWith(MockitoExtension.class) +class ChatReportMailSenderTest { + + @Mock + private SesV2Client sesV2Client; + + @Mock + private MailProperties mailProperties; + + @Captor + private ArgumentCaptor requestCaptor; + + @Test + void send_maliciousReason_escapesHtmlInMailBody() { + // given + ChatReportMailSender chatReportMailSender = new ChatReportMailSender(sesV2Client, mailProperties); + given(mailProperties.sender()).willReturn("noreply@buddys.com"); + given(mailProperties.operationsRecipient()).willReturn("ops@buddys.com"); + given(sesV2Client.sendEmail(requestCaptor.capture())).willReturn(SendEmailResponse.builder().build()); + + User reporter = createUser(1L, ""); + User reported = createUser(2L, "정상닉네임"); + ChatRoom chatRoom = createChatRoom(10L); + ChatUserReport report = new ChatUserReport( + chatRoom, reporter, reported, "악성 신고 사유" + ); + + // when + chatReportMailSender.send(report); + + // then + String htmlBody = requestCaptor.getValue().content().simple().body().html().data(); + assertThat(htmlBody) + .doesNotContain("