From 9d627bdfb5da29cd101f16c962906d9ea845bb66 Mon Sep 17 00:00:00 2001 From: dustle Date: Tue, 30 Jun 2026 16:10:02 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=ED=95=84/?= =?UTF-8?q?=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=EB=A5=BC=20=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../binarycontent/entity/BinaryContent.java | 6 +- .../entity/BinaryContentUploadStatus.java | 2 - .../event/BinaryContentUploadEvent.java | 11 --- .../listener/BinaryContentUploadListener.java | 34 --------- .../service/BinaryContentService.java | 21 +++--- .../content/dto/response/ContentResponse.java | 2 - .../team5/mopl/content/entity/Content.java | 4 ++ .../mopl/content/mapper/ContentMapper.java | 1 - .../mopl/content/service/ContentService.java | 33 ++------- .../codeit/team5/mopl/user/entity/User.java | 4 ++ .../mopl/user/repository/UserRepository.java | 7 +- .../team5/mopl/user/service/UserService.java | 45 ++++-------- ...2__reduce_binary_content_upload_status.sql | 10 +++ .../BinaryContentUploadListenerTest.java | 61 ---------------- .../service/BinaryContentServiceTest.java | 71 +++++++++++-------- .../controller/ContentControllerTest.java | 11 +-- .../repository/ContentRepositoryTest.java | 2 +- .../content/service/ContentServiceTest.java | 67 ++++++----------- .../mopl/user/service/UserServiceTest.java | 49 +++++++------ 19 files changed, 145 insertions(+), 296 deletions(-) delete mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java delete mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java create mode 100644 src/main/resources/db/migration/V12__reduce_binary_content_upload_status.sql delete mode 100644 src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java b/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java index 9e0d29be..cede6104 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContent.java @@ -26,13 +26,13 @@ public class BinaryContent extends BaseUpdatableEntity { @Column(name = "upload_status", nullable = false, length = 20) private BinaryContentUploadStatus uploadStatus; - public static BinaryContent pending(String url) { - BinaryContent binaryContent = new BinaryContent(); + public static BinaryContent completed(String url) { if (!StringUtils.hasText(url)) { throw new InvalidBinaryContentUrlException(); } + BinaryContent binaryContent = new BinaryContent(); binaryContent.url = url; - binaryContent.uploadStatus = BinaryContentUploadStatus.PENDING; + binaryContent.uploadStatus = BinaryContentUploadStatus.COMPLETED; return binaryContent; } diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContentUploadStatus.java b/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContentUploadStatus.java index 1ac92ebf..c473f4ab 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContentUploadStatus.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/entity/BinaryContentUploadStatus.java @@ -1,8 +1,6 @@ package com.codeit.team5.mopl.binarycontent.entity; public enum BinaryContentUploadStatus { - PENDING, COMPLETED, - FAILED, DELETED } diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java b/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java deleted file mode 100644 index cfdabcdb..00000000 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.codeit.team5.mopl.binarycontent.event; - -import java.util.UUID; - -public record BinaryContentUploadEvent( - UUID binaryContentId, - String key, - byte[] bytes, - String contentType -) { -} diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java b/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java deleted file mode 100644 index c848d5f6..00000000 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.codeit.team5.mopl.binarycontent.listener; - -import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; -import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; -import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; -import com.codeit.team5.mopl.binarycontent.service.BinaryContentService; -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; - -@Slf4j -@Component -@RequiredArgsConstructor -public class BinaryContentUploadListener { - - private final BinaryContentStorage binaryContentStorage; - private final BinaryContentService binaryContentService; - - @Async("binaryContentUploadExecutor") - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handle(BinaryContentUploadEvent event) { - try { - binaryContentStorage.store(event.key(), event.bytes(), event.contentType()); - binaryContentService.updateUploadStatus(event.binaryContentId(), BinaryContentUploadStatus.COMPLETED); - log.debug("파일 업로드 완료 - binaryContentId: {}", event.binaryContentId()); - } catch (Exception e) { - binaryContentService.updateUploadStatus(event.binaryContentId(), BinaryContentUploadStatus.FAILED); - log.error("파일 업로드 실패 - binaryContentId: {}", event.binaryContentId(), e); - } - } -} diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentService.java b/src/main/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentService.java index 1f638351..28e63151 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentService.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentService.java @@ -1,25 +1,30 @@ package com.codeit.team5.mopl.binarycontent.service; -import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; -import com.codeit.team5.mopl.binarycontent.exception.BinaryContentNotFoundException; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; +import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.storage.GeneratedKey; +import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; +import com.codeit.team5.mopl.binarycontent.storage.StorageKeyFactory; +import com.codeit.team5.mopl.global.dto.FileRequest; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class BinaryContentService { + private final BinaryContentStorage binaryContentStorage; + private final StorageKeyFactory storageKeyFactory; private final BinaryContentRepository binaryContentRepository; @Transactional - public void updateUploadStatus(UUID binaryContentId, BinaryContentUploadStatus status) { - binaryContentRepository.findById(binaryContentId) - .orElseThrow(() -> new BinaryContentNotFoundException(binaryContentId)) - .updateUploadStatus(status); + public BinaryContent upload(StorageDirectory directory, UUID ownerId, FileRequest image) { + GeneratedKey generated = storageKeyFactory.generate(directory, ownerId, image.filename()); + binaryContentStorage.store(generated.key(), image.bytes(), generated.contentType()); + return binaryContentRepository.save( + BinaryContent.completed(binaryContentStorage.toUrl(generated.key()))); } } diff --git a/src/main/java/com/codeit/team5/mopl/content/dto/response/ContentResponse.java b/src/main/java/com/codeit/team5/mopl/content/dto/response/ContentResponse.java index f69759bf..e560a406 100644 --- a/src/main/java/com/codeit/team5/mopl/content/dto/response/ContentResponse.java +++ b/src/main/java/com/codeit/team5/mopl/content/dto/response/ContentResponse.java @@ -1,6 +1,5 @@ package com.codeit.team5.mopl.content.dto.response; -import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.content.entity.ContentType; import java.util.List; import java.util.UUID; @@ -11,7 +10,6 @@ public record ContentResponse( String title, String description, String thumbnailUrl, - BinaryContentUploadStatus thumbnailUploadStatus, List tags, double averageRating, int reviewCount, diff --git a/src/main/java/com/codeit/team5/mopl/content/entity/Content.java b/src/main/java/com/codeit/team5/mopl/content/entity/Content.java index e5b085b1..7b288ffd 100644 --- a/src/main/java/com/codeit/team5/mopl/content/entity/Content.java +++ b/src/main/java/com/codeit/team5/mopl/content/entity/Content.java @@ -1,6 +1,7 @@ package com.codeit.team5.mopl.content.entity; import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.content.exception.InvalidContentSourceException; import com.codeit.team5.mopl.content.exception.InvalidContentTitleException; import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; @@ -106,6 +107,9 @@ public static Content createByExternalSource(ContentType type, String title, Str } public void attachThumbnail(BinaryContent thumbnail) { + if (this.thumbnail != null) { + this.thumbnail.updateUploadStatus(BinaryContentUploadStatus.DELETED); + } this.thumbnail = thumbnail; } diff --git a/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java b/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java index 23b88f45..0fd0fe4d 100644 --- a/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java +++ b/src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java @@ -18,7 +18,6 @@ public interface ContentMapper { @Mapping(target = "reviewCount", source = "stats.reviewCount") @Mapping(target = "watcherCount", source = "stats.watcherCount") @Mapping(target = "thumbnailUrl", source = "thumbnail.url") - @Mapping(target = "thumbnailUploadStatus", source = "thumbnail.uploadStatus") ContentResponse toDto(Content content); default CursorResponse toCursor(List page, boolean hasNext, diff --git a/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java index be519b37..5fa1f125 100644 --- a/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java +++ b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java @@ -2,12 +2,8 @@ import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; -import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; -import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; -import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; -import com.codeit.team5.mopl.binarycontent.storage.GeneratedKey; +import com.codeit.team5.mopl.binarycontent.service.BinaryContentService; import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; -import com.codeit.team5.mopl.binarycontent.storage.StorageKeyFactory; import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; import com.codeit.team5.mopl.content.dto.request.ContentCursorRequest; import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest; @@ -34,7 +30,6 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -54,10 +49,7 @@ public class ContentService { private final ContentStatsRepository contentStatsRepository; private final TagRepository tagRepository; private final ContentMapper contentMapper; - private final BinaryContentStorage binaryContentStorage; - private final StorageKeyFactory storageKeyFactory; - private final BinaryContentRepository binaryContentRepository; - private final ApplicationEventPublisher eventPublisher; + private final BinaryContentService binaryContentService; private static final String SECONDARY_SORT_FIELD = "id"; @@ -77,7 +69,7 @@ public ContentResponse create(ContentCreateRequest request, FileRequest image) { )); if (image != null) { - content.attachThumbnail(storeThumbnailImage(content.getId(), image)); + content.attachThumbnail(binaryContentService.upload(StorageDirectory.THUMBNAIL, content.getId(), image)); } attachTags(content, request.tags()); @@ -108,12 +100,7 @@ public ContentResponse update(UUID contentId, ContentUpdateRequest request, File updateTags(content, tagNames); if (image != null) { - // TODO(고아 정리): 비정상적인 상태를 가진 BinaryContent를 배치로 정리 (DB/S3 누적 방지) - BinaryContent oldThumbnail = content.getThumbnail(); - if (oldThumbnail != null) { - oldThumbnail.updateUploadStatus(BinaryContentUploadStatus.DELETED); - } - content.attachThumbnail(storeThumbnailImage(content.getId(), image)); + content.attachThumbnail(binaryContentService.upload(StorageDirectory.THUMBNAIL, content.getId(), image)); } return contentMapper.toDto(content); @@ -164,18 +151,6 @@ public void delete(UUID contentId) { contentRepository.delete(content); } - private BinaryContent storeThumbnailImage(UUID contentId, FileRequest image) { - GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.THUMBNAIL, contentId, image.filename()); - BinaryContent thumbnailImage = binaryContentRepository.save( - BinaryContent.pending(binaryContentStorage.toUrl(generated.key()))); - - eventPublisher.publishEvent( - new BinaryContentUploadEvent(thumbnailImage.getId(), generated.key(), image.bytes(), generated.contentType())); - // TODO(업로드 실패 대응): 비동기 업로드 실패 시 보상 트랜잭션으로 썸네일 이미지 롤백 - - return thumbnailImage; - } - private void attachTags(Content content, List rawTagNames) { List tagNames = normalizeTagNames(rawTagNames); insertTags(content, tagNames); diff --git a/src/main/java/com/codeit/team5/mopl/user/entity/User.java b/src/main/java/com/codeit/team5/mopl/user/entity/User.java index 6e7710f8..abc8128d 100644 --- a/src/main/java/com/codeit/team5/mopl/user/entity/User.java +++ b/src/main/java/com/codeit/team5/mopl/user/entity/User.java @@ -1,6 +1,7 @@ package com.codeit.team5.mopl.user.entity; import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; import com.codeit.team5.mopl.user.exception.InvalidPasswordException; import com.codeit.team5.mopl.user.exception.InvalidUsernameException; @@ -68,6 +69,9 @@ public void updateName(String newName) { } public void updateProfileImage(BinaryContent profileImage) { + if (this.profileImage != null) { + this.profileImage.updateUploadStatus(BinaryContentUploadStatus.DELETED); + } this.profileImage = profileImage; } diff --git a/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java b/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java index 9a88f9ce..7802aa02 100644 --- a/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java +++ b/src/main/java/com/codeit/team5/mopl/user/repository/UserRepository.java @@ -4,7 +4,6 @@ import jakarta.persistence.LockModeType; import java.util.Optional; import java.util.UUID; -import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; @@ -20,7 +19,7 @@ public interface UserRepository extends JpaRepository { @Query("select u from User u where u.id = :id") Optional findByIdForUpdate(@Param("id") UUID id); - // N+1 문제 방지 update용 조회 메서드 - @EntityGraph(attributePaths = {"profileImage"}) - Optional findWithProfileImageById(UUID id); + // 프로필 이미지를 fetch join으로 함께 조회 (update 시 N+1 방지) + @Query("select u from User u left join fetch u.profileImage where u.id = :id") + Optional findWithProfileImageById(@Param("id") UUID id); } diff --git a/src/main/java/com/codeit/team5/mopl/user/service/UserService.java b/src/main/java/com/codeit/team5/mopl/user/service/UserService.java index 974a70de..2a09c5d2 100644 --- a/src/main/java/com/codeit/team5/mopl/user/service/UserService.java +++ b/src/main/java/com/codeit/team5/mopl/user/service/UserService.java @@ -1,14 +1,8 @@ package com.codeit.team5.mopl.user.service; import com.codeit.team5.mopl.auth.service.RefreshTokenStore; -import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; -import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; -import com.codeit.team5.mopl.binarycontent.storage.GeneratedKey; +import com.codeit.team5.mopl.binarycontent.service.BinaryContentService; import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; -import com.codeit.team5.mopl.binarycontent.storage.StorageKeyFactory; -import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; -import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; -import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; import com.codeit.team5.mopl.global.dto.FileRequest; import com.codeit.team5.mopl.notification.event.RoleChangedEvent; import com.codeit.team5.mopl.user.dto.request.UserLockedUpdateRequest; @@ -41,9 +35,7 @@ public class UserService { private final UserRepository userRepository; private final UserMapper userMapper; private final PasswordEncoder passwordEncoder; - private final BinaryContentStorage binaryContentStorage; - private final StorageKeyFactory storageKeyFactory; - private final BinaryContentRepository binaryContentRepository; + private final BinaryContentService binaryContentService; private final ApplicationEventPublisher eventPublisher; private final RefreshTokenStore refreshTokenStore; @@ -79,20 +71,11 @@ public UserResponse getById(UUID userId) { public UserResponse update(UUID currentUserId, UUID userId, UserUpdateRequest request, FileRequest image) { validateOwner(currentUserId, userId); - User user = userRepository.findWithProfileImageById(userId) - .orElseThrow(() -> { - log.warn("User not found: userId={}", userId); - return new UserNotFoundException(userId); - }); + User user = getUserWithProfileImage(userId); user.updateName(request.name()); if (image != null) { - // TODO(고아 정리): 비정상적인 상태를 가진 BinaryContent를 배치로 정리 (DB/S3 누적 방지) - BinaryContent oldProfile = user.getProfileImage(); - if (oldProfile != null) { - oldProfile.updateUploadStatus(BinaryContentUploadStatus.DELETED); - } - user.updateProfileImage(storeProfileImage(user.getId(), image)); + user.updateProfileImage(binaryContentService.upload(StorageDirectory.PROFILE, user.getId(), image)); } log.info("User updated: userId={}", userId); @@ -133,24 +116,20 @@ public void updateLock(UUID userId, UserLockedUpdateRequest request) { log.info("User lock status updated: userId={}, isLocked={}", userId, request.locked()); } - private BinaryContent storeProfileImage(UUID userId, FileRequest image) { - GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.PROFILE, userId, image.filename()); - BinaryContent profileImage = binaryContentRepository.save( - BinaryContent.pending(binaryContentStorage.toUrl(generated.key()))); - - eventPublisher.publishEvent( - new BinaryContentUploadEvent(profileImage.getId(), generated.key(), image.bytes(), generated.contentType())); - // TODO(업로드 실패 대응): 비동기 업로드 실패 시 보상 트랜잭션으로 프로필 이미지 롤백 - - return profileImage; - } - private void validateOwner(UUID currentUserId, UUID userId) { if (!currentUserId.equals(userId)) { throw new UserForbiddenException(currentUserId, userId); } } + private User getUserWithProfileImage(UUID userId) { + return userRepository.findWithProfileImageById(userId) + .orElseThrow(() -> { + log.warn("User not found: userId={}", userId); + return new UserNotFoundException(userId); + }); + } + private User getUser(UUID userId) { return userRepository.findById(userId) .orElseThrow(() -> { diff --git a/src/main/resources/db/migration/V12__reduce_binary_content_upload_status.sql b/src/main/resources/db/migration/V12__reduce_binary_content_upload_status.sql new file mode 100644 index 00000000..721ebf3d --- /dev/null +++ b/src/main/resources/db/migration/V12__reduce_binary_content_upload_status.sql @@ -0,0 +1,10 @@ +UPDATE binary_contents +SET upload_status = 'COMPLETED' +WHERE upload_status IN ('PENDING', 'FAILED'); + +ALTER TABLE binary_contents + DROP CONSTRAINT ck_binary_contents_upload_status; + +ALTER TABLE binary_contents + ADD CONSTRAINT ck_binary_contents_upload_status + CHECK (upload_status IN ('COMPLETED', 'DELETED')); diff --git a/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java b/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java deleted file mode 100644 index ab06eac8..00000000 --- a/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.codeit.team5.mopl.binarycontent.listener; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.verify; - -import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; -import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; -import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; -import com.codeit.team5.mopl.binarycontent.service.BinaryContentService; -import java.util.UUID; -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; - -@ExtendWith(MockitoExtension.class) -class BinaryContentUploadListenerTest { - - @Mock - private BinaryContentStorage binaryContentStorage; - - @Mock - private BinaryContentService binaryContentService; - - @InjectMocks - private BinaryContentUploadListener listener; - - @Test - @DisplayName("파일 업로드 성공 시 COMPLETED 상태로 업데이트한다") - void handle_uploadSuccess_updatesCompleted() { - // given - UUID binaryContentId = UUID.randomUUID(); - BinaryContentUploadEvent event = new BinaryContentUploadEvent(binaryContentId, "thumbnails/test.jpg", new byte[]{1, 2, 3}, "image/jpeg"); - - // when - listener.handle(event); - - // then - verify(binaryContentStorage).store("thumbnails/test.jpg", event.bytes(), "image/jpeg"); - verify(binaryContentService).updateUploadStatus(binaryContentId, BinaryContentUploadStatus.COMPLETED); - } - - @Test - @DisplayName("파일 업로드 실패 시 FAILED 상태로 업데이트한다") - void handle_uploadFails_updatesFailed() { - // given - UUID binaryContentId = UUID.randomUUID(); - BinaryContentUploadEvent event = new BinaryContentUploadEvent(binaryContentId, "thumbnails/test.jpg", new byte[]{1, 2, 3}, "image/jpeg"); - doThrow(new RuntimeException("S3 연결 실패")).when(binaryContentStorage).store(any(), any(), any()); - - // when - listener.handle(event); - - // then - verify(binaryContentService).updateUploadStatus(binaryContentId, BinaryContentUploadStatus.FAILED); - } -} diff --git a/src/test/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentServiceTest.java b/src/test/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentServiceTest.java index 7b4da761..a6c71666 100644 --- a/src/test/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentServiceTest.java @@ -2,13 +2,22 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.AdditionalAnswers.returnsFirstArg; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; -import com.codeit.team5.mopl.binarycontent.exception.BinaryContentNotFoundException; import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; -import java.util.Optional; +import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.storage.GeneratedKey; +import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; +import com.codeit.team5.mopl.binarycontent.storage.StorageKeyFactory; +import com.codeit.team5.mopl.global.dto.FileRequest; import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -20,6 +29,12 @@ @ExtendWith(MockitoExtension.class) class BinaryContentServiceTest { + @Mock + private BinaryContentStorage binaryContentStorage; + + @Mock + private StorageKeyFactory storageKeyFactory; + @Mock private BinaryContentRepository binaryContentRepository; @@ -27,44 +42,40 @@ class BinaryContentServiceTest { private BinaryContentService binaryContentService; @Test - @DisplayName("COMPLETED 상태로 업데이트한다") - void updateUploadStatus_completed() { - // given - UUID id = UUID.randomUUID(); - BinaryContent binaryContent = BinaryContent.pending("http://localhost/test.jpg"); - when(binaryContentRepository.findById(id)).thenReturn(Optional.of(binaryContent)); - - // when - binaryContentService.updateUploadStatus(id, BinaryContentUploadStatus.COMPLETED); - - // then - assertThat(binaryContent.getUploadStatus()).isEqualTo(BinaryContentUploadStatus.COMPLETED); - } - - @Test - @DisplayName("FAILED 상태로 업데이트한다") - void updateUploadStatus_failed() { + @DisplayName("이미지 업로드 후 COMPLETED 상태로 저장에 성공한다") + void upload_success() { // given - UUID id = UUID.randomUUID(); - BinaryContent binaryContent = BinaryContent.pending("http://localhost/test.jpg"); - when(binaryContentRepository.findById(id)).thenReturn(Optional.of(binaryContent)); + UUID ownerId = UUID.randomUUID(); + FileRequest image = new FileRequest(new byte[]{1, 2, 3}, "profile.jpg"); + when(storageKeyFactory.generate(eq(StorageDirectory.PROFILE), eq(ownerId), eq("profile.jpg"))) + .thenReturn(new GeneratedKey("profiles/key.jpg", "image/jpeg")); + when(binaryContentStorage.toUrl("profiles/key.jpg")) + .thenReturn("http://localhost/profiles/key.jpg"); + when(binaryContentRepository.save(any(BinaryContent.class))).then(returnsFirstArg()); // when - binaryContentService.updateUploadStatus(id, BinaryContentUploadStatus.FAILED); + BinaryContent result = binaryContentService.upload(StorageDirectory.PROFILE, ownerId, image); // then - assertThat(binaryContent.getUploadStatus()).isEqualTo(BinaryContentUploadStatus.FAILED); + verify(binaryContentStorage).store("profiles/key.jpg", image.bytes(), "image/jpeg"); + assertThat(result.getUrl()).isEqualTo("http://localhost/profiles/key.jpg"); + assertThat(result.getUploadStatus()).isEqualTo(BinaryContentUploadStatus.COMPLETED); } @Test - @DisplayName("BinaryContent가 없으면 BinaryContentNotFoundException을 던진다") - void updateUploadStatus_notFound_throwsException() { + @DisplayName("스토리지 업로드 실패 시 예외를 전파하고 저장하지 않는다") + void upload_storeFails_throwsAndDoesNotSave() { // given - UUID id = UUID.randomUUID(); - when(binaryContentRepository.findById(id)).thenReturn(Optional.empty()); + UUID ownerId = UUID.randomUUID(); + FileRequest image = new FileRequest(new byte[]{1, 2, 3}, "profile.jpg"); + when(storageKeyFactory.generate(eq(StorageDirectory.PROFILE), eq(ownerId), eq("profile.jpg"))) + .thenReturn(new GeneratedKey("profiles/key.jpg", "image/jpeg")); + doThrow(new RuntimeException("S3 연결 실패")) + .when(binaryContentStorage).store(any(), any(), any()); // when & then - assertThatThrownBy(() -> binaryContentService.updateUploadStatus(id, BinaryContentUploadStatus.COMPLETED)) - .isInstanceOf(BinaryContentNotFoundException.class); + assertThatThrownBy(() -> binaryContentService.upload(StorageDirectory.PROFILE, ownerId, image)) + .isInstanceOf(RuntimeException.class); + verify(binaryContentRepository, never()).save(any()); } } diff --git a/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java b/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java index c7f7c91a..3d6f5dbe 100644 --- a/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java @@ -18,7 +18,6 @@ import com.codeit.team5.mopl.TestGlobalExceptionHandlerConfig; import com.codeit.team5.mopl.auth.filter.JwtAuthenticationFilter; -import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; import com.codeit.team5.mopl.content.dto.request.ContentCursorRequest; import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest; @@ -89,7 +88,6 @@ void postContent_success() throws Exception { "테스트 영화", "테스트 설명", "http://localhost:8080/thumbnails/11111111-1111-1111-1111-111111111111/test.jpg", - BinaryContentUploadStatus.PENDING, List.of("액션", "드라마"), 0.0, 0, 0 ); @@ -119,7 +117,6 @@ void postContent_success() throws Exception { .andExpect(jsonPath("$.title").value("테스트 영화")) .andExpect(jsonPath("$.description").value("테스트 설명")) .andExpect(jsonPath("$.thumbnailUrl").value(response.thumbnailUrl())) - .andExpect(jsonPath("$.thumbnailUploadStatus").value("PENDING")) .andExpect(jsonPath("$.tags[0]").value("액션")) .andExpect(jsonPath("$.tags[1]").value("드라마")) .andExpect(jsonPath("$.averageRating").value(0.0)) @@ -153,7 +150,6 @@ void postContent_withoutThumbnail_success() throws Exception { "테스트 드라마", "테스트 설명", null, - null, List.of("로맨스"), 0.0, 0, 0 ); @@ -305,7 +301,6 @@ void patchContent_success() throws Exception { "수정된 영화", "수정된 설명", "http://localhost:8080/thumbnails/11111111-1111-1111-1111-111111111111/new.jpg", - BinaryContentUploadStatus.PENDING, List.of("SF", "액션"), 0.0, 0, 0 ); @@ -332,7 +327,6 @@ void patchContent_success() throws Exception { .andExpect(jsonPath("$.title").value("수정된 영화")) .andExpect(jsonPath("$.description").value("수정된 설명")) .andExpect(jsonPath("$.thumbnailUrl").value(response.thumbnailUrl())) - .andExpect(jsonPath("$.thumbnailUploadStatus").value("PENDING")) .andExpect(jsonPath("$.averageRating").value(0.0)) .andExpect(jsonPath("$.reviewCount").value(0)) .andExpect(jsonPath("$.watcherCount").value(0)); @@ -363,7 +357,6 @@ void patchContent_withoutThumbnail_success() throws Exception { "수정된 드라마", null, null, - null, List.of("로맨스"), 0.0, 0, 0 ); @@ -536,7 +529,6 @@ void getContent_success() throws Exception { "테스트 영화", "테스트 설명", "http://localhost:8080/thumbnails/test.jpg", - BinaryContentUploadStatus.PENDING, List.of("액션", "SF"), 4.5, 10, 100L ); @@ -551,7 +543,6 @@ void getContent_success() throws Exception { .andExpect(jsonPath("$.title").value("테스트 영화")) .andExpect(jsonPath("$.description").value("테스트 설명")) .andExpect(jsonPath("$.thumbnailUrl").value(response.thumbnailUrl())) - .andExpect(jsonPath("$.thumbnailUploadStatus").value("PENDING")) .andExpect(jsonPath("$.tags[0]").value("액션")) .andExpect(jsonPath("$.tags[1]").value("SF")) .andExpect(jsonPath("$.averageRating").value(4.5)) @@ -601,7 +592,7 @@ void getContents_success() throws Exception { ContentType.MOVIE, "테스트 영화", "테스트 설명", - null, null, + null, List.of("액션"), 0.0, 0, 0L ); diff --git a/src/test/java/com/codeit/team5/mopl/content/repository/ContentRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/content/repository/ContentRepositoryTest.java index 37f500c3..023fac4a 100644 --- a/src/test/java/com/codeit/team5/mopl/content/repository/ContentRepositoryTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/repository/ContentRepositoryTest.java @@ -64,7 +64,7 @@ void setUp() { ContentStats stats = persistAndFlush(ContentStats.create(content)); content.attachStats(stats); - BinaryContent thumbnail = persistAndFlush(BinaryContent.pending("https://example.com/thumb.jpg")); + BinaryContent thumbnail = persistAndFlush(BinaryContent.completed("https://example.com/thumb.jpg")); content.attachThumbnail(thumbnail); flush(); clear(); diff --git a/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java b/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java index 0b1c0975..c25d1762 100644 --- a/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java @@ -11,13 +11,9 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; -import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; -import com.codeit.team5.mopl.binarycontent.storage.GeneratedKey; import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; -import com.codeit.team5.mopl.binarycontent.storage.StorageKeyFactory; import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; -import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; -import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; +import com.codeit.team5.mopl.binarycontent.service.BinaryContentService; import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; import com.codeit.team5.mopl.content.dto.request.ContentCursorRequest; @@ -70,13 +66,7 @@ class ContentServiceTest { private ContentMapper contentMapper; @Mock - private BinaryContentStorage binaryContentStorage; - - @Mock - private StorageKeyFactory storageKeyFactory; - - @Mock - private BinaryContentRepository binaryContentRepository; + private BinaryContentService binaryContentService; @Mock private ApplicationEventPublisher eventPublisher; @@ -97,17 +87,15 @@ void create_success() { Tag dramaTag = Tag.create("드라마"); ContentResponse expectedResponse = new ContentResponse( UUID.randomUUID(), ContentType.MOVIE, "테스트 영화", "테스트 설명", - null, null, List.of("액션", "드라마"), 0.0, 0, 0 + null, List.of("액션", "드라마"), 0.0, 0, 0 ); when(contentRepository.save(any(Content.class))).then(returnsFirstArg()); when(tagRepository.findByNameIn(List.of("액션", "드라마"))).thenReturn(List.of(actionTag)); when(tagRepository.saveAll(anyList())).thenReturn(List.of(dramaTag)); when(contentStatsRepository.save(any(ContentStats.class))).then(returnsFirstArg()); - when(storageKeyFactory.generate(eq(StorageDirectory.THUMBNAIL), any(), eq("test.jpg"))) - .thenReturn(new GeneratedKey("thumbnails/test.jpg", "image/jpeg")); - when(binaryContentStorage.toUrl("thumbnails/test.jpg")).thenReturn("http://localhost:8080/thumbnails/test.jpg"); - when(binaryContentRepository.save(any(BinaryContent.class))).then(returnsFirstArg()); + when(binaryContentService.upload(eq(StorageDirectory.THUMBNAIL), any(), any())) + .thenReturn(BinaryContent.completed("http://localhost:8080/thumbnails/test.jpg")); when(contentMapper.toDto(any(Content.class))).thenReturn(expectedResponse); // when @@ -123,9 +111,7 @@ void create_success() { assertThat(savedContent.getTitle()).isEqualTo("테스트 영화"); assertThat(savedContent.getThumbnail()).isNotNull(); - ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(BinaryContentUploadEvent.class); - verify(eventPublisher).publishEvent(eventCaptor.capture()); - assertThat(eventCaptor.getValue().key()).isEqualTo("thumbnails/test.jpg"); + verify(binaryContentService).upload(eq(StorageDirectory.THUMBNAIL), any(), any()); verify(tagRepository).findByNameIn(List.of("액션", "드라마")); verify(contentStatsRepository).save(any(ContentStats.class)); @@ -141,7 +127,7 @@ void create_withoutThumbnail_success() { Tag romanceTag = Tag.create("로맨스"); ContentResponse expectedResponse = new ContentResponse( UUID.randomUUID(), ContentType.TV_SERIES, "테스트 드라마", - null, null, null, List.of("로맨스"), 0.0, 0, 0 + null, null, List.of("로맨스"), 0.0, 0, 0 ); when(contentRepository.save(any(Content.class))).then(returnsFirstArg()); @@ -154,7 +140,7 @@ void create_withoutThumbnail_success() { // then assertThat(result).isSameAs(expectedResponse); - verifyNoInteractions(storageKeyFactory, binaryContentStorage, binaryContentRepository, eventPublisher); + verifyNoInteractions(binaryContentService, eventPublisher); } @Test @@ -267,7 +253,7 @@ void update_withoutThumbnail_success() { ContentUpdateRequest request = new ContentUpdateRequest("수정 제목", "수정 설명", List.of("SF")); ContentResponse expectedResponse = new ContentResponse( contentId, ContentType.MOVIE, "수정 제목", "수정 설명", - null, null, List.of("sf"), 0.0, 0, 0 + null, List.of("sf"), 0.0, 0, 0 ); when(contentRepository.findWithStatsAndTagsById(contentId)).thenReturn(Optional.of(content)); @@ -281,7 +267,7 @@ void update_withoutThumbnail_success() { assertThat(result).isSameAs(expectedResponse); assertThat(content.getTitle()).isEqualTo("수정 제목"); assertThat(content.getDescription()).isEqualTo("수정 설명"); - verifyNoInteractions(storageKeyFactory, binaryContentStorage, binaryContentRepository, eventPublisher); + verifyNoInteractions(binaryContentService, eventPublisher); } @Test @@ -293,17 +279,14 @@ void update_withNewThumbnail_noOldThumbnail_success() { FileRequest image = new FileRequest(new byte[]{1, 2, 3}, "new.jpg"); ContentResponse expectedResponse = new ContentResponse( contentId, ContentType.MOVIE, "수정 제목", null, - "http://localhost/thumbnails/new.jpg", BinaryContentUploadStatus.PENDING, + "http://localhost/thumbnails/new.jpg", List.of("액션"), 0.0, 0, 0 ); when(contentRepository.findWithStatsAndTagsById(contentId)).thenReturn(Optional.of(content)); when(tagRepository.findByNameIn(List.of("액션"))).thenReturn(List.of(Tag.create("액션"))); - when(storageKeyFactory.generate(eq(StorageDirectory.THUMBNAIL), any(), eq("new.jpg"))) - .thenReturn(new GeneratedKey("thumbnails/new.jpg", "image/jpeg")); - when(binaryContentStorage.toUrl("thumbnails/new.jpg")) - .thenReturn("http://localhost/thumbnails/new.jpg"); - when(binaryContentRepository.save(any(BinaryContent.class))).then(returnsFirstArg()); + when(binaryContentService.upload(eq(StorageDirectory.THUMBNAIL), any(), any())) + .thenReturn(BinaryContent.completed("http://localhost/thumbnails/new.jpg")); when(contentMapper.toDto(content)).thenReturn(expectedResponse); // when @@ -312,10 +295,7 @@ void update_withNewThumbnail_noOldThumbnail_success() { // then assertThat(result).isSameAs(expectedResponse); assertThat(content.getThumbnail()).isNotNull(); - verify(binaryContentRepository).save(any(BinaryContent.class)); - ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(BinaryContentUploadEvent.class); - verify(eventPublisher).publishEvent(eventCaptor.capture()); - assertThat(eventCaptor.getValue().key()).isEqualTo("thumbnails/new.jpg"); + verify(binaryContentService).upload(eq(StorageDirectory.THUMBNAIL), any(), any()); } @Test @@ -324,17 +304,14 @@ void update_withNewThumbnail_oldThumbnailMarkedDeleted() { // given UUID contentId = UUID.randomUUID(); Content content = Content.createByAdmin(ContentType.MOVIE, "기존 제목", null); - BinaryContent oldThumbnail = BinaryContent.pending("http://localhost/thumbnails/old.jpg"); + BinaryContent oldThumbnail = BinaryContent.completed("http://localhost/thumbnails/old.jpg"); content.attachThumbnail(oldThumbnail); FileRequest image = new FileRequest(new byte[]{4, 5, 6}, "new.jpg"); when(contentRepository.findWithStatsAndTagsById(contentId)).thenReturn(Optional.of(content)); when(tagRepository.findByNameIn(List.of("액션"))).thenReturn(List.of(Tag.create("액션"))); - when(storageKeyFactory.generate(eq(StorageDirectory.THUMBNAIL), any(), eq("new.jpg"))) - .thenReturn(new GeneratedKey("thumbnails/new.jpg", "image/jpeg")); - when(binaryContentStorage.toUrl("thumbnails/new.jpg")) - .thenReturn("http://localhost/thumbnails/new.jpg"); - when(binaryContentRepository.save(any(BinaryContent.class))).then(returnsFirstArg()); + when(binaryContentService.upload(eq(StorageDirectory.THUMBNAIL), any(), any())) + .thenReturn(BinaryContent.completed("http://localhost/thumbnails/new.jpg")); when(contentMapper.toDto(content)).thenReturn(null); // when @@ -391,7 +368,7 @@ void update_notFound_throwsException() { contentId, new ContentUpdateRequest("제목", null, List.of("액션")), null)) .isInstanceOf(ContentNotFoundException.class); - verifyNoInteractions(tagRepository, binaryContentRepository, eventPublisher, contentMapper); + verifyNoInteractions(tagRepository, binaryContentService, eventPublisher, contentMapper); } @Test @@ -409,7 +386,7 @@ contentId, new ContentUpdateRequest("수정 제목", null, List.of(" ", " ")), assertThat(content.getTitle()).isEqualTo("기존 제목"); verify(tagRepository, never()).findByNameIn(anyList()); - verifyNoInteractions(binaryContentRepository, eventPublisher, contentMapper); + verifyNoInteractions(binaryContentService, eventPublisher, contentMapper); } // --- FIND BY ID --- @@ -421,7 +398,7 @@ void findById_success() { Content content = Content.createByAdmin(ContentType.MOVIE, "테스트 영화", "설명"); ContentResponse expectedResponse = new ContentResponse( contentId, ContentType.MOVIE, "테스트 영화", "설명", - null, null, List.of("액션"), 4.5, 10, 100L + null, List.of("액션"), 4.5, 10, 100L ); when(contentRepository.findWithStatsAndTagsById(contentId)).thenReturn(Optional.of(content)); @@ -555,7 +532,7 @@ void delete_withoutThumbnail_success() { // then verify(contentRepository).delete(content); - verifyNoInteractions(binaryContentRepository, eventPublisher); + verifyNoInteractions(binaryContentService, eventPublisher); } @Test @@ -564,7 +541,7 @@ void delete_withThumbnail_marksDeletedAndDeletes() { // given UUID contentId = UUID.randomUUID(); Content content = Content.createByAdmin(ContentType.MOVIE, "테스트 영화", null); - BinaryContent thumbnail = BinaryContent.pending("http://localhost/thumbnails/thumb.jpg"); + BinaryContent thumbnail = BinaryContent.completed("http://localhost/thumbnails/thumb.jpg"); content.attachThumbnail(thumbnail); when(contentRepository.findWithStatsAndTagsById(contentId)).thenReturn(Optional.of(content)); diff --git a/src/test/java/com/codeit/team5/mopl/user/service/UserServiceTest.java b/src/test/java/com/codeit/team5/mopl/user/service/UserServiceTest.java index 69dc3f25..e410c332 100644 --- a/src/test/java/com/codeit/team5/mopl/user/service/UserServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/user/service/UserServiceTest.java @@ -11,13 +11,9 @@ import static org.mockito.Mockito.when; import com.codeit.team5.mopl.auth.service.RefreshTokenStore; -import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; -import com.codeit.team5.mopl.binarycontent.storage.GeneratedKey; import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; -import com.codeit.team5.mopl.binarycontent.storage.StorageKeyFactory; import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; -import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; -import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; +import com.codeit.team5.mopl.binarycontent.service.BinaryContentService; import com.codeit.team5.mopl.global.dto.FileRequest; import com.codeit.team5.mopl.notification.event.RoleChangedEvent; import com.codeit.team5.mopl.user.dto.request.UserLockedUpdateRequest; @@ -62,13 +58,7 @@ class UserServiceTest { private PasswordEncoder passwordEncoder; @Mock - private BinaryContentStorage binaryContentStorage; - - @Mock - private StorageKeyFactory storageKeyFactory; - - @Mock - private BinaryContentRepository binaryContentRepository; + private BinaryContentService binaryContentService; @Mock private ApplicationEventPublisher eventPublisher; @@ -204,7 +194,7 @@ void update_nameOnly_success() { assertThat(result).isSameAs(expected); assertThat(user.getName()).isEqualTo("새이름"); assertThat(user.getProfileImage()).isNull(); - verifyNoInteractions(storageKeyFactory, binaryContentStorage, binaryContentRepository, eventPublisher); + verifyNoInteractions(binaryContentService, eventPublisher); } @Test @@ -219,11 +209,8 @@ void update_withImage_success() { "user@example.com", "새이름", "http://localhost/profiles/key.jpg", "USER", false ); when(userRepository.findWithProfileImageById(userId)).thenReturn(Optional.of(user)); - when(storageKeyFactory.generate(eq(StorageDirectory.PROFILE), eq(user.getId()), eq("profile.jpg"))) - .thenReturn(new GeneratedKey("profiles/key.jpg", "image/jpeg")); - when(binaryContentStorage.toUrl("profiles/key.jpg")) - .thenReturn("http://localhost/profiles/key.jpg"); - when(binaryContentRepository.save(any(BinaryContent.class))).then(returnsFirstArg()); + when(binaryContentService.upload(eq(StorageDirectory.PROFILE), eq(user.getId()), any())) + .thenReturn(BinaryContent.completed("http://localhost/profiles/key.jpg")); when(userMapper.toDto(user)).thenReturn(expected); // When @@ -233,8 +220,26 @@ void update_withImage_success() { assertThat(result).isSameAs(expected); assertThat(user.getName()).isEqualTo("새이름"); assertThat(user.getProfileImage()).isNotNull(); - verify(binaryContentRepository).save(any(BinaryContent.class)); - verify(eventPublisher).publishEvent(any(BinaryContentUploadEvent.class)); + verify(binaryContentService).upload(eq(StorageDirectory.PROFILE), eq(user.getId()), any()); + } + + @Test + @DisplayName("프로필 이미지 업로드 실패 시 변경 실패") + void update_imageUploadFails_throwsException() { + // Given + UUID userId = UUID.randomUUID(); + User user = User.create("user@example.com", "encoded-password", "기존이름"); + FileRequest image = new FileRequest(new byte[]{1, 2, 3}, "profile.jpg"); + when(userRepository.findWithProfileImageById(userId)).thenReturn(Optional.of(user)); + when(binaryContentService.upload(eq(StorageDirectory.PROFILE), eq(user.getId()), any())) + .thenThrow(new RuntimeException("S3 연결 실패")); + + // When & Then + assertThatThrownBy(() -> userService.update(userId, userId, new UserUpdateRequest("새이름"), image)) + .isInstanceOf(RuntimeException.class); + + assertThat(user.getProfileImage()).isNull(); + verifyNoInteractions(userMapper); } @Test @@ -249,7 +254,7 @@ void update_notFound_throwsException() { .isInstanceOf(UserNotFoundException.class); verify(userRepository).findWithProfileImageById(userId); - verifyNoInteractions(userMapper, binaryContentRepository, eventPublisher); + verifyNoInteractions(userMapper, binaryContentService, eventPublisher); } @Test @@ -263,7 +268,7 @@ void update_notOwner_throwsException() { assertThatThrownBy(() -> userService.update(currentUserId, userId, new UserUpdateRequest("새이름"), null)) .isInstanceOf(UserForbiddenException.class); - verifyNoInteractions(userRepository, userMapper, binaryContentRepository, eventPublisher); + verifyNoInteractions(userRepository, userMapper, binaryContentService, eventPublisher); } @Test From 9d964467de7980a77147891a1465bcfc798f8ce7 Mon Sep 17 00:00:00 2001 From: dustle Date: Tue, 30 Jun 2026 16:36:27 +0900 Subject: [PATCH 2/4] =?UTF-8?q?test:=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EC=A1=B0=EC=A0=95=20=EB=B0=8F=20=EB=A1=A4?= =?UTF-8?q?=EB=B0=B1=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # src/main/java/com/codeit/team5/mopl/content/service/ContentService.java --- .../mopl/content/service/ContentService.java | 6 +- .../service/BinaryContentServiceTest.java | 1 + ...erServiceImageRollbackIntegrationTest.java | 63 +++++++++++++++++++ .../mopl/user/service/UserServiceTest.java | 27 ++++++++ 4 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/codeit/team5/mopl/user/service/UserServiceImageRollbackIntegrationTest.java diff --git a/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java index 5fa1f125..934f364d 100644 --- a/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java +++ b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java @@ -68,13 +68,13 @@ public ContentResponse create(ContentCreateRequest request, FileRequest image) { request.description() )); + attachTags(content, request.tags()); + if (image != null) { content.attachThumbnail(binaryContentService.upload(StorageDirectory.THUMBNAIL, content.getId(), image)); } - attachTags(content, request.tags()); - - ContentStats stats = contentStatsRepository.save(ContentStats.create(content)); + ContentStats stats = contentStatsRepository.save(ContentStats.create()); content.attachStats(stats); return contentMapper.toDto(content); diff --git a/src/test/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentServiceTest.java b/src/test/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentServiceTest.java index a6c71666..99b68bca 100644 --- a/src/test/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/binarycontent/service/BinaryContentServiceTest.java @@ -58,6 +58,7 @@ void upload_success() { // then verify(binaryContentStorage).store("profiles/key.jpg", image.bytes(), "image/jpeg"); + verify(binaryContentRepository).save(any(BinaryContent.class)); assertThat(result.getUrl()).isEqualTo("http://localhost/profiles/key.jpg"); assertThat(result.getUploadStatus()).isEqualTo(BinaryContentUploadStatus.COMPLETED); } diff --git a/src/test/java/com/codeit/team5/mopl/user/service/UserServiceImageRollbackIntegrationTest.java b/src/test/java/com/codeit/team5/mopl/user/service/UserServiceImageRollbackIntegrationTest.java new file mode 100644 index 00000000..e3cf7cbd --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/user/service/UserServiceImageRollbackIntegrationTest.java @@ -0,0 +1,63 @@ +package com.codeit.team5.mopl.user.service; + +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.Mockito.doThrow; + +import com.codeit.team5.mopl.TestcontainersConfiguration; +import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; +import com.codeit.team5.mopl.global.dto.FileRequest; +import com.codeit.team5.mopl.user.dto.request.UserUpdateRequest; +import com.codeit.team5.mopl.user.entity.User; +import com.codeit.team5.mopl.user.repository.UserRepository; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +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.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@SpringBootTest +@ActiveProfiles("test") +@Import(TestcontainersConfiguration.class) +class UserServiceImageRollbackIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @MockitoBean + private BinaryContentStorage binaryContentStorage; + + @AfterEach + void cleanup() { + userRepository.deleteAll(); + } + + @Test + @DisplayName("프로필 이미지 업로드 실패 시 트랜잭션 롤백으로 기존 정보 유지 성공") + void update_imageUploadFails_rollsBack() { + // given + User saved = userRepository.save(User.create("rollback@example.com", "encoded-password", "기존이름")); + UUID userId = saved.getId(); + doThrow(new RuntimeException("S3 업로드 실패")) + .when(binaryContentStorage).store(any(), any(), any()); + FileRequest image = new FileRequest(new byte[]{1, 2, 3}, "profile.jpg"); + + // when & then + assertThatThrownBy(() -> + userService.update(userId, userId, new UserUpdateRequest("새이름"), image)) + .isInstanceOf(RuntimeException.class); + + // 트랜잭션 롤백으로 이름 변경과 프로필 이미지가 모두 반영되지 않아야 한다 + User reloaded = userRepository.findWithProfileImageById(userId).orElseThrow(); + assertThat(reloaded.getName()).isEqualTo("기존이름"); + assertThat(reloaded.getProfileImage()).isNull(); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/user/service/UserServiceTest.java b/src/test/java/com/codeit/team5/mopl/user/service/UserServiceTest.java index e410c332..46d86a65 100644 --- a/src/test/java/com/codeit/team5/mopl/user/service/UserServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/user/service/UserServiceTest.java @@ -13,6 +13,7 @@ import com.codeit.team5.mopl.auth.service.RefreshTokenStore; import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.binarycontent.service.BinaryContentService; import com.codeit.team5.mopl.global.dto.FileRequest; import com.codeit.team5.mopl.notification.event.RoleChangedEvent; @@ -242,6 +243,32 @@ void update_imageUploadFails_throwsException() { verifyNoInteractions(userMapper); } + @Test + @DisplayName("기존 프로필 이미지 교체 시 기존 이미지 DELETED 처리 성공") + void update_replaceImage_oldImageDeleted() { + // Given + UUID userId = UUID.randomUUID(); + User user = User.create("user@example.com", "encoded-password", "기존이름"); + BinaryContent oldImage = BinaryContent.completed("http://localhost/profiles/old.jpg"); + user.updateProfileImage(oldImage); + FileRequest image = new FileRequest(new byte[]{1, 2, 3}, "profile.jpg"); + UserResponse expected = new UserResponse( + userId, Instant.parse("2026-06-25T00:00:00Z"), + "user@example.com", "새이름", "http://localhost/profiles/new.jpg", "USER", false + ); + when(userRepository.findWithProfileImageById(userId)).thenReturn(Optional.of(user)); + when(binaryContentService.upload(eq(StorageDirectory.PROFILE), eq(user.getId()), any())) + .thenReturn(BinaryContent.completed("http://localhost/profiles/new.jpg")); + when(userMapper.toDto(user)).thenReturn(expected); + + // When + userService.update(userId, userId, new UserUpdateRequest("새이름"), image); + + // Then + assertThat(oldImage.getUploadStatus()).isEqualTo(BinaryContentUploadStatus.DELETED); + assertThat(user.getProfileImage().getUrl()).isEqualTo("http://localhost/profiles/new.jpg"); + } + @Test @DisplayName("존재하지 않는 사용자 프로필 변경 실패") void update_notFound_throwsException() { From dd1140926d5d5fef9d16f948c4c9aa70c7ac1ad8 Mon Sep 17 00:00:00 2001 From: dustle Date: Tue, 30 Jun 2026 16:48:33 +0900 Subject: [PATCH 3/4] =?UTF-8?q?test:=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EB=B3=B4=EA=B0=95=20=EB=B0=8F=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4=20=EC=8B=A4=ED=8C=A8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mopl/content/service/ContentService.java | 6 ++-- ...erServiceImageRollbackIntegrationTest.java | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java index 934f364d..cc8a46b7 100644 --- a/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java +++ b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java @@ -70,13 +70,13 @@ public ContentResponse create(ContentCreateRequest request, FileRequest image) { attachTags(content, request.tags()); + ContentStats stats = contentStatsRepository.save(ContentStats.create()); + content.attachStats(stats); + if (image != null) { content.attachThumbnail(binaryContentService.upload(StorageDirectory.THUMBNAIL, content.getId(), image)); } - ContentStats stats = contentStatsRepository.save(ContentStats.create()); - content.attachStats(stats); - return contentMapper.toDto(content); } diff --git a/src/test/java/com/codeit/team5/mopl/user/service/UserServiceImageRollbackIntegrationTest.java b/src/test/java/com/codeit/team5/mopl/user/service/UserServiceImageRollbackIntegrationTest.java index e3cf7cbd..df4f2a59 100644 --- a/src/test/java/com/codeit/team5/mopl/user/service/UserServiceImageRollbackIntegrationTest.java +++ b/src/test/java/com/codeit/team5/mopl/user/service/UserServiceImageRollbackIntegrationTest.java @@ -6,6 +6,8 @@ import static org.mockito.Mockito.doThrow; import com.codeit.team5.mopl.TestcontainersConfiguration; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; +import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; import com.codeit.team5.mopl.global.dto.FileRequest; import com.codeit.team5.mopl.user.dto.request.UserUpdateRequest; @@ -32,12 +34,16 @@ class UserServiceImageRollbackIntegrationTest { @Autowired private UserRepository userRepository; + @Autowired + private BinaryContentRepository binaryContentRepository; + @MockitoBean private BinaryContentStorage binaryContentStorage; @AfterEach void cleanup() { userRepository.deleteAll(); + binaryContentRepository.deleteAll(); } @Test @@ -60,4 +66,29 @@ void update_imageUploadFails_rollsBack() { assertThat(reloaded.getName()).isEqualTo("기존이름"); assertThat(reloaded.getProfileImage()).isNull(); } + + @Test + @DisplayName("기존 프로필 이미지 교체 업로드 실패 시 기존 이미지 유지 성공") + void update_replaceImageFails_keepsOldImage() { + // given + BinaryContent oldImage = binaryContentRepository.save( + BinaryContent.completed("http://localhost/profiles/old.jpg")); + User user = User.create("replace-fail@example.com", "encoded-password", "기존이름"); + user.updateProfileImage(oldImage); + UUID userId = userRepository.save(user).getId(); + doThrow(new RuntimeException("S3 업로드 실패")) + .when(binaryContentStorage).store(any(), any(), any()); + FileRequest image = new FileRequest(new byte[]{1, 2, 3}, "profile.jpg"); + + // when & then + assertThatThrownBy(() -> + userService.update(userId, userId, new UserUpdateRequest("새이름"), image)) + .isInstanceOf(RuntimeException.class); + + // 롤백으로 기존 이미지가 그대로 유지되어야 한다 + User reloaded = userRepository.findWithProfileImageById(userId).orElseThrow(); + assertThat(reloaded.getName()).isEqualTo("기존이름"); + assertThat(reloaded.getProfileImage()).isNotNull(); + assertThat(reloaded.getProfileImage().getUrl()).isEqualTo("http://localhost/profiles/old.jpg"); + } } From 8c52bcc26f73c748f9087c7bd56d45bcb36f684d Mon Sep 17 00:00:00 2001 From: dustle Date: Tue, 30 Jun 2026 17:11:50 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20dev=20=EB=A6=AC=EB=B2=A0=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0=20(ContentS?= =?UTF-8?q?tats=20=EC=8B=9C=EA=B7=B8=EB=8B=88=EC=B2=98,=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/codeit/team5/mopl/content/service/ContentService.java | 2 +- ..._status.sql => V13__reduce_binary_content_upload_status.sql} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/main/resources/db/migration/{V12__reduce_binary_content_upload_status.sql => V13__reduce_binary_content_upload_status.sql} (100%) diff --git a/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java index cc8a46b7..b386d842 100644 --- a/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java +++ b/src/main/java/com/codeit/team5/mopl/content/service/ContentService.java @@ -70,7 +70,7 @@ public ContentResponse create(ContentCreateRequest request, FileRequest image) { attachTags(content, request.tags()); - ContentStats stats = contentStatsRepository.save(ContentStats.create()); + ContentStats stats = contentStatsRepository.save(ContentStats.create(content)); content.attachStats(stats); if (image != null) { diff --git a/src/main/resources/db/migration/V12__reduce_binary_content_upload_status.sql b/src/main/resources/db/migration/V13__reduce_binary_content_upload_status.sql similarity index 100% rename from src/main/resources/db/migration/V12__reduce_binary_content_upload_status.sql rename to src/main/resources/db/migration/V13__reduce_binary_content_upload_status.sql