diff --git a/src/main/java/umc/cockple/demo/Application.java b/src/main/java/umc/cockple/demo/Application.java index 934d856b5..5fbaf64e1 100644 --- a/src/main/java/umc/cockple/demo/Application.java +++ b/src/main/java/umc/cockple/demo/Application.java @@ -4,10 +4,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing @EnableCaching +@EnableScheduling public class Application { public static void main(String[] args) { diff --git a/src/main/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupEvent.java b/src/main/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupEvent.java new file mode 100644 index 000000000..db38a19ad --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupEvent.java @@ -0,0 +1,9 @@ +package umc.cockple.demo.domain.chat.events; + +public record ChatRoomRedisCleanupEvent( + Long chatRoomId +) { + public static ChatRoomRedisCleanupEvent of(Long chatRoomId) { + return new ChatRoomRedisCleanupEvent(chatRoomId); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListener.java b/src/main/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListener.java new file mode 100644 index 000000000..a3f881d49 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListener.java @@ -0,0 +1,39 @@ +package umc.cockple.demo.domain.chat.events; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import umc.cockple.demo.domain.chat.service.websocket.ChatListSubscriptionService; +import umc.cockple.demo.domain.chat.service.websocket.ChatRoomListCacheService; +import umc.cockple.demo.domain.chat.service.websocket.RedisSubscriptionService; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ChatRoomRedisCleanupListener { + + private final ChatRoomListCacheService chatRoomListCacheService; + private final RedisSubscriptionService redisSubscriptionService; + private final ChatListSubscriptionService chatListSubscriptionService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + public void handleChatRoomRedisCleanup(ChatRoomRedisCleanupEvent event) { + Long chatRoomId = event.chatRoomId(); + log.info("[채팅방 Redis 정리 시작] - chatRoomId: {}", chatRoomId); + + try { + chatRoomListCacheService.evictLastMessage(chatRoomId); + } catch (Exception e) { + log.warn("[채팅방 Redis 정리] 마지막 메시지 캐시 best-effort 삭제 실패 - chatRoomId: {}", chatRoomId, e); + } + + redisSubscriptionService.tryClearRoomSubscribers(chatRoomId); + chatListSubscriptionService.tryClearChatListSubscribers(chatRoomId); + + log.info("[채팅방 Redis 정리 완료] - chatRoomId: {}", chatRoomId); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListener.java b/src/main/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListener.java new file mode 100644 index 000000000..32daa35dd --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListener.java @@ -0,0 +1,22 @@ +package umc.cockple.demo.domain.chat.events; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import umc.cockple.demo.domain.chat.service.ChatRoomService; +import umc.cockple.demo.domain.party.events.PartyDeletedEvent; + +@Component +@RequiredArgsConstructor +@Slf4j +public class PartyDeletedChatCleanupListener { + + private final ChatRoomService chatRoomService; + + @EventListener + public void handlePartyDeleted(PartyDeletedEvent event) { + log.info("모임 삭제 이벤트 처리 - partyId: {}, deletedBy: {}", event.partyId(), event.deletedByMemberId()); + chatRoomService.deletePartyChatRoom(event.partyId()); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatFileRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatFileRepository.java index a1113c845..001c86601 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatFileRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatFileRepository.java @@ -1,7 +1,25 @@ package umc.cockple.demo.domain.chat.repository; 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; import umc.cockple.demo.domain.chat.domain.ChatMessageFile; +import java.util.List; + public interface ChatFileRepository extends JpaRepository { + + @Query(""" + SELECT cmf.fileKey FROM ChatMessageFile cmf + WHERE cmf.chatMessage.chatRoom.id = :chatRoomId + """) + List findObjectKeysByChatRoomId(@Param("chatRoomId") Long chatRoomId); + + @Modifying + @Query(""" + DELETE FROM ChatMessageFile cmf + WHERE cmf.chatMessage.chatRoom.id = :chatRoomId + """) + int deleteByChatRoomId(@Param("chatRoomId") Long chatRoomId); } diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java index 4b14a0235..f3254b23d 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatMessageRepository.java @@ -2,6 +2,7 @@ import org.springframework.data.domain.Pageable; 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; import umc.cockple.demo.domain.chat.domain.ChatMessage; @@ -10,6 +11,13 @@ public interface ChatMessageRepository extends JpaRepository { + @Modifying + @Query(""" + DELETE FROM ChatMessage cm + WHERE cm.chatRoom.id = :chatRoomId + """) + int deleteByChatRoomId(@Param("chatRoomId") Long chatRoomId); + ChatMessage findTop1ByChatRoom_IdOrderByCreatedAtDesc(Long chatRoomId); @Query(""" diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java index 14884030a..baa4169ba 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomMemberRepository.java @@ -12,6 +12,13 @@ public interface ChatRoomMemberRepository extends JpaRepository { + @Modifying + @Query(""" + DELETE FROM ChatRoomMember crm + WHERE crm.chatRoom.id = :chatRoomId + """) + int deleteByChatRoomId(@Param("chatRoomId") Long chatRoomId); + // 채팅방 내 참여자 수 @Query("SELECT COUNT(c) FROM ChatRoomMember c WHERE c.chatRoom.id = :chatRoomId") int countByChatRoomId(@Param("chatRoomId") Long chatRoomId); @@ -84,4 +91,3 @@ AND counterPart.chatRoom.id IN ( """) List findDirectChatCounterParts(@Param("memberId") Long memberId); } - diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomRepository.java index 1d9bd3c24..ceb5ac567 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/ChatRoomRepository.java @@ -3,6 +3,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; 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; import umc.cockple.demo.domain.chat.domain.ChatRoom; @@ -11,6 +12,13 @@ public interface ChatRoomRepository extends JpaRepository { + @Modifying + @Query(""" + DELETE FROM ChatRoom cr + WHERE cr.id = :chatRoomId + """) + int deleteRoomById(@Param("chatRoomId") Long chatRoomId); + @Query(""" SELECT cr FROM ChatRoom cr JOIN cr.chatRoomMembers crm @@ -103,4 +111,4 @@ Slice searchDirectChatRoomsByName( WHERE cr.id = :roomId """) Optional findChatRoomWithPartyById(@Param("roomId") Long roomId); -} \ No newline at end of file +} diff --git a/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java b/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java index 9392be1ea..961ecba40 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java +++ b/src/main/java/umc/cockple/demo/domain/chat/repository/MessageReadStatusRepository.java @@ -10,6 +10,13 @@ public interface MessageReadStatusRepository extends JpaRepository { + @Modifying + @Query(""" + DELETE FROM MessageReadStatus mrs + WHERE mrs.chatRoomId = :chatRoomId + """) + int deleteByChatRoomId(@Param("chatRoomId") Long chatRoomId); + @Modifying @Query(""" UPDATE MessageReadStatus mrs diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java b/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java index 0e7d452f2..81abbdd8e 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/ChatRoomService.java @@ -2,17 +2,26 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import umc.cockple.demo.domain.chat.domain.ChatRoom; import umc.cockple.demo.domain.chat.domain.ChatRoomMember; +import umc.cockple.demo.domain.chat.events.ChatRoomRedisCleanupEvent; import umc.cockple.demo.domain.chat.exception.ChatErrorCode; import umc.cockple.demo.domain.chat.exception.ChatException; +import umc.cockple.demo.domain.chat.repository.ChatFileRepository; +import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; +import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; +import umc.cockple.demo.domain.file.service.ObjectStorageDeleteOutboxService; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.party.domain.Party; +import java.util.List; +import java.util.Optional; + @Service @Transactional @RequiredArgsConstructor @@ -20,7 +29,12 @@ public class ChatRoomService { private final ChatRoomRepository chatRoomRepository; + private final ChatFileRepository chatFileRepository; + private final ChatMessageRepository chatMessageRepository; private final ChatRoomMemberRepository chatRoomMemberRepository; + private final MessageReadStatusRepository messageReadStatusRepository; + private final ObjectStorageDeleteOutboxService objectStorageDeleteOutboxService; + private final ApplicationEventPublisher applicationEventPublisher; public void createPartyChatRoom(Party party, Member owner) { log.info("[모임 채팅방 생성 시작] - partyId: {}", party.getId()); @@ -51,6 +65,32 @@ public void leavePartyChatRoom(Long partyId, Long memberId) { log.info("[모임 채팅방 퇴장 완료] - chatRoomId: {}", chatRoom.getId()); } + public void deletePartyChatRoom(Long partyId) { + log.info("[모임 채팅방 삭제 시작] - partyId: {}", partyId); + + Optional chatRoomOptional = chatRoomRepository.findByPartyId(partyId); + + if (chatRoomOptional.isEmpty()) { + log.warn("[모임 채팅방 삭제 스킵] - partyId: {}, 채팅방이 존재하지 않습니다.", partyId); + return; + } + + ChatRoom chatRoom = chatRoomOptional.get(); + Long chatRoomId = chatRoom.getId(); + List objectKeys = chatFileRepository.findObjectKeysByChatRoomId(chatRoomId); + + objectStorageDeleteOutboxService.enqueuePartyChatFiles(chatRoomId, objectKeys); + + messageReadStatusRepository.deleteByChatRoomId(chatRoomId); + chatFileRepository.deleteByChatRoomId(chatRoomId); + chatMessageRepository.deleteByChatRoomId(chatRoomId); + chatRoomMemberRepository.deleteByChatRoomId(chatRoomId); + chatRoomRepository.deleteRoomById(chatRoomId); + applicationEventPublisher.publishEvent(ChatRoomRedisCleanupEvent.of(chatRoomId)); + + log.info("[모임 채팅방 삭제 완료] - partyId: {}, chatRoomId: {}", partyId, chatRoomId); + } + private ChatRoom findChatRoomByPartyIdOrThrow(Long partyId) { return chatRoomRepository.findByPartyId(partyId) .orElseThrow(() -> new ChatException(ChatErrorCode.CHAT_ROOM_NOT_FOUND)); diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionService.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionService.java index b20bf3da8..db14e3630 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionService.java @@ -61,4 +61,13 @@ public Set getChatListSubscribers(Long chatRoomId) { return Set.of(); } } + + public void tryClearChatListSubscribers(Long chatRoomId) { + try { + stringRedisTemplate.delete(CHAT_LIST_SUBSCRIBERS + chatRoomId); + log.info("채팅방 목록 구독 키 best-effort 삭제 완료 - 채팅방: {}", chatRoomId); + } catch (Exception e) { + log.warn("채팅방 목록 구독 키 best-effort 삭제 실패 - 채팅방: {}, TTL 만료를 기다립니다.", chatRoomId, e); + } + } } diff --git a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionService.java b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionService.java index 4a7bffd70..139ea094f 100644 --- a/src/main/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionService.java +++ b/src/main/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionService.java @@ -76,4 +76,13 @@ public Set getSubscribers(Long chatRoomId) { return Set.of(); } } + + public void tryClearRoomSubscribers(Long chatRoomId) { + try { + stringRedisTemplate.delete(CHAT_ROOM_SUBSCRIBERS + chatRoomId); + log.info("Redis 구독 키 best-effort 삭제 완료 - 채팅방: {}", chatRoomId); + } catch (Exception e) { + log.warn("Redis 구독 키 best-effort 삭제 실패 - 채팅방: {}, TTL 만료를 기다립니다.", chatRoomId, e); + } + } } diff --git a/src/main/java/umc/cockple/demo/domain/file/domain/ObjectStorageDeleteOutbox.java b/src/main/java/umc/cockple/demo/domain/file/domain/ObjectStorageDeleteOutbox.java new file mode 100644 index 000000000..88260e610 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/domain/ObjectStorageDeleteOutbox.java @@ -0,0 +1,93 @@ +package umc.cockple.demo.domain.file.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteSourceType; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; +import umc.cockple.demo.global.common.BaseEntity; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "object_storage_delete_outbox") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class ObjectStorageDeleteOutbox extends BaseEntity { + + private static final int LAST_ERROR_MAX_LENGTH = 2000; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "object_key", nullable = false, length = 512) + private String objectKey; + + @Enumerated(EnumType.STRING) + @Column(name = "source_type", nullable = false, length = 50) + private ObjectStorageDeleteSourceType sourceType; + + @Column(name = "source_id") + private Long sourceId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ObjectStorageDeleteStatus status; + + @Column(name = "retry_count", nullable = false) + private int retryCount; + + @Column(name = "last_error", length = LAST_ERROR_MAX_LENGTH) + private String lastError; + + @Column(name = "last_attempted_at") + private LocalDateTime lastAttemptedAt; + + @Column(name = "claim_token", length = 36) + private String claimToken; + + public static ObjectStorageDeleteOutbox pending(String objectKey, ObjectStorageDeleteSourceType sourceType, Long sourceId) { + return ObjectStorageDeleteOutbox.builder() + .objectKey(objectKey) + .sourceType(sourceType) + .sourceId(sourceId) + .status(ObjectStorageDeleteStatus.PENDING) + .retryCount(0) + .build(); + } + + public void markDone() { + this.status = ObjectStorageDeleteStatus.DONE; + this.lastError = null; + this.lastAttemptedAt = LocalDateTime.now(); + this.claimToken = null; + } + + public void markFailed(String errorMessage) { + this.status = ObjectStorageDeleteStatus.FAILED; + this.retryCount++; + this.lastError = truncate(errorMessage); + this.lastAttemptedAt = LocalDateTime.now(); + this.claimToken = null; + } + + private String truncate(String value) { + if (value == null || value.length() <= LAST_ERROR_MAX_LENGTH) { + return value; + } + return value.substring(0, LAST_ERROR_MAX_LENGTH); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteSourceType.java b/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteSourceType.java new file mode 100644 index 000000000..b37a75dd9 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteSourceType.java @@ -0,0 +1,5 @@ +package umc.cockple.demo.domain.file.enums; + +public enum ObjectStorageDeleteSourceType { + PARTY_CHAT_ROOM +} diff --git a/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteStatus.java b/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteStatus.java new file mode 100644 index 000000000..e0f6ead61 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/enums/ObjectStorageDeleteStatus.java @@ -0,0 +1,14 @@ +package umc.cockple.demo.domain.file.enums; + +import java.util.List; + +public enum ObjectStorageDeleteStatus { + PENDING, + PROCESSING, + DONE, + FAILED; + + public static List retryableStatuses() { + return List.of(PENDING, FAILED); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/file/repository/ObjectStorageDeleteOutboxRepository.java b/src/main/java/umc/cockple/demo/domain/file/repository/ObjectStorageDeleteOutboxRepository.java new file mode 100644 index 000000000..4d5ac268a --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/repository/ObjectStorageDeleteOutboxRepository.java @@ -0,0 +1,75 @@ +package umc.cockple.demo.domain.file.repository; + +import org.springframework.data.domain.Pageable; +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; +import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface ObjectStorageDeleteOutboxRepository extends JpaRepository { + + @Query(""" + SELECT outbox.id FROM ObjectStorageDeleteOutbox outbox + WHERE outbox.retryCount < :maxRetryCount + AND ( + outbox.status IN :retryableStatuses + OR ( + outbox.status = :processingStatus + AND ( + outbox.lastAttemptedAt IS NULL + OR outbox.lastAttemptedAt < :processingTimeoutBefore + ) + ) + ) + ORDER BY outbox.createdAt ASC + """) + List findClaimCandidateIds( + @Param("retryableStatuses") Collection retryableStatuses, + @Param("processingStatus") ObjectStorageDeleteStatus processingStatus, + @Param("maxRetryCount") int maxRetryCount, + @Param("processingTimeoutBefore") LocalDateTime processingTimeoutBefore, + Pageable pageable + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE ObjectStorageDeleteOutbox outbox + SET outbox.status = :processingStatus, + outbox.lastAttemptedAt = :claimedAt, + outbox.claimToken = :claimToken + WHERE outbox.id = :outboxId + AND outbox.retryCount < :maxRetryCount + AND ( + outbox.status IN :retryableStatuses + OR ( + outbox.status = :processingStatus + AND ( + outbox.lastAttemptedAt IS NULL + OR outbox.lastAttemptedAt < :processingTimeoutBefore + ) + ) + ) + """) + int claimForProcessing( + @Param("outboxId") Long outboxId, + @Param("retryableStatuses") Collection retryableStatuses, + @Param("processingStatus") ObjectStorageDeleteStatus processingStatus, + @Param("maxRetryCount") int maxRetryCount, + @Param("processingTimeoutBefore") LocalDateTime processingTimeoutBefore, + @Param("claimedAt") LocalDateTime claimedAt, + @Param("claimToken") String claimToken + ); + + Optional findByIdAndStatusAndClaimToken( + Long id, + ObjectStorageDeleteStatus status, + String claimToken + ); +} diff --git a/src/main/java/umc/cockple/demo/domain/file/service/ClaimedObjectStorageDeleteOutbox.java b/src/main/java/umc/cockple/demo/domain/file/service/ClaimedObjectStorageDeleteOutbox.java new file mode 100644 index 000000000..95b1e806c --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/service/ClaimedObjectStorageDeleteOutbox.java @@ -0,0 +1,8 @@ +package umc.cockple.demo.domain.file.service; + +record ClaimedObjectStorageDeleteOutbox( + Long id, + String objectKey, + String claimToken +) { +} diff --git a/src/main/java/umc/cockple/demo/domain/file/service/GcsObjectStorageClient.java b/src/main/java/umc/cockple/demo/domain/file/service/GcsObjectStorageClient.java new file mode 100644 index 000000000..00aa4ba9e --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/service/GcsObjectStorageClient.java @@ -0,0 +1,16 @@ +package umc.cockple.demo.domain.file.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class GcsObjectStorageClient implements ObjectStorageClient { + + private final FileService fileService; + + @Override + public void delete(String objectKey) { + fileService.delete(objectKey); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageClient.java b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageClient.java new file mode 100644 index 000000000..422bffd01 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageClient.java @@ -0,0 +1,6 @@ +package umc.cockple.demo.domain.file.service; + +public interface ObjectStorageClient { + + void delete(String objectKey); +} diff --git a/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxClaimService.java b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxClaimService.java new file mode 100644 index 000000000..bfd2f48e9 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxClaimService.java @@ -0,0 +1,80 @@ +package umc.cockple.demo.domain.file.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; +import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ObjectStorageDeleteOutboxClaimService { + + private final ObjectStorageDeleteOutboxRepository objectStorageDeleteOutboxRepository; + + @Transactional + public Optional claim( + Long outboxId, + int maxRetryCount, + LocalDateTime processingTimeoutBefore + ) { + String claimToken = UUID.randomUUID().toString(); + int updatedCount = objectStorageDeleteOutboxRepository.claimForProcessing( + outboxId, + ObjectStorageDeleteStatus.retryableStatuses(), + ObjectStorageDeleteStatus.PROCESSING, + maxRetryCount, + processingTimeoutBefore, + LocalDateTime.now(), + claimToken + ); + + if (updatedCount == 0) { + if (!objectStorageDeleteOutboxRepository.existsById(outboxId)) { + throw new IllegalArgumentException("Object storage 삭제 outbox를 찾을 수 없습니다. id=" + outboxId); + } + return Optional.empty(); + } + + return objectStorageDeleteOutboxRepository + .findByIdAndStatusAndClaimToken(outboxId, ObjectStorageDeleteStatus.PROCESSING, claimToken) + .map(outbox -> new ClaimedObjectStorageDeleteOutbox( + outbox.getId(), + outbox.getObjectKey(), + claimToken + )); + } + + @Transactional + public boolean markDone(ClaimedObjectStorageDeleteOutbox claimedOutbox) { + return findClaimedOutbox(claimedOutbox) + .map(outbox -> { + outbox.markDone(); + return true; + }) + .orElse(false); + } + + @Transactional + public boolean markFailed(ClaimedObjectStorageDeleteOutbox claimedOutbox, String errorMessage) { + return findClaimedOutbox(claimedOutbox) + .map(outbox -> { + outbox.markFailed(errorMessage); + return true; + }) + .orElse(false); + } + + private Optional findClaimedOutbox(ClaimedObjectStorageDeleteOutbox claimedOutbox) { + return objectStorageDeleteOutboxRepository.findByIdAndStatusAndClaimToken( + claimedOutbox.id(), + ObjectStorageDeleteStatus.PROCESSING, + claimedOutbox.claimToken() + ); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessor.java b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessor.java new file mode 100644 index 000000000..f989aa263 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessor.java @@ -0,0 +1,83 @@ +package umc.cockple.demo.domain.file.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; +import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ObjectStorageDeleteOutboxProcessor { + + private final ObjectStorageDeleteOutboxRepository objectStorageDeleteOutboxRepository; + private final ObjectStorageDeleteOutboxClaimService objectStorageDeleteOutboxClaimService; + private final ObjectStorageClient objectStorageClient; + + @Value("${cockple.object-storage-delete-outbox.batch-size:50}") + private int batchSize; + + @Value("${cockple.object-storage-delete-outbox.max-retry-count:5}") + private int maxRetryCount; + + @Value("${cockple.object-storage-delete-outbox.processing-timeout-minutes:10}") + private long processingTimeoutMinutes; + + public int processPendingBatch() { + LocalDateTime processingTimeoutBefore = LocalDateTime.now().minusMinutes(processingTimeoutMinutes); + List outboxIds = objectStorageDeleteOutboxRepository + .findClaimCandidateIds( + ObjectStorageDeleteStatus.retryableStatuses(), + ObjectStorageDeleteStatus.PROCESSING, + maxRetryCount, + processingTimeoutBefore, + PageRequest.of(0, batchSize) + ); + + int processedCount = 0; + for (Long outboxId : outboxIds) { + if (processOne(outboxId)) { + processedCount++; + } + } + return processedCount; + } + + public boolean processOne(Long outboxId) { + LocalDateTime processingTimeoutBefore = LocalDateTime.now().minusMinutes(processingTimeoutMinutes); + Optional claimedOutbox = objectStorageDeleteOutboxClaimService.claim( + outboxId, + maxRetryCount, + processingTimeoutBefore + ); + + return claimedOutbox + .map(this::process) + .orElse(false); + } + + private boolean process(ClaimedObjectStorageDeleteOutbox outbox) { + try { + objectStorageClient.delete(outbox.objectKey()); + if (objectStorageDeleteOutboxClaimService.markDone(outbox)) { + log.info("Object storage 삭제 outbox 처리 완료 - outboxId: {}, objectKey: {}", outbox.id(), outbox.objectKey()); + } else { + log.warn("Object storage 삭제 outbox 처리 완료 후 상태 변경 스킵 - outboxId: {}, objectKey: {}", outbox.id(), outbox.objectKey()); + } + } catch (Exception e) { + if (objectStorageDeleteOutboxClaimService.markFailed(outbox, e.getMessage())) { + log.warn("Object storage 삭제 outbox 처리 실패 - outboxId: {}, objectKey: {}", outbox.id(), outbox.objectKey(), e); + } else { + log.warn("Object storage 삭제 outbox 처리 실패 후 상태 변경 스킵 - outboxId: {}, objectKey: {}", outbox.id(), outbox.objectKey(), e); + } + } + return true; + } +} diff --git a/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxScheduler.java b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxScheduler.java new file mode 100644 index 000000000..d603ee711 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxScheduler.java @@ -0,0 +1,32 @@ +package umc.cockple.demo.domain.file.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +@ConditionalOnProperty( + prefix = "cockple.object-storage-delete-outbox.scheduler", + name = "enabled", + havingValue = "true", + matchIfMissing = true +) +public class ObjectStorageDeleteOutboxScheduler { + + private final ObjectStorageDeleteOutboxProcessor objectStorageDeleteOutboxProcessor; + + @Scheduled( + initialDelayString = "${cockple.object-storage-delete-outbox.scheduler.initial-delay-ms:10000}", + fixedDelayString = "${cockple.object-storage-delete-outbox.scheduler.fixed-delay-ms:60000}" + ) + public void processPendingDeletes() { + int processedCount = objectStorageDeleteOutboxProcessor.processPendingBatch(); + if (processedCount > 0) { + log.info("Object storage 삭제 outbox 배치 처리 완료 - 처리 수: {}", processedCount); + } + } +} diff --git a/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxService.java b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxService.java new file mode 100644 index 000000000..7273517c3 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxService.java @@ -0,0 +1,45 @@ +package umc.cockple.demo.domain.file.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteSourceType; +import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; + +import java.util.Collection; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ObjectStorageDeleteOutboxService { + + private final ObjectStorageDeleteOutboxRepository objectStorageDeleteOutboxRepository; + + @Transactional + public void enqueuePartyChatFiles(Long chatRoomId, Collection objectKeys) { + if (objectKeys == null || objectKeys.isEmpty()) { + return; + } + + List outboxes = objectKeys.stream() + .filter(StringUtils::hasText) + .distinct() + .map(objectKey -> ObjectStorageDeleteOutbox.pending( + objectKey, + ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM, + chatRoomId + )) + .toList(); + + if (outboxes.isEmpty()) { + return; + } + + objectStorageDeleteOutboxRepository.saveAll(outboxes); + log.info("Object storage 삭제 outbox 등록 - chatRoomId: {}, 파일 수: {}", chatRoomId, outboxes.size()); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/party/events/PartyDeletedEvent.java b/src/main/java/umc/cockple/demo/domain/party/events/PartyDeletedEvent.java new file mode 100644 index 000000000..3a7436dbc --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/party/events/PartyDeletedEvent.java @@ -0,0 +1,17 @@ +package umc.cockple.demo.domain.party.events; + +import java.time.LocalDateTime; + +public record PartyDeletedEvent( + Long partyId, + Long deletedByMemberId, + LocalDateTime occurredAt +) { + public static PartyDeletedEvent deleted(Long partyId, Long deletedByMemberId) { + return new PartyDeletedEvent( + partyId, + deletedByMemberId, + LocalDateTime.now() + ); + } +} diff --git a/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java b/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java index 9616b587a..6c1a684ea 100644 --- a/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java +++ b/src/main/java/umc/cockple/demo/domain/party/service/PartyCommandServiceImpl.java @@ -22,6 +22,7 @@ import umc.cockple.demo.domain.party.enums.PartyStatus; import umc.cockple.demo.domain.party.enums.RequestAction; import umc.cockple.demo.domain.party.enums.RequestStatus; +import umc.cockple.demo.domain.party.events.PartyDeletedEvent; import umc.cockple.demo.domain.party.events.PartyMemberJoinedEvent; import umc.cockple.demo.domain.party.exception.PartyErrorCode; import umc.cockple.demo.domain.party.exception.PartyException; @@ -112,6 +113,7 @@ public void deleteParty(Long partyId, Long memberId) { //Party 엔티티의 상태를 INACTIVE로 변경 party.delete(); + applicationEventPublisher.publishEvent(PartyDeletedEvent.deleted(partyId, memberId)); createNotification(member, partyId, NotificationTarget.PARTY_DELETE); diff --git a/src/main/resources/db/migration/V2026.05.27.13.30__create_object_storage_delete_outbox.sql b/src/main/resources/db/migration/V2026.05.27.13.30__create_object_storage_delete_outbox.sql new file mode 100644 index 000000000..ef13bf4c2 --- /dev/null +++ b/src/main/resources/db/migration/V2026.05.27.13.30__create_object_storage_delete_outbox.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS object_storage_delete_outbox +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at DATETIME(6), + updated_at DATETIME(6), + object_key VARCHAR(512) NOT NULL, + source_type VARCHAR(50) NOT NULL, + source_id BIGINT, + PRIMARY KEY (id), + INDEX idx_object_storage_delete_outbox_source (source_type, source_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; diff --git a/src/main/resources/db/migration/V2026.05.27.13.40__add_status_to_object_storage_delete_outbox.sql b/src/main/resources/db/migration/V2026.05.27.13.40__add_status_to_object_storage_delete_outbox.sql new file mode 100644 index 000000000..01ced2a49 --- /dev/null +++ b/src/main/resources/db/migration/V2026.05.27.13.40__add_status_to_object_storage_delete_outbox.sql @@ -0,0 +1,6 @@ +ALTER TABLE object_storage_delete_outbox + ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0, + ADD COLUMN last_error VARCHAR(2000), + ADD COLUMN last_attempted_at DATETIME(6), + ADD INDEX idx_object_storage_delete_outbox_retry (status, retry_count, created_at); diff --git a/src/main/resources/db/migration/V2026.05.27.14.10__add_claim_to_object_storage_delete_outbox.sql b/src/main/resources/db/migration/V2026.05.27.14.10__add_claim_to_object_storage_delete_outbox.sql new file mode 100644 index 000000000..f0764b22a --- /dev/null +++ b/src/main/resources/db/migration/V2026.05.27.14.10__add_claim_to_object_storage_delete_outbox.sql @@ -0,0 +1,3 @@ +ALTER TABLE object_storage_delete_outbox + ADD COLUMN claim_token VARCHAR(36), + ADD INDEX idx_object_storage_delete_outbox_claim (status, retry_count, last_attempted_at, created_at); diff --git a/src/test/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListenerTest.java b/src/test/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListenerTest.java new file mode 100644 index 000000000..b0ae82c4c --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/events/ChatRoomRedisCleanupListenerTest.java @@ -0,0 +1,79 @@ +package umc.cockple.demo.domain.chat.events; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.scheduling.annotation.Async; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import umc.cockple.demo.domain.chat.service.websocket.ChatListSubscriptionService; +import umc.cockple.demo.domain.chat.service.websocket.ChatRoomListCacheService; +import umc.cockple.demo.domain.chat.service.websocket.RedisSubscriptionService; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatRoomRedisCleanupListener") +class ChatRoomRedisCleanupListenerTest { + + @InjectMocks + private ChatRoomRedisCleanupListener listener; + + @Mock + private ChatRoomListCacheService chatRoomListCacheService; + @Mock + private RedisSubscriptionService redisSubscriptionService; + @Mock + private ChatListSubscriptionService chatListSubscriptionService; + + @Test + @DisplayName("채팅방 Redis 정리 이벤트는 커밋 이후 비동기로 처리되도록 설정한다") + void handleChatRoomRedisCleanup_runsAfterCommit() throws NoSuchMethodException { + Method method = ChatRoomRedisCleanupListener.class.getDeclaredMethod( + "handleChatRoomRedisCleanup", + ChatRoomRedisCleanupEvent.class + ); + + TransactionalEventListener annotation = method.getAnnotation(TransactionalEventListener.class); + + assertThat(annotation).isNotNull(); + assertThat(annotation.phase()).isEqualTo(TransactionPhase.AFTER_COMMIT); + assertThat(method.getAnnotation(Async.class)).isNotNull(); + } + + @Test + @DisplayName("채팅방 Redis 정리 이벤트를 받으면 마지막 메시지 캐시와 구독 키를 삭제한다") + void handleChatRoomRedisCleanup_clearsCacheAndSubscriptionKeys() { + Long chatRoomId = 1L; + ChatRoomRedisCleanupEvent event = ChatRoomRedisCleanupEvent.of(chatRoomId); + + listener.handleChatRoomRedisCleanup(event); + + var inOrder = inOrder(chatRoomListCacheService, redisSubscriptionService, chatListSubscriptionService); + inOrder.verify(chatRoomListCacheService).evictLastMessage(chatRoomId); + inOrder.verify(redisSubscriptionService).tryClearRoomSubscribers(chatRoomId); + inOrder.verify(chatListSubscriptionService).tryClearChatListSubscribers(chatRoomId); + } + + @Test + @DisplayName("마지막 메시지 캐시 삭제가 실패해도 구독 키 정리를 계속한다") + void handleChatRoomRedisCleanup_continuesWhenCacheEvictFails() { + Long chatRoomId = 1L; + willThrow(new RuntimeException("redis cache down")) + .given(chatRoomListCacheService) + .evictLastMessage(chatRoomId); + + listener.handleChatRoomRedisCleanup(ChatRoomRedisCleanupEvent.of(chatRoomId)); + + verify(redisSubscriptionService).tryClearRoomSubscribers(chatRoomId); + verify(chatListSubscriptionService).tryClearChatListSubscribers(chatRoomId); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListenerTest.java b/src/test/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListenerTest.java new file mode 100644 index 000000000..3fc41f3d3 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/events/PartyDeletedChatCleanupListenerTest.java @@ -0,0 +1,35 @@ +package umc.cockple.demo.domain.chat.events; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.chat.service.ChatRoomService; +import umc.cockple.demo.domain.party.events.PartyDeletedEvent; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("PartyDeletedChatCleanupListener") +class PartyDeletedChatCleanupListenerTest { + + @InjectMocks + private PartyDeletedChatCleanupListener listener; + + @Mock + private ChatRoomService chatRoomService; + + @Test + @DisplayName("모임 삭제 이벤트를 받으면 채팅방 삭제 서비스에 위임한다") + void handlePartyDeleted_delegatesToChatRoomService() { + Long partyId = 1L; + Long deletedByMemberId = 10L; + PartyDeletedEvent event = PartyDeletedEvent.deleted(partyId, deletedByMemberId); + + listener.handlePartyDeleted(event); + + verify(chatRoomService).deletePartyChatRoom(partyId); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java new file mode 100644 index 000000000..76c281a44 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/ChatRoomServiceTest.java @@ -0,0 +1,115 @@ +package umc.cockple.demo.domain.chat.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.chat.domain.ChatRoom; +import umc.cockple.demo.domain.chat.events.ChatRoomRedisCleanupEvent; +import umc.cockple.demo.domain.chat.repository.ChatFileRepository; +import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; +import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; +import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; +import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; +import umc.cockple.demo.domain.file.service.ObjectStorageDeleteOutboxService; +import umc.cockple.demo.support.fixture.ChatFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.util.List; +import java.util.Optional; + +import static org.mockito.BDDMockito.given; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatRoomService 단위 테스트") +class ChatRoomServiceTest { + + @InjectMocks + private ChatRoomService chatRoomService; + + @Mock + private ChatRoomRepository chatRoomRepository; + @Mock + private ChatFileRepository chatFileRepository; + @Mock + private ChatMessageRepository chatMessageRepository; + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + @Mock + private MessageReadStatusRepository messageReadStatusRepository; + @Mock + private ObjectStorageDeleteOutboxService objectStorageDeleteOutboxService; + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @Nested + @DisplayName("deletePartyChatRoom") + class DeletePartyChatRoom { + + @Test + @DisplayName("성공 - 채팅방이 있으면 읽음 상태, 파일, 메시지, 멤버, 방을 삭제하고 Redis 정리 이벤트를 발행한다") + void success_deletePartyChatRoom() { + Long partyId = 1L; + Long chatRoomId = 2L; + + ChatRoom chatRoom = ChatFixture.createPartyChatRoom( + PartyFixture.createParty("테스트 모임", 10L, PartyFixture.createPartyAddr("서울", "강남")) + ); + ReflectionTestUtils.setField(chatRoom, "id", chatRoomId); + List objectKeys = List.of("chat/a.jpg", "chat/b.jpg"); + + given(chatRoomRepository.findByPartyId(partyId)).willReturn(Optional.of(chatRoom)); + given(chatFileRepository.findObjectKeysByChatRoomId(chatRoomId)).willReturn(objectKeys); + + chatRoomService.deletePartyChatRoom(partyId); + + var inOrder = inOrder( + chatFileRepository, + objectStorageDeleteOutboxService, + messageReadStatusRepository, + chatMessageRepository, + chatRoomMemberRepository, + chatRoomRepository, + applicationEventPublisher + ); + inOrder.verify(chatFileRepository).findObjectKeysByChatRoomId(chatRoomId); + inOrder.verify(objectStorageDeleteOutboxService).enqueuePartyChatFiles(chatRoomId, objectKeys); + inOrder.verify(messageReadStatusRepository).deleteByChatRoomId(chatRoomId); + inOrder.verify(chatFileRepository).deleteByChatRoomId(chatRoomId); + inOrder.verify(chatMessageRepository).deleteByChatRoomId(chatRoomId); + inOrder.verify(chatRoomMemberRepository).deleteByChatRoomId(chatRoomId); + inOrder.verify(chatRoomRepository).deleteRoomById(chatRoomId); + inOrder.verify(applicationEventPublisher).publishEvent(new ChatRoomRedisCleanupEvent(chatRoomId)); + } + + @Test + @DisplayName("성공 - 채팅방이 없으면 아무것도 삭제하지 않고 종료한다") + void success_deletePartyChatRoom_whenRoomMissing() { + Long partyId = 1L; + given(chatRoomRepository.findByPartyId(partyId)).willReturn(Optional.empty()); + + chatRoomService.deletePartyChatRoom(partyId); + + verify(messageReadStatusRepository, never()).deleteByChatRoomId(org.mockito.ArgumentMatchers.anyLong()); + verify(chatFileRepository, never()).findObjectKeysByChatRoomId(org.mockito.ArgumentMatchers.anyLong()); + verify(objectStorageDeleteOutboxService, never()).enqueuePartyChatFiles( + org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.any() + ); + verify(chatFileRepository, never()).deleteByChatRoomId(org.mockito.ArgumentMatchers.anyLong()); + verify(chatMessageRepository, never()).deleteByChatRoomId(org.mockito.ArgumentMatchers.anyLong()); + verify(chatRoomMemberRepository, never()).deleteByChatRoomId(org.mockito.ArgumentMatchers.anyLong()); + verify(chatRoomRepository, never()).deleteRoomById(org.mockito.ArgumentMatchers.anyLong()); + verify(applicationEventPublisher, never()).publishEvent(any(ChatRoomRedisCleanupEvent.class)); + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionServiceTest.java new file mode 100644 index 000000000..5646a7321 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/ChatListSubscriptionServiceTest.java @@ -0,0 +1,43 @@ +package umc.cockple.demo.domain.chat.service.websocket; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatListSubscriptionService 단위 테스트") +class ChatListSubscriptionServiceTest { + + @InjectMocks + private ChatListSubscriptionService chatListSubscriptionService; + + @Mock + private StringRedisTemplate stringRedisTemplate; + + @Test + @DisplayName("채팅 목록 구독 키는 best-effort로 삭제한다") + void tryClearChatListSubscribers_deletesKey() { + chatListSubscriptionService.tryClearChatListSubscribers(10L); + + verify(stringRedisTemplate).delete("chatlist:subscribers:10"); + } + + @Test + @DisplayName("채팅 목록 구독 키 삭제 실패는 TTL 만료에 맡기고 예외를 전파하지 않는다") + void tryClearChatListSubscribers_doesNotThrowWhenRedisFails() { + willThrow(new RuntimeException("redis down")) + .given(stringRedisTemplate) + .delete("chatlist:subscribers:10"); + + assertThatCode(() -> chatListSubscriptionService.tryClearChatListSubscribers(10L)) + .doesNotThrowAnyException(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionServiceTest.java b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionServiceTest.java new file mode 100644 index 000000000..c38192af5 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/chat/service/websocket/RedisSubscriptionServiceTest.java @@ -0,0 +1,43 @@ +package umc.cockple.demo.domain.chat.service.websocket; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RedisSubscriptionService 단위 테스트") +class RedisSubscriptionServiceTest { + + @InjectMocks + private RedisSubscriptionService redisSubscriptionService; + + @Mock + private StringRedisTemplate stringRedisTemplate; + + @Test + @DisplayName("채팅방 구독 키는 best-effort로 삭제한다") + void tryClearRoomSubscribers_deletesKey() { + redisSubscriptionService.tryClearRoomSubscribers(10L); + + verify(stringRedisTemplate).delete("chatroom:subscribers:10"); + } + + @Test + @DisplayName("채팅방 구독 키 삭제 실패는 TTL 만료에 맡기고 예외를 전파하지 않는다") + void tryClearRoomSubscribers_doesNotThrowWhenRedisFails() { + willThrow(new RuntimeException("redis down")) + .given(stringRedisTemplate) + .delete("chatroom:subscribers:10"); + + assertThatCode(() -> redisSubscriptionService.tryClearRoomSubscribers(10L)) + .doesNotThrowAnyException(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/file/integration/ObjectStorageDeleteOutboxIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/file/integration/ObjectStorageDeleteOutboxIntegrationTest.java new file mode 100644 index 000000000..3f9b3ed09 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/file/integration/ObjectStorageDeleteOutboxIntegrationTest.java @@ -0,0 +1,147 @@ +package umc.cockple.demo.domain.file.integration; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.chat.domain.ChatMessage; +import umc.cockple.demo.domain.chat.domain.ChatMessageFile; +import umc.cockple.demo.domain.chat.domain.ChatRoom; +import umc.cockple.demo.domain.chat.domain.ChatRoomMember; +import umc.cockple.demo.domain.chat.repository.ChatFileRepository; +import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; +import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; +import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; +import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteSourceType; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; +import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; +import umc.cockple.demo.domain.file.service.ObjectStorageClient; +import umc.cockple.demo.domain.file.service.ObjectStorageDeleteOutboxProcessor; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberParty; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.domain.PartyAddr; +import umc.cockple.demo.domain.party.repository.PartyAddrRepository; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.domain.party.service.PartyCommandService; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.fixture.ChatFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; + +@Transactional +@Import(ObjectStorageDeleteOutboxIntegrationTest.TestObjectStorageConfig.class) +@DisplayName("ObjectStorageDeleteOutbox 통합 테스트") +class ObjectStorageDeleteOutboxIntegrationTest extends IntegrationTestBase { + + @Autowired + private MemberRepository memberRepository; + @Autowired + private MemberPartyRepository memberPartyRepository; + @Autowired + private PartyAddrRepository partyAddrRepository; + @Autowired + private PartyRepository partyRepository; + @Autowired + private ChatRoomRepository chatRoomRepository; + @Autowired + private ChatRoomMemberRepository chatRoomMemberRepository; + @Autowired + private ChatMessageRepository chatMessageRepository; + @Autowired + private ChatFileRepository chatFileRepository; + @Autowired + private ObjectStorageDeleteOutboxRepository objectStorageDeleteOutboxRepository; + @Autowired + private ObjectStorageDeleteOutboxProcessor objectStorageDeleteOutboxProcessor; + @Autowired + private ObjectStorageClient objectStorageClient; + @Autowired + private PartyCommandService partyCommandService; + + @PersistenceContext + private EntityManager entityManager; + + private Member owner; + private Party party; + private ChatRoom chatRoom; + + @BeforeEach + void setUp() { + reset(objectStorageClient); + + owner = memberRepository.save(MemberFixture.createMember("매니저", Gender.MALE, Level.A, 2001L)); + PartyAddr partyAddr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울", "강남")); + party = partyRepository.save(PartyFixture.createParty("삭제 테스트 모임", owner.getId(), partyAddr)); + memberPartyRepository.save(MemberParty.createOwner(owner, party)); + + chatRoom = chatRoomRepository.save(ChatRoom.createPartyChatRoom(party)); + chatRoomMemberRepository.save(ChatRoomMember.create(chatRoom, owner)); + } + + @Test + @DisplayName("모임 삭제 시 채팅 파일 object key를 outbox에 보존하고 processor가 삭제 완료 처리한다") + void deleteParty_enqueuesObjectKey_thenProcessorMarksDone() { + String objectKey = "chat/delete-target.jpg"; + ChatMessage message = chatMessageRepository.save(ChatFixture.createTextMessage(chatRoom, owner, "첨부 메시지")); + ChatMessageFile file = ChatFixture.createChatMessageFile(message, objectKey, 0, "delete-target.jpg"); + message.getChatMessageFiles().add(file); + chatMessageRepository.save(message); + entityManager.flush(); + entityManager.clear(); + + partyCommandService.deleteParty(party.getId(), owner.getId()); + entityManager.flush(); + entityManager.clear(); + + List outboxes = objectStorageDeleteOutboxRepository.findAll(); + assertThat(outboxes).hasSize(1); + ObjectStorageDeleteOutbox outbox = outboxes.get(0); + assertThat(outbox.getObjectKey()).isEqualTo(objectKey); + assertThat(outbox.getSourceType()).isEqualTo(ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM); + assertThat(outbox.getSourceId()).isEqualTo(chatRoom.getId()); + assertThat(outbox.getStatus()).isEqualTo(ObjectStorageDeleteStatus.PENDING); + assertThat(chatFileRepository.findObjectKeysByChatRoomId(chatRoom.getId())).isEmpty(); + + int processedCount = objectStorageDeleteOutboxProcessor.processPendingBatch(); + entityManager.flush(); + entityManager.clear(); + + assertThat(processedCount).isEqualTo(1); + verify(objectStorageClient).delete(objectKey); + ObjectStorageDeleteOutbox processedOutbox = objectStorageDeleteOutboxRepository.findById(outbox.getId()).orElseThrow(); + assertThat(processedOutbox.getStatus()).isEqualTo(ObjectStorageDeleteStatus.DONE); + assertThat(processedOutbox.getLastError()).isNull(); + assertThat(processedOutbox.getLastAttemptedAt()).isNotNull(); + } + + @TestConfiguration(proxyBeanMethods = false) + static class TestObjectStorageConfig { + + @Bean + @Primary + ObjectStorageClient objectStorageClient() { + return mock(ObjectStorageClient.class); + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/file/service/GcsObjectStorageClientTest.java b/src/test/java/umc/cockple/demo/domain/file/service/GcsObjectStorageClientTest.java new file mode 100644 index 000000000..0ac0f8ea5 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/file/service/GcsObjectStorageClientTest.java @@ -0,0 +1,29 @@ +package umc.cockple.demo.domain.file.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GcsObjectStorageClient 단위 테스트") +class GcsObjectStorageClientTest { + + @InjectMocks + private GcsObjectStorageClient gcsObjectStorageClient; + + @Mock + private FileService fileService; + + @Test + @DisplayName("objectKey 삭제를 FileService에 위임한다") + void delete_delegatesToFileService() { + gcsObjectStorageClient.delete("chat/file.jpg"); + + verify(fileService).delete("chat/file.jpg"); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxClaimServiceTest.java b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxClaimServiceTest.java new file mode 100644 index 000000000..b02bd8fae --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxClaimServiceTest.java @@ -0,0 +1,163 @@ +package umc.cockple.demo.domain.file.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteSourceType; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; +import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ObjectStorageDeleteOutboxClaimService 단위 테스트") +class ObjectStorageDeleteOutboxClaimServiceTest { + + @InjectMocks + private ObjectStorageDeleteOutboxClaimService objectStorageDeleteOutboxClaimService; + + @Mock + private ObjectStorageDeleteOutboxRepository objectStorageDeleteOutboxRepository; + + @Test + @DisplayName("claim 성공 시 PROCESSING으로 선점한 outbox 정보를 반환한다") + void claim_returnsClaimedOutboxWhenUpdateSucceeds() { + Long outboxId = 1L; + ObjectStorageDeleteOutbox outbox = ObjectStorageDeleteOutbox.pending( + "chat/file.jpg", + ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM, + 10L + ); + ReflectionTestUtils.setField(outbox, "id", outboxId); + given(objectStorageDeleteOutboxRepository.claimForProcessing( + eq(outboxId), + eq(List.of(ObjectStorageDeleteStatus.PENDING, ObjectStorageDeleteStatus.FAILED)), + eq(ObjectStorageDeleteStatus.PROCESSING), + eq(5), + any(LocalDateTime.class), + any(LocalDateTime.class), + any(String.class) + )).willReturn(1); + given(objectStorageDeleteOutboxRepository.findByIdAndStatusAndClaimToken( + eq(outboxId), + eq(ObjectStorageDeleteStatus.PROCESSING), + any(String.class) + )).willReturn(Optional.of(outbox)); + + Optional claimedOutbox = objectStorageDeleteOutboxClaimService.claim( + outboxId, + 5, + LocalDateTime.now().minusMinutes(10) + ); + + assertThat(claimedOutbox).isPresent(); + assertThat(claimedOutbox.get().id()).isEqualTo(outboxId); + assertThat(claimedOutbox.get().objectKey()).isEqualTo("chat/file.jpg"); + assertThat(claimedOutbox.get().claimToken()).isNotBlank(); + } + + @Test + @DisplayName("이미 다른 worker가 선점한 outbox는 빈 결과를 반환한다") + void claim_returnsEmptyWhenUpdateSkipped() { + Long outboxId = 1L; + given(objectStorageDeleteOutboxRepository.claimForProcessing( + eq(outboxId), + eq(List.of(ObjectStorageDeleteStatus.PENDING, ObjectStorageDeleteStatus.FAILED)), + eq(ObjectStorageDeleteStatus.PROCESSING), + eq(5), + any(LocalDateTime.class), + any(LocalDateTime.class), + any(String.class) + )).willReturn(0); + given(objectStorageDeleteOutboxRepository.existsById(outboxId)).willReturn(true); + + Optional claimedOutbox = objectStorageDeleteOutboxClaimService.claim( + outboxId, + 5, + LocalDateTime.now().minusMinutes(10) + ); + + assertThat(claimedOutbox).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 outbox claim은 예외를 반환한다") + void claim_throwsWhenOutboxMissing() { + Long outboxId = 1L; + given(objectStorageDeleteOutboxRepository.claimForProcessing( + eq(outboxId), + eq(List.of(ObjectStorageDeleteStatus.PENDING, ObjectStorageDeleteStatus.FAILED)), + eq(ObjectStorageDeleteStatus.PROCESSING), + eq(5), + any(LocalDateTime.class), + any(LocalDateTime.class), + any(String.class) + )).willReturn(0); + given(objectStorageDeleteOutboxRepository.existsById(outboxId)).willReturn(false); + + assertThatThrownBy(() -> objectStorageDeleteOutboxClaimService.claim( + outboxId, + 5, + LocalDateTime.now().minusMinutes(10) + )).isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("claim token이 일치하는 PROCESSING outbox만 DONE으로 변경한다") + void markDone_marksOnlyClaimedOutbox() { + ObjectStorageDeleteOutbox outbox = ObjectStorageDeleteOutbox.pending( + "chat/file.jpg", + ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM, + 10L + ); + ClaimedObjectStorageDeleteOutbox claimedOutbox = new ClaimedObjectStorageDeleteOutbox(1L, "chat/file.jpg", "token"); + given(objectStorageDeleteOutboxRepository.findByIdAndStatusAndClaimToken( + 1L, + ObjectStorageDeleteStatus.PROCESSING, + "token" + )).willReturn(Optional.of(outbox)); + + boolean marked = objectStorageDeleteOutboxClaimService.markDone(claimedOutbox); + + assertThat(marked).isTrue(); + assertThat(outbox.getStatus()).isEqualTo(ObjectStorageDeleteStatus.DONE); + assertThat(outbox.getClaimToken()).isNull(); + } + + @Test + @DisplayName("claim token이 일치하는 PROCESSING outbox만 FAILED로 변경한다") + void markFailed_marksOnlyClaimedOutbox() { + ObjectStorageDeleteOutbox outbox = ObjectStorageDeleteOutbox.pending( + "chat/file.jpg", + ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM, + 10L + ); + ClaimedObjectStorageDeleteOutbox claimedOutbox = new ClaimedObjectStorageDeleteOutbox(1L, "chat/file.jpg", "token"); + given(objectStorageDeleteOutboxRepository.findByIdAndStatusAndClaimToken( + 1L, + ObjectStorageDeleteStatus.PROCESSING, + "token" + )).willReturn(Optional.of(outbox)); + + boolean marked = objectStorageDeleteOutboxClaimService.markFailed(claimedOutbox, "delete failed"); + + assertThat(marked).isTrue(); + assertThat(outbox.getStatus()).isEqualTo(ObjectStorageDeleteStatus.FAILED); + assertThat(outbox.getRetryCount()).isEqualTo(1); + assertThat(outbox.getLastError()).isEqualTo("delete failed"); + assertThat(outbox.getClaimToken()).isNull(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessorTest.java b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessorTest.java new file mode 100644 index 000000000..faaefd1e1 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxProcessorTest.java @@ -0,0 +1,120 @@ +package umc.cockple.demo.domain.file.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; +import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ObjectStorageDeleteOutboxProcessor 단위 테스트") +class ObjectStorageDeleteOutboxProcessorTest { + + @InjectMocks + private ObjectStorageDeleteOutboxProcessor objectStorageDeleteOutboxProcessor; + + @Mock + private ObjectStorageDeleteOutboxRepository objectStorageDeleteOutboxRepository; + @Mock + private ObjectStorageDeleteOutboxClaimService objectStorageDeleteOutboxClaimService; + @Mock + private ObjectStorageClient objectStorageClient; + + @Test + @DisplayName("PENDING/FAILED outbox를 batchSize만큼 조회해 처리한다") + void processPendingBatch_deletesRetryTargets() { + ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "batchSize", 2); + ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "maxRetryCount", 5); + ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "processingTimeoutMinutes", 10L); + ClaimedObjectStorageDeleteOutbox first = new ClaimedObjectStorageDeleteOutbox(1L, "chat/first.jpg", "token-1"); + ClaimedObjectStorageDeleteOutbox second = new ClaimedObjectStorageDeleteOutbox(2L, "chat/second.jpg", "token-2"); + given(objectStorageDeleteOutboxRepository.findClaimCandidateIds( + eq(List.of(ObjectStorageDeleteStatus.PENDING, ObjectStorageDeleteStatus.FAILED)), + eq(ObjectStorageDeleteStatus.PROCESSING), + eq(5), + any(LocalDateTime.class), + eq(PageRequest.of(0, 2)) + )).willReturn(List.of(1L, 2L)); + given(objectStorageDeleteOutboxClaimService.claim(eq(1L), eq(5), any(LocalDateTime.class))) + .willReturn(Optional.of(first)); + given(objectStorageDeleteOutboxClaimService.claim(eq(2L), eq(5), any(LocalDateTime.class))) + .willReturn(Optional.of(second)); + given(objectStorageDeleteOutboxClaimService.markDone(first)).willReturn(true); + given(objectStorageDeleteOutboxClaimService.markDone(second)).willReturn(true); + + int processedCount = objectStorageDeleteOutboxProcessor.processPendingBatch(); + + assertThat(processedCount).isEqualTo(2); + verify(objectStorageClient).delete("chat/first.jpg"); + verify(objectStorageClient).delete("chat/second.jpg"); + verify(objectStorageDeleteOutboxClaimService).markDone(first); + verify(objectStorageDeleteOutboxClaimService).markDone(second); + } + + @Test + @DisplayName("삭제에 성공하면 outbox를 DONE으로 표시한다") + void processOne_marksDoneWhenDeleteSucceeds() { + ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "maxRetryCount", 5); + ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "processingTimeoutMinutes", 10L); + ClaimedObjectStorageDeleteOutbox outbox = new ClaimedObjectStorageDeleteOutbox(1L, "chat/file.jpg", "token"); + given(objectStorageDeleteOutboxClaimService.claim(eq(1L), eq(5), any(LocalDateTime.class))) + .willReturn(Optional.of(outbox)); + given(objectStorageDeleteOutboxClaimService.markDone(outbox)).willReturn(true); + + boolean processed = objectStorageDeleteOutboxProcessor.processOne(1L); + + assertThat(processed).isTrue(); + verify(objectStorageClient).delete("chat/file.jpg"); + verify(objectStorageDeleteOutboxClaimService).markDone(outbox); + } + + @Test + @DisplayName("삭제에 실패하면 실패 사유와 retryCount를 남긴다") + void processOne_marksFailedWhenDeleteFails() { + ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "maxRetryCount", 5); + ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "processingTimeoutMinutes", 10L); + ClaimedObjectStorageDeleteOutbox outbox = new ClaimedObjectStorageDeleteOutbox(1L, "chat/file.jpg", "token"); + given(objectStorageDeleteOutboxClaimService.claim(eq(1L), eq(5), any(LocalDateTime.class))) + .willReturn(Optional.of(outbox)); + willThrow(new RuntimeException("delete failed")) + .given(objectStorageClient) + .delete("chat/file.jpg"); + given(objectStorageDeleteOutboxClaimService.markFailed(outbox, "delete failed")).willReturn(true); + + boolean processed = objectStorageDeleteOutboxProcessor.processOne(1L); + + assertThat(processed).isTrue(); + verify(objectStorageDeleteOutboxClaimService).markFailed(outbox, "delete failed"); + } + + @Test + @DisplayName("다른 worker가 먼저 claim한 outbox는 처리하지 않는다") + void processOne_skipsWhenClaimFails() { + ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "maxRetryCount", 5); + ReflectionTestUtils.setField(objectStorageDeleteOutboxProcessor, "processingTimeoutMinutes", 10L); + given(objectStorageDeleteOutboxClaimService.claim(eq(1L), eq(5), any(LocalDateTime.class))) + .willReturn(Optional.empty()); + + boolean processed = objectStorageDeleteOutboxProcessor.processOne(1L); + + assertThat(processed).isFalse(); + verify(objectStorageClient, never()).delete(any()); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxSchedulerTest.java b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxSchedulerTest.java new file mode 100644 index 000000000..86757b952 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxSchedulerTest.java @@ -0,0 +1,29 @@ +package umc.cockple.demo.domain.file.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ObjectStorageDeleteOutboxScheduler 단위 테스트") +class ObjectStorageDeleteOutboxSchedulerTest { + + @InjectMocks + private ObjectStorageDeleteOutboxScheduler objectStorageDeleteOutboxScheduler; + + @Mock + private ObjectStorageDeleteOutboxProcessor objectStorageDeleteOutboxProcessor; + + @Test + @DisplayName("스케줄 실행 시 pending outbox 처리를 위임한다") + void processPendingDeletes_delegatesToProcessor() { + objectStorageDeleteOutboxScheduler.processPendingDeletes(); + + verify(objectStorageDeleteOutboxProcessor).processPendingBatch(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxServiceTest.java b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxServiceTest.java new file mode 100644 index 000000000..1d1d67ce1 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/file/service/ObjectStorageDeleteOutboxServiceTest.java @@ -0,0 +1,62 @@ +package umc.cockple.demo.domain.file.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteSourceType; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; +import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ObjectStorageDeleteOutboxService 단위 테스트") +class ObjectStorageDeleteOutboxServiceTest { + + @InjectMocks + private ObjectStorageDeleteOutboxService objectStorageDeleteOutboxService; + + @Mock + private ObjectStorageDeleteOutboxRepository objectStorageDeleteOutboxRepository; + + @Test + @DisplayName("채팅 object key를 중복 제거 후 PARTY_CHAT_ROOM outbox로 등록한다") + void enqueuePartyChatFiles_savesDistinctObjectKeys() { + Long chatRoomId = 10L; + List objectKeys = List.of("chat/a.jpg", "chat/a.jpg", "", "chat/b.jpg"); + + objectStorageDeleteOutboxService.enqueuePartyChatFiles(chatRoomId, objectKeys); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(objectStorageDeleteOutboxRepository).saveAll(captor.capture()); + + List savedOutboxes = captor.getValue(); + assertThat(savedOutboxes) + .extracting(ObjectStorageDeleteOutbox::getObjectKey) + .containsExactly("chat/a.jpg", "chat/b.jpg"); + assertThat(savedOutboxes) + .allSatisfy(outbox -> { + assertThat(outbox.getSourceType()).isEqualTo(ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM); + assertThat(outbox.getSourceId()).isEqualTo(chatRoomId); + assertThat(outbox.getStatus()).isEqualTo(ObjectStorageDeleteStatus.PENDING); + assertThat(outbox.getRetryCount()).isZero(); + }); + } + + @Test + @DisplayName("등록할 object key가 없으면 저장하지 않는다") + void enqueuePartyChatFiles_skipsWhenNoObjectKeys() { + objectStorageDeleteOutboxService.enqueuePartyChatFiles(10L, List.of("", " ")); + + verify(objectStorageDeleteOutboxRepository, never()).saveAll(org.mockito.ArgumentMatchers.any()); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java index 25fe51839..778b930d2 100644 --- a/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/party/integration/PartyIntegrationTest.java @@ -1,6 +1,8 @@ package umc.cockple.demo.domain.party.integration; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -10,12 +12,22 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; +import umc.cockple.demo.domain.chat.domain.ChatMessage; +import umc.cockple.demo.domain.chat.domain.ChatMessageFile; import umc.cockple.demo.domain.chat.domain.ChatRoom; import umc.cockple.demo.domain.chat.domain.ChatRoomMember; +import umc.cockple.demo.domain.chat.domain.MessageReadStatus; +import umc.cockple.demo.domain.chat.repository.ChatFileRepository; +import umc.cockple.demo.domain.chat.repository.ChatMessageRepository; import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository; import umc.cockple.demo.domain.chat.repository.ChatRoomRepository; +import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository; import umc.cockple.demo.domain.exercise.domain.Exercise; import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.file.domain.ObjectStorageDeleteOutbox; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteSourceType; +import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus; +import umc.cockple.demo.domain.file.repository.ObjectStorageDeleteOutboxRepository; import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.MemberAddr; import umc.cockple.demo.domain.member.domain.MemberParty; @@ -40,6 +52,7 @@ import umc.cockple.demo.global.enums.Role; import umc.cockple.demo.support.IntegrationTestBase; import umc.cockple.demo.support.SecurityContextHelper; +import umc.cockple.demo.support.fixture.ChatFixture; import umc.cockple.demo.support.fixture.ExerciseFixture; import umc.cockple.demo.support.fixture.MemberFixture; import umc.cockple.demo.support.fixture.PartyFixture; @@ -76,11 +89,21 @@ class PartyIntegrationTest extends IntegrationTestBase { @Autowired ChatRoomMemberRepository chatRoomMemberRepository; @Autowired + ChatMessageRepository chatMessageRepository; + @Autowired + MessageReadStatusRepository messageReadStatusRepository; + @Autowired + ChatFileRepository chatFileRepository; + @Autowired + ObjectStorageDeleteOutboxRepository objectStorageDeleteOutboxRepository; + @Autowired PartyJoinRequestRepository partyJoinRequestRepository; @Autowired PartyInvitationRepository partyInvitationRepository; @Autowired ObjectMapper objectMapper; + @PersistenceContext + EntityManager entityManager; private Member manager; private Member normalMember; @@ -786,15 +809,49 @@ class DeleteParty { void success_deleteParty() throws Exception { // given SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + ChatRoom partyChatRoom = chatRoomRepository.findByPartyId(party.getId()).orElseThrow(); + Long chatRoomId = partyChatRoom.getId(); + + ChatMessage chatMessage = chatMessageRepository.save( + ChatFixture.createTextMessage(partyChatRoom, manager, "삭제 전 메시지") + ); + Long chatMessageId = chatMessage.getId(); + String objectKey = "chat/delete-party-file.jpg"; + ChatMessageFile chatMessageFile = ChatFixture.createChatMessageFile( + chatMessage, + objectKey, + 0, + "delete-party-file.jpg" + ); + chatFileRepository.save(chatMessageFile); + messageReadStatusRepository.save(MessageReadStatus.createRead(chatMessageId, manager.getId(), chatRoomId)); + messageReadStatusRepository.save(MessageReadStatus.createUnread(chatMessageId, normalMember.getId(), chatRoomId)); + entityManager.flush(); + entityManager.clear(); // when & then mockMvc.perform(patch("/api/parties/{partyId}/status", party.getId())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value("COMMON200")); + entityManager.flush(); + entityManager.clear(); // 검증 Party deletedParty = partyRepository.findById(party.getId()).orElseThrow(); assertThat(deletedParty.getStatus()).isEqualTo(PartyStatus.INACTIVE); + assertThat(chatRoomRepository.findByPartyId(party.getId())).isEmpty(); + assertThat(chatRoomMemberRepository.findByChatRoomId(chatRoomId)).isEmpty(); + assertThat(chatMessageRepository.countByChatRoomId(chatRoomId)).isZero(); + assertThat(chatFileRepository.findObjectKeysByChatRoomId(chatRoomId)).isEmpty(); + assertThat(messageReadStatusRepository.findAll()) + .noneMatch(readStatus -> readStatus.getChatRoomId().equals(chatRoomId)); + List outboxes = objectStorageDeleteOutboxRepository.findAll(); + assertThat(outboxes).hasSize(1); + ObjectStorageDeleteOutbox outbox = outboxes.get(0); + assertThat(outbox.getObjectKey()).isEqualTo(objectKey); + assertThat(outbox.getSourceType()).isEqualTo(ObjectStorageDeleteSourceType.PARTY_CHAT_ROOM); + assertThat(outbox.getSourceId()).isEqualTo(chatRoomId); + assertThat(outbox.getStatus()).isEqualTo(ObjectStorageDeleteStatus.PENDING); } @Test diff --git a/src/test/java/umc/cockple/demo/domain/party/service/PartyCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/party/service/PartyCommandServiceTest.java index e90511e4c..dd750c071 100644 --- a/src/test/java/umc/cockple/demo/domain/party/service/PartyCommandServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/party/service/PartyCommandServiceTest.java @@ -29,6 +29,7 @@ import umc.cockple.demo.domain.party.enums.PartyStatus; import umc.cockple.demo.domain.party.enums.RequestAction; import umc.cockple.demo.domain.party.enums.RequestStatus; +import umc.cockple.demo.domain.party.events.PartyDeletedEvent; import umc.cockple.demo.domain.party.events.PartyMemberJoinedEvent; import umc.cockple.demo.domain.party.exception.PartyErrorCode; import umc.cockple.demo.domain.party.exception.PartyException; @@ -739,6 +740,7 @@ void success_deleteParty() { // then assertThat(party.getStatus()).isEqualTo(PartyStatus.INACTIVE); + verify(applicationEventPublisher).publishEvent(any(PartyDeletedEvent.class)); } @Test diff --git a/src/test/resources/application-integrationtest.yml b/src/test/resources/application-integrationtest.yml index befc63ec4..bf6c2af19 100644 --- a/src/test/resources/application-integrationtest.yml +++ b/src/test/resources/application-integrationtest.yml @@ -27,6 +27,11 @@ spring: gcs: bucket: test-bucket +cockple: + object-storage-delete-outbox: + scheduler: + enabled: false + kakao: client-id: test-client-id client-secret: test-client-secret diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 570edddd1..e055d686f 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -24,4 +24,9 @@ spring: use_sql_comments: true gcs: - bucket: test-bucket \ No newline at end of file + bucket: test-bucket + +cockple: + object-storage-delete-outbox: + scheduler: + enabled: false