From be2be42c097b56df05cec191e296715a682a99e5 Mon Sep 17 00:00:00 2001 From: dustle Date: Fri, 3 Jul 2026 12:07:51 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20=EB=B0=94=EC=9D=B4=EB=84=88?= =?UTF-8?q?=EB=A6=AC=20=EC=97=85=EB=A1=9C=EB=93=9C=EB=A5=BC=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=B0=96=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=ED=95=98=EA=B3=A0=20=EB=A1=A4=EB=B0=B1=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BinaryContentService를 uploadToStorage(S3)/saveCompleted(DB)/deleteQuietly로 분리 - S3 업로드는 트랜잭션 밖, DB 저장만 짧은 트랜잭션 (S3Client 타임아웃 설정) - 업로드 후 실패 시 롤백을 UploadWithRollback으로 공통화, 삭제는 비동기 이벤트로 처리 - UserProfileFacade/ContentFacade가 업로드 조율 (User는 소유자 검증 선행) - S3 저장 경로에서 소유자 세그먼트 제거 --- .../dto/UploadedBinaryContent.java | 7 + .../event/BinaryContentDeleteEvent.java | 9 ++ .../BinaryContentDeleteEventListener.java | 21 +++ .../service/BinaryContentService.java | 31 +++- .../service/UploadWithRollback.java | 41 +++++ .../storage/BinaryContentStorage.java | 2 + .../storage/StorageKeyFactory.java | 6 +- .../local/LocalBinaryContentStorage.java | 11 ++ .../storage/s3/S3BinaryContentStorage.java | 15 ++ .../storage/s3/S3StorageProperties.java | 13 +- .../team5/mopl/config/StorageConfig.java | 5 + .../content/controller/ContentController.java | 6 +- .../mopl/content/facade/ContentFacade.java | 30 ++++ .../mopl/content/service/ContentService.java | 15 +- .../mopl/user/controller/UserController.java | 30 +++- .../mopl/user/facade/UserProfileFacade.java | 34 +++++ .../team5/mopl/user/service/UserService.java | 23 ++- .../BinaryContentDeleteEventListenerTest.java | 37 +++++ .../service/BinaryContentServiceTest.java | 87 ++++++++++- .../storage/StorageKeyFactoryTest.java | 32 +--- .../local/LocalBinaryContentStorageTest.java | 28 ++++ .../s3/S3BinaryContentStorageTest.java | 29 +++- .../controller/ContentControllerTest.java | 42 ++--- .../content/facade/ContentFacadeTest.java | 143 ++++++++++++++++++ .../content/service/ContentServiceTest.java | 26 ++-- .../user/controller/UserControllerTest.java | 10 +- .../UserProfileFacadeIntegrationTest.java | 90 +++++++++++ .../user/facade/UserProfileFacadeTest.java | 137 +++++++++++++++++ ...erServiceImageRollbackIntegrationTest.java | 94 ------------ .../mopl/user/service/UserServiceTest.java | 64 ++------ 30 files changed, 870 insertions(+), 248 deletions(-) create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/dto/UploadedBinaryContent.java create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentDeleteEvent.java create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/eventlistener/BinaryContentDeleteEventListener.java create mode 100644 src/main/java/com/codeit/team5/mopl/binarycontent/service/UploadWithRollback.java create mode 100644 src/main/java/com/codeit/team5/mopl/content/facade/ContentFacade.java create mode 100644 src/main/java/com/codeit/team5/mopl/user/facade/UserProfileFacade.java create mode 100644 src/test/java/com/codeit/team5/mopl/binarycontent/eventlistener/BinaryContentDeleteEventListenerTest.java create mode 100644 src/test/java/com/codeit/team5/mopl/content/facade/ContentFacadeTest.java create mode 100644 src/test/java/com/codeit/team5/mopl/user/facade/UserProfileFacadeIntegrationTest.java create mode 100644 src/test/java/com/codeit/team5/mopl/user/facade/UserProfileFacadeTest.java delete mode 100644 src/test/java/com/codeit/team5/mopl/user/service/UserServiceImageRollbackIntegrationTest.java diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/dto/UploadedBinaryContent.java b/src/main/java/com/codeit/team5/mopl/binarycontent/dto/UploadedBinaryContent.java new file mode 100644 index 00000000..dcaf34ca --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/dto/UploadedBinaryContent.java @@ -0,0 +1,7 @@ +package com.codeit.team5.mopl.binarycontent.dto; + +public record UploadedBinaryContent( + String key, + String url +) { +} diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentDeleteEvent.java b/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentDeleteEvent.java new file mode 100644 index 00000000..8502dd3a --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentDeleteEvent.java @@ -0,0 +1,9 @@ +package com.codeit.team5.mopl.binarycontent.event; + +import com.codeit.team5.mopl.binarycontent.dto.UploadedBinaryContent; + +// 업로드 후 후속 작업 실패 시, 업로드된 스토리지 객체를 비동기로 삭제(롤백)하기 위한 이벤트 +public record BinaryContentDeleteEvent( + UploadedBinaryContent uploaded +) { +} diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/eventlistener/BinaryContentDeleteEventListener.java b/src/main/java/com/codeit/team5/mopl/binarycontent/eventlistener/BinaryContentDeleteEventListener.java new file mode 100644 index 00000000..0b38c6eb --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/eventlistener/BinaryContentDeleteEventListener.java @@ -0,0 +1,21 @@ +package com.codeit.team5.mopl.binarycontent.eventlistener; + +import com.codeit.team5.mopl.binarycontent.event.BinaryContentDeleteEvent; +import com.codeit.team5.mopl.binarycontent.service.BinaryContentService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class BinaryContentDeleteEventListener { + + private final BinaryContentService binaryContentService; + + @Async + @EventListener + public void handle(BinaryContentDeleteEvent event) { + binaryContentService.deleteQuietly(event.uploaded()); + } +} 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 28e63151..c5f72a1e 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,5 +1,6 @@ package com.codeit.team5.mopl.binarycontent.service; +import com.codeit.team5.mopl.binarycontent.dto.UploadedBinaryContent; import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; @@ -7,24 +8,46 @@ 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 lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor +@Slf4j public class BinaryContentService { private final BinaryContentStorage binaryContentStorage; private final StorageKeyFactory storageKeyFactory; private final BinaryContentRepository binaryContentRepository; - @Transactional - public BinaryContent upload(StorageDirectory directory, UUID ownerId, FileRequest image) { - GeneratedKey generated = storageKeyFactory.generate(directory, ownerId, image.filename()); + public BinaryContent upload(StorageDirectory directory, FileRequest image) { + GeneratedKey generated = storageKeyFactory.generate(directory, image.filename()); binaryContentStorage.store(generated.key(), image.bytes(), generated.contentType()); return binaryContentRepository.save( BinaryContent.completed(binaryContentStorage.toUrl(generated.key()))); } + + public UploadedBinaryContent uploadToStorage(StorageDirectory directory, FileRequest image) { + GeneratedKey generated = storageKeyFactory.generate(directory, image.filename()); + binaryContentStorage.store(generated.key(), image.bytes(), generated.contentType()); + return new UploadedBinaryContent(generated.key(), binaryContentStorage.toUrl(generated.key())); + } + + @Transactional + public BinaryContent saveCompleted(UploadedBinaryContent uploaded) { + return binaryContentRepository.save(BinaryContent.completed(uploaded.url())); + } + + public void deleteQuietly(UploadedBinaryContent uploaded) { + if (uploaded == null) { + return; + } + try { + binaryContentStorage.delete(uploaded.key()); + } catch (RuntimeException e) { + log.warn("업로드 보상 삭제 실패: key={}", uploaded.key(), e); + } + } } diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/service/UploadWithRollback.java b/src/main/java/com/codeit/team5/mopl/binarycontent/service/UploadWithRollback.java new file mode 100644 index 00000000..8c512ee9 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/service/UploadWithRollback.java @@ -0,0 +1,41 @@ +package com.codeit.team5.mopl.binarycontent.service; + +import com.codeit.team5.mopl.binarycontent.dto.UploadedBinaryContent; +import com.codeit.team5.mopl.binarycontent.event.BinaryContentDeleteEvent; +import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; +import com.codeit.team5.mopl.global.dto.FileRequest; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * 스토리지 업로드(트랜잭션 밖)를 수행한 뒤 후속 작업을 실행하고, + * 후속 작업이 실패하면 업로드한 객체를 비동기로 삭제해 롤백한다. + */ +@Component +@RequiredArgsConstructor +public class UploadWithRollback { + + private final BinaryContentService binaryContentService; + private final ApplicationEventPublisher eventPublisher; + + public T execute( + StorageDirectory directory, + FileRequest image, + Function persist + ) { + UploadedBinaryContent uploaded = null; + try { + if (image != null) { + uploaded = binaryContentService.uploadToStorage(directory, image); + } + return persist.apply(uploaded); + } catch (RuntimeException e) { + if (uploaded != null) { + eventPublisher.publishEvent(new BinaryContentDeleteEvent(uploaded)); + } + throw e; + } + } +} diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/BinaryContentStorage.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/BinaryContentStorage.java index 1185de47..42ae71d1 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/BinaryContentStorage.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/BinaryContentStorage.java @@ -5,4 +5,6 @@ public interface BinaryContentStorage { String toUrl(String key); void store(String key, byte[] bytes, String contentType); + + void delete(String key); } diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/StorageKeyFactory.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/StorageKeyFactory.java index 009a3025..8bfef5af 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/StorageKeyFactory.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/StorageKeyFactory.java @@ -9,16 +9,16 @@ /** * 저장소 키 생성 책임을 담당한다. - * 확장자 검증 → 폴더 prefix/소유자/난수 조합 → Content-Type 결정을 한 곳에서 처리한다. + * 확장자 검증 → 폴더 prefix/난수 조합 → Content-Type 결정을 한 곳에서 처리한다. */ @Component public class StorageKeyFactory { - public GeneratedKey generate(StorageDirectory directory, UUID ownerId, String originalFilename) { + public GeneratedKey generate(StorageDirectory directory, String originalFilename) { String extension = StringUtils.getFilenameExtension(originalFilename); ImageExtension imageExtension = ImageExtension.from(extension) .orElseThrow(() -> new InvalidImageExtensionException(extension)); - String key = directory.value() + "/" + ownerId + "/" + UUID.randomUUID() + String key = directory.value() + "/" + UUID.randomUUID() + "." + imageExtension.name().toLowerCase(Locale.ROOT); return new GeneratedKey(key, imageExtension.contentType()); } diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java index 2b66ea8e..e9fd22aa 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java @@ -42,6 +42,17 @@ public void store(String key, byte[] bytes, String contentType) { log.debug("파일 저장 완료: {}", destination); } + @Override + public void delete(String key) { + Path destination = uploadDir.resolve(key); + try { + Files.deleteIfExists(destination); + } catch (IOException e) { + throw new FileStorageException(key, e); + } + log.debug("파일 삭제 완료: {}", destination); + } + private void init() { try { Files.createDirectories(uploadDir); diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorage.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorage.java index 9fb469b1..571ef8d7 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorage.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorage.java @@ -9,6 +9,7 @@ import software.amazon.awssdk.core.exception.SdkException; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; @Slf4j @@ -45,4 +46,18 @@ public void store(String key, byte[] bytes, String contentType) { HttpStatus.INTERNAL_SERVER_ERROR, "S3 업로드 실패: key=" + key, e); } } + + @Override + public void delete(String key) { + try { + s3Client.deleteObject(DeleteObjectRequest.builder() + .bucket(properties.bucketName()) + .key(key) + .build()); + log.debug("S3 객체 삭제 완료: key={}", key); + } catch (SdkException e) { + throw new BinaryContentStorageException( + HttpStatus.INTERNAL_SERVER_ERROR, "S3 객체 삭제 실패: key=" + key, e); + } + } } diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java index 9af81795..96cc3e62 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java @@ -1,5 +1,6 @@ package com.codeit.team5.mopl.binarycontent.storage.s3; +import java.time.Duration; import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "mopl.storage.s3") @@ -7,7 +8,17 @@ public record S3StorageProperties( String accessKey, String secretKey, String bucketName, - String region + String region, + Duration apiCallTimeout, + Duration apiCallAttemptTimeout ) { + public S3StorageProperties { + if (apiCallTimeout == null) { + apiCallTimeout = Duration.ofSeconds(10); + } + if (apiCallAttemptTimeout == null) { + apiCallAttemptTimeout = Duration.ofSeconds(5); + } + } } diff --git a/src/main/java/com/codeit/team5/mopl/config/StorageConfig.java b/src/main/java/com/codeit/team5/mopl/config/StorageConfig.java index 3bb84dbe..baa57349 100644 --- a/src/main/java/com/codeit/team5/mopl/config/StorageConfig.java +++ b/src/main/java/com/codeit/team5/mopl/config/StorageConfig.java @@ -11,6 +11,7 @@ import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; @@ -40,6 +41,10 @@ public S3Client s3Client(S3StorageProperties props) { return S3Client.builder() .region(Region.of(region)) .credentialsProvider(credentialsProvider) + .overrideConfiguration(ClientOverrideConfiguration.builder() + .apiCallTimeout(props.apiCallTimeout()) + .apiCallAttemptTimeout(props.apiCallAttemptTimeout()) + .build()) .build(); } } diff --git a/src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java b/src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java index d6c60360..8b65671d 100644 --- a/src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java +++ b/src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java @@ -8,6 +8,7 @@ import com.codeit.team5.mopl.content.dto.response.ContentResponse; import com.codeit.team5.mopl.content.entity.ContentSortByType; import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.content.facade.ContentFacade; import com.codeit.team5.mopl.content.service.ContentService; import com.codeit.team5.mopl.global.dto.CursorResponse; import jakarta.validation.Valid; @@ -38,6 +39,7 @@ public class ContentController implements ContentApi { private final ContentService contentService; + private final ContentFacade contentFacade; @InitBinder public void initBinder(WebDataBinder binder) { @@ -63,7 +65,7 @@ public ResponseEntity postContent( @RequestPart(value = "thumbnail", required = false) MultipartFile thumbnail ) { log.info("Content Create request: POST /api/contents"); - ContentResponse response = contentService.create(request, MultipartFiles.toImageResource(thumbnail)); + ContentResponse response = contentFacade.create(request, MultipartFiles.toImageResource(thumbnail)); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @@ -76,7 +78,7 @@ public ResponseEntity patchContent( @RequestPart(value = "thumbnail", required = false) MultipartFile thumbnail ) { log.info("Content Update request: PATCH /api/contents/{}", contentId); - ContentResponse response = contentService.update(contentId, request, MultipartFiles.toImageResource(thumbnail)); + ContentResponse response = contentFacade.update(contentId, request, MultipartFiles.toImageResource(thumbnail)); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/codeit/team5/mopl/content/facade/ContentFacade.java b/src/main/java/com/codeit/team5/mopl/content/facade/ContentFacade.java new file mode 100644 index 00000000..de081dbe --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/content/facade/ContentFacade.java @@ -0,0 +1,30 @@ +package com.codeit.team5.mopl.content.facade; + +import com.codeit.team5.mopl.binarycontent.service.UploadWithRollback; +import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; +import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; +import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest; +import com.codeit.team5.mopl.content.dto.response.ContentResponse; +import com.codeit.team5.mopl.content.service.ContentService; +import com.codeit.team5.mopl.global.dto.FileRequest; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ContentFacade { + + private final ContentService contentService; + private final UploadWithRollback uploadWithRollback; + + public ContentResponse create(ContentCreateRequest request, FileRequest image) { + return uploadWithRollback.execute(StorageDirectory.THUMBNAIL, image, + uploaded -> contentService.create(request, uploaded)); + } + + public ContentResponse update(UUID contentId, ContentUpdateRequest request, FileRequest image) { + return uploadWithRollback.execute(StorageDirectory.THUMBNAIL, image, + uploaded -> contentService.update(contentId, request, uploaded)); + } +} 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 b386d842..248878d5 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 @@ -1,9 +1,9 @@ package com.codeit.team5.mopl.content.service; +import com.codeit.team5.mopl.binarycontent.dto.UploadedBinaryContent; 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.binarycontent.storage.StorageDirectory; 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; @@ -18,7 +18,6 @@ import com.codeit.team5.mopl.content.repository.ContentRepository; import com.codeit.team5.mopl.content.repository.ContentStatsRepository; import com.codeit.team5.mopl.global.dto.CursorResponse; -import com.codeit.team5.mopl.global.dto.FileRequest; import com.codeit.team5.mopl.tag.entity.Tag; import com.codeit.team5.mopl.tag.repository.TagRepository; import java.util.HashSet; @@ -61,7 +60,7 @@ public class ContentService { * @return 생성된 콘텐츠 응답 DTO */ @Transactional - public ContentResponse create(ContentCreateRequest request, FileRequest image) { + public ContentResponse create(ContentCreateRequest request, UploadedBinaryContent thumbnail) { Content content = contentRepository.save(Content.createByAdmin( request.type(), request.title(), @@ -73,8 +72,8 @@ public ContentResponse create(ContentCreateRequest request, FileRequest image) { ContentStats stats = contentStatsRepository.save(ContentStats.create(content)); content.attachStats(stats); - if (image != null) { - content.attachThumbnail(binaryContentService.upload(StorageDirectory.THUMBNAIL, content.getId(), image)); + if (thumbnail != null) { + content.attachThumbnail(binaryContentService.saveCompleted(thumbnail)); } return contentMapper.toDto(content); @@ -90,7 +89,7 @@ public ContentResponse create(ContentCreateRequest request, FileRequest image) { * @throws ContentNotFoundException 콘텐츠가 존재하지 않을 때 */ @Transactional - public ContentResponse update(UUID contentId, ContentUpdateRequest request, FileRequest image) { + public ContentResponse update(UUID contentId, ContentUpdateRequest request, UploadedBinaryContent thumbnail) { Content content = contentRepository.findWithStatsAndTagsById(contentId) .orElseThrow(() -> new ContentNotFoundException(contentId)); @@ -99,8 +98,8 @@ public ContentResponse update(UUID contentId, ContentUpdateRequest request, File content.update(request.title(), request.description()); updateTags(content, tagNames); - if (image != null) { - content.attachThumbnail(binaryContentService.upload(StorageDirectory.THUMBNAIL, content.getId(), image)); + if (thumbnail != null) { + content.attachThumbnail(binaryContentService.saveCompleted(thumbnail)); } return contentMapper.toDto(content); diff --git a/src/main/java/com/codeit/team5/mopl/user/controller/UserController.java b/src/main/java/com/codeit/team5/mopl/user/controller/UserController.java index 06dcd5fe..33048efb 100644 --- a/src/main/java/com/codeit/team5/mopl/user/controller/UserController.java +++ b/src/main/java/com/codeit/team5/mopl/user/controller/UserController.java @@ -4,6 +4,7 @@ import com.codeit.team5.mopl.auth.security.details.MoplUserDetails; import com.codeit.team5.mopl.binarycontent.support.MultipartFiles; import com.codeit.team5.mopl.global.dto.CursorResponse; +import com.codeit.team5.mopl.global.dto.FileRequest; import com.codeit.team5.mopl.user.controller.api.UserApi; import com.codeit.team5.mopl.user.dto.request.ChangePasswordRequest; import com.codeit.team5.mopl.user.dto.request.UserCursorRequest; @@ -12,6 +13,7 @@ import com.codeit.team5.mopl.user.dto.request.UserRoleUpdateRequest; import com.codeit.team5.mopl.user.dto.request.UserUpdateRequest; import com.codeit.team5.mopl.user.dto.response.UserResponse; +import com.codeit.team5.mopl.user.facade.UserProfileFacade; import com.codeit.team5.mopl.user.service.UserService; import jakarta.validation.Valid; import java.util.UUID; @@ -37,11 +39,13 @@ @Slf4j public class UserController implements UserApi { private final UserService userService; + private final UserProfileFacade userProfileFacade; @Override @PostMapping public ResponseEntity registerUser( - @Valid @RequestBody UserRegisterRequest userRegisterRequest) { + @Valid @RequestBody UserRegisterRequest userRegisterRequest + ) { log.info("User register request: POST /api/users"); UserResponse response = userService.create(userRegisterRequest); @@ -65,11 +69,20 @@ public ResponseEntity updateUser( @AuthenticationPrincipal MoplUserDetails userDetails, @PathVariable UUID userId, @Valid @RequestPart("request") UserUpdateRequest request, - @RequestPart(value = "image", required = false) MultipartFile image) { + @RequestPart(value = "image", required = false) MultipartFile image + ) { log.info("User update request: PATCH /api/users/{}", userId); - UserResponse response = userService.update( - userDetails.getId(), userId, request, MultipartFiles.toImageResource(image)); + FileRequest imageRequest = (image != null && !image.isEmpty()) + ? MultipartFiles.toImageResource(image) + : null; + + UserResponse response = userProfileFacade.updateProfile( + userDetails.getId(), + userId, + request, + imageRequest + ); return ResponseEntity.ok(response); } @@ -78,7 +91,8 @@ public ResponseEntity updateUser( @PatchMapping(value = "/{userId}/role") public ResponseEntity updateRole( @PathVariable UUID userId, - @Valid @RequestBody UserRoleUpdateRequest request) { + @Valid @RequestBody UserRoleUpdateRequest request + ) { log.info("User role update request: PATCH /api/users/{}/role", userId); userService.updateRole(userId, request); @@ -90,7 +104,8 @@ public ResponseEntity updateRole( @PatchMapping(value = "/{userId}/locked") public ResponseEntity updateLockStatus( @PathVariable UUID userId, - @Valid @RequestBody UserLockedUpdateRequest request) { + @Valid @RequestBody UserLockedUpdateRequest request + ) { log.info("User lock status update request: PATCH /api/users/{}/locked", userId); userService.updateLock(userId, request); @@ -114,7 +129,8 @@ public ResponseEntity updatePassword( @Override @GetMapping public ResponseEntity> getUsers( - @Valid UserCursorRequest request) { + @Valid UserCursorRequest request + ) { log.info("User list request: GET /api/users"); CursorResponse response = userService.findUsers(request); diff --git a/src/main/java/com/codeit/team5/mopl/user/facade/UserProfileFacade.java b/src/main/java/com/codeit/team5/mopl/user/facade/UserProfileFacade.java new file mode 100644 index 00000000..217d9088 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/user/facade/UserProfileFacade.java @@ -0,0 +1,34 @@ +package com.codeit.team5.mopl.user.facade; + +import com.codeit.team5.mopl.binarycontent.service.UploadWithRollback; +import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; +import com.codeit.team5.mopl.global.dto.FileRequest; +import com.codeit.team5.mopl.user.dto.request.UserUpdateRequest; +import com.codeit.team5.mopl.user.dto.response.UserResponse; +import com.codeit.team5.mopl.user.exception.UserForbiddenException; +import com.codeit.team5.mopl.user.service.UserService; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class UserProfileFacade { + + private final UserService userService; + private final UploadWithRollback uploadWithRollback; + + public UserResponse updateProfile( + UUID currentUserId, + UUID userId, + UserUpdateRequest request, + FileRequest image + ) { + if (!currentUserId.equals(userId)) { + throw new UserForbiddenException(currentUserId, userId); + } + + return uploadWithRollback.execute(StorageDirectory.PROFILE, image, + uploaded -> userService.update(userId, request, uploaded)); + } +} 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 fd0cedf3..14507b8c 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 @@ -3,10 +3,9 @@ import com.codeit.team5.mopl.auth.service.RefreshTokenStore; import com.codeit.team5.mopl.auth.service.TemporaryPasswordService; import com.codeit.team5.mopl.auth.support.EmailNormalizer; +import com.codeit.team5.mopl.binarycontent.dto.UploadedBinaryContent; import com.codeit.team5.mopl.binarycontent.service.BinaryContentService; -import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; import com.codeit.team5.mopl.global.dto.CursorResponse; -import com.codeit.team5.mopl.global.dto.FileRequest; import com.codeit.team5.mopl.user.dto.request.ChangePasswordRequest; import com.codeit.team5.mopl.user.dto.request.UserCursorRequest; import com.codeit.team5.mopl.user.dto.request.UserLockedUpdateRequest; @@ -75,14 +74,12 @@ public UserResponse getById(UUID userId) { } @Transactional - public UserResponse update(UUID currentUserId, UUID userId, UserUpdateRequest request, FileRequest image) { - validateOwner(currentUserId, userId); - + public UserResponse update(UUID userId, UserUpdateRequest request, UploadedBinaryContent uploadedImage) { User user = getUserWithProfileImage(userId); user.updateName(request.name()); - if (image != null) { - user.updateProfileImage(binaryContentService.upload(StorageDirectory.PROFILE, user.getId(), image)); + if (uploadedImage != null) { + user.updateProfileImage(binaryContentService.saveCompleted(uploadedImage)); } log.info("User updated: userId={}", userId); @@ -148,12 +145,6 @@ public CursorResponse findUsers(UserCursorRequest request) { return userMapper.toCursor(page, hasNext, totalCount, request.sortBy(), request.sortDirection()); } - 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(() -> { @@ -170,6 +161,12 @@ private User getUser(UUID userId) { }); } + private void validateOwner(UUID currentUserId, UUID userId) { + if (!currentUserId.equals(userId)) { + throw new UserForbiddenException(currentUserId, userId); + } + } + private void validateDuplicateEmail(String email) { if (userRepository.existsByEmail(email)) { log.warn( diff --git a/src/test/java/com/codeit/team5/mopl/binarycontent/eventlistener/BinaryContentDeleteEventListenerTest.java b/src/test/java/com/codeit/team5/mopl/binarycontent/eventlistener/BinaryContentDeleteEventListenerTest.java new file mode 100644 index 00000000..a1f3efc0 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/binarycontent/eventlistener/BinaryContentDeleteEventListenerTest.java @@ -0,0 +1,37 @@ +package com.codeit.team5.mopl.binarycontent.eventlistener; + +import static org.mockito.Mockito.verify; + +import com.codeit.team5.mopl.binarycontent.dto.UploadedBinaryContent; +import com.codeit.team5.mopl.binarycontent.event.BinaryContentDeleteEvent; +import com.codeit.team5.mopl.binarycontent.service.BinaryContentService; +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 BinaryContentDeleteEventListenerTest { + + @Mock + private BinaryContentService binaryContentService; + + @InjectMocks + private BinaryContentDeleteEventListener listener; + + @Test + @DisplayName("삭제 이벤트를 받으면 스토리지 객체 삭제 성공") + void handle_deletesUploaded() { + // given + UploadedBinaryContent uploaded = + new UploadedBinaryContent("thumbnails/key.jpg", "http://localhost/thumbnails/key.jpg"); + + // when + listener.handle(new BinaryContentDeleteEvent(uploaded)); + + // then + verify(binaryContentService).deleteQuietly(uploaded); + } +} 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 99b68bca..fdbab113 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 @@ -10,6 +10,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.codeit.team5.mopl.binarycontent.dto.UploadedBinaryContent; import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; @@ -18,7 +19,6 @@ 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; import org.junit.jupiter.api.extension.ExtendWith; @@ -45,16 +45,15 @@ class BinaryContentServiceTest { @DisplayName("이미지 업로드 후 COMPLETED 상태로 저장에 성공한다") void upload_success() { // given - 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"))) + when(storageKeyFactory.generate(eq(StorageDirectory.PROFILE), 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 - BinaryContent result = binaryContentService.upload(StorageDirectory.PROFILE, ownerId, image); + BinaryContent result = binaryContentService.upload(StorageDirectory.PROFILE, image); // then verify(binaryContentStorage).store("profiles/key.jpg", image.bytes(), "image/jpeg"); @@ -67,16 +66,90 @@ void upload_success() { @DisplayName("스토리지 업로드 실패 시 예외를 전파하고 저장하지 않는다") void upload_storeFails_throwsAndDoesNotSave() { // given - 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"))) + when(storageKeyFactory.generate(eq(StorageDirectory.PROFILE), 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.upload(StorageDirectory.PROFILE, ownerId, image)) + assertThatThrownBy(() -> binaryContentService.upload(StorageDirectory.PROFILE, image)) .isInstanceOf(RuntimeException.class); verify(binaryContentRepository, never()).save(any()); } + + @Test + @DisplayName("스토리지에만 업로드하고 DB 미저장 성공") + void uploadToStorage_success() { + // given + FileRequest image = new FileRequest(new byte[]{1, 2, 3}, "profile.jpg"); + when(storageKeyFactory.generate(eq(StorageDirectory.PROFILE), eq("profile.jpg"))) + .thenReturn(new GeneratedKey("profiles/key.jpg", "image/jpeg")); + when(binaryContentStorage.toUrl("profiles/key.jpg")) + .thenReturn("http://localhost/profiles/key.jpg"); + + // when + UploadedBinaryContent result = + binaryContentService.uploadToStorage(StorageDirectory.PROFILE, image); + + // then + verify(binaryContentStorage).store("profiles/key.jpg", image.bytes(), "image/jpeg"); + verify(binaryContentRepository, never()).save(any()); + assertThat(result.key()).isEqualTo("profiles/key.jpg"); + assertThat(result.url()).isEqualTo("http://localhost/profiles/key.jpg"); + } + + @Test + @DisplayName("업로드 객체를 COMPLETED 상태로 저장 성공") + void saveCompleted_success() { + // given + UploadedBinaryContent uploaded = + new UploadedBinaryContent("profiles/key.jpg", "http://localhost/profiles/key.jpg"); + when(binaryContentRepository.save(any(BinaryContent.class))).then(returnsFirstArg()); + + // when + BinaryContent result = binaryContentService.saveCompleted(uploaded); + + // then + verify(binaryContentRepository).save(any(BinaryContent.class)); + assertThat(result.getUrl()).isEqualTo("http://localhost/profiles/key.jpg"); + assertThat(result.getUploadStatus()).isEqualTo(BinaryContentUploadStatus.COMPLETED); + } + + @Test + @DisplayName("보상 삭제로 스토리지 객체 삭제 성공") + void deleteQuietly_success() { + // given + UploadedBinaryContent uploaded = + new UploadedBinaryContent("profiles/key.jpg", "http://localhost/profiles/key.jpg"); + + // when + binaryContentService.deleteQuietly(uploaded); + + // then + verify(binaryContentStorage).delete("profiles/key.jpg"); + } + + @Test + @DisplayName("보상 삭제 대상이 null이면 미수행 성공") + void deleteQuietly_null_noOp() { + // when + binaryContentService.deleteQuietly(null); + + // then + verify(binaryContentStorage, never()).delete(any()); + } + + @Test + @DisplayName("보상 삭제 실패 시 예외 삼킴 성공") + void deleteQuietly_deleteFails_swallowsException() { + // given + UploadedBinaryContent uploaded = + new UploadedBinaryContent("profiles/key.jpg", "http://localhost/profiles/key.jpg"); + doThrow(new RuntimeException("삭제 실패")).when(binaryContentStorage).delete(any()); + + // when & then + binaryContentService.deleteQuietly(uploaded); + verify(binaryContentStorage).delete("profiles/key.jpg"); + } } diff --git a/src/test/java/com/codeit/team5/mopl/binarycontent/storage/StorageKeyFactoryTest.java b/src/test/java/com/codeit/team5/mopl/binarycontent/storage/StorageKeyFactoryTest.java index eeb438bc..d0918a3f 100644 --- a/src/test/java/com/codeit/team5/mopl/binarycontent/storage/StorageKeyFactoryTest.java +++ b/src/test/java/com/codeit/team5/mopl/binarycontent/storage/StorageKeyFactoryTest.java @@ -4,7 +4,6 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.codeit.team5.mopl.binarycontent.exception.InvalidImageExtensionException; -import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -13,16 +12,13 @@ class StorageKeyFactoryTest { private final StorageKeyFactory storageKeyFactory = new StorageKeyFactory(); @Test - @DisplayName("generate는 {prefix}/{ownerId}/{uuid}.{ext} 형식의 키와 contentType 반환 성공") + @DisplayName("generate는 {prefix}/{uuid}.{ext} 형식의 키와 contentType 반환 성공") void generate_returnsKeyAndContentType() { - // given - UUID ownerId = UUID.randomUUID(); - // when - GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.THUMBNAIL, ownerId, "test.jpg"); + GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.THUMBNAIL, "test.jpg"); // then - assertThat(generated.key()).startsWith("thumbnails/" + ownerId + "/"); + assertThat(generated.key()).startsWith("thumbnails/"); assertThat(generated.key()).endsWith(".jpg"); assertThat(generated.contentType()).isEqualTo("image/jpeg"); } @@ -30,25 +26,19 @@ void generate_returnsKeyAndContentType() { @Test @DisplayName("generate는 StorageDirectory에 맞는 prefix 적용 성공") void generate_appliesDirectoryPrefix() { - // given - UUID ownerId = UUID.randomUUID(); - // when - GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.PROFILE, ownerId, "test.png"); + GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.PROFILE, "test.png"); // then - assertThat(generated.key()).startsWith("profiles/" + ownerId + "/"); + assertThat(generated.key()).startsWith("profiles/"); assertThat(generated.contentType()).isEqualTo("image/png"); } @Test @DisplayName("generate는 대문자 확장자를 소문자로 정규화 성공") void generate_normalizesUppercaseExtension() { - // given - UUID ownerId = UUID.randomUUID(); - // when - GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.THUMBNAIL, ownerId, "photo.JPG"); + GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.THUMBNAIL, "photo.JPG"); // then assertThat(generated.key()).endsWith(".jpg"); @@ -58,22 +48,16 @@ void generate_normalizesUppercaseExtension() { @Test @DisplayName("generate는 확장자가 없는 파일명이면 예외 발생 실패") void generate_noExtension_throwsException() { - // given - UUID ownerId = UUID.randomUUID(); - // when & then - assertThatThrownBy(() -> storageKeyFactory.generate(StorageDirectory.THUMBNAIL, ownerId, "testfile")) + assertThatThrownBy(() -> storageKeyFactory.generate(StorageDirectory.THUMBNAIL, "testfile")) .isInstanceOf(InvalidImageExtensionException.class); } @Test @DisplayName("generate는 허용되지 않는 확장자이면 예외 발생 실패") void generate_disallowedExtension_throwsException() { - // given - UUID ownerId = UUID.randomUUID(); - // when & then - assertThatThrownBy(() -> storageKeyFactory.generate(StorageDirectory.THUMBNAIL, ownerId, "malicious.exe")) + assertThatThrownBy(() -> storageKeyFactory.generate(StorageDirectory.THUMBNAIL, "malicious.exe")) .isInstanceOf(InvalidImageExtensionException.class); } } diff --git a/src/test/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorageTest.java b/src/test/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorageTest.java index a31d0c31..0292575d 100644 --- a/src/test/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorageTest.java +++ b/src/test/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorageTest.java @@ -105,4 +105,32 @@ void store_ioFailure_throwsFileStorageException() throws IOException { assertThatThrownBy(() -> storage.store(key, bytes, "image/jpeg")) .isInstanceOf(FileStorageException.class); } + + @Test + @DisplayName("delete는 저장된 파일 삭제 성공") + void delete_removesFile() { + // given + String key = "thumbnails/" + UUID.randomUUID() + "/test.jpg"; + storage.store(key, new byte[]{1, 2, 3}, "image/jpeg"); + assertThat(Files.exists(tempDir.resolve(key))).isTrue(); + + // when + storage.delete(key); + + // then + assertThat(Files.exists(tempDir.resolve(key))).isFalse(); + } + + @Test + @DisplayName("delete는 파일이 없어도 예외 없이 성공") + void delete_missingFile_noException() { + // given + String key = "thumbnails/none/none.jpg"; + + // when + storage.delete(key); + + // then + assertThat(Files.exists(tempDir.resolve(key))).isFalse(); + } } diff --git a/src/test/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorageTest.java b/src/test/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorageTest.java index d49f147e..aa7bbeb5 100644 --- a/src/test/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorageTest.java +++ b/src/test/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorageTest.java @@ -17,6 +17,7 @@ import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; @ExtendWith(MockitoExtension.class) @@ -30,7 +31,7 @@ class S3BinaryContentStorageTest { @BeforeEach void setUp() { S3StorageProperties properties = - new S3StorageProperties(null, null, "mopl-bucket", "ap-northeast-2"); + new S3StorageProperties(null, null, "mopl-bucket", "ap-northeast-2", null, null); storage = new S3BinaryContentStorage(properties, s3Client); } @@ -71,4 +72,30 @@ void toUrl_success() { assertThat(url) .isEqualTo("https://mopl-bucket.s3.ap-northeast-2.amazonaws.com/profiles/abc.jpg"); } + + @Test + @DisplayName("S3 객체 삭제 성공") + void delete_success() { + // When + storage.delete("profiles/abc.jpg"); + + // Then + ArgumentCaptor captor = ArgumentCaptor.forClass(DeleteObjectRequest.class); + verify(s3Client).deleteObject(captor.capture()); + DeleteObjectRequest request = captor.getValue(); + assertThat(request.bucket()).isEqualTo("mopl-bucket"); + assertThat(request.key()).isEqualTo("profiles/abc.jpg"); + } + + @Test + @DisplayName("삭제 중 SDK 예외가 발생하면 BinaryContentStorageException으로 변환하며 S3 삭제 실패") + void delete_sdkException_throwsBinaryContentStorageException() { + // Given + given(s3Client.deleteObject(any(DeleteObjectRequest.class))) + .willThrow(SdkClientException.create("S3 연결 실패")); + + // When & Then + assertThatThrownBy(() -> storage.delete("profiles/abc.jpg")) + .isInstanceOf(BinaryContentStorageException.class); + } } 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 70711724..1f7527f4 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 @@ -25,6 +25,7 @@ import com.codeit.team5.mopl.global.dto.CursorResponse; import com.codeit.team5.mopl.content.entity.ContentType; import com.codeit.team5.mopl.content.exception.ContentNotFoundException; +import com.codeit.team5.mopl.content.facade.ContentFacade; import com.codeit.team5.mopl.content.service.ContentService; import com.codeit.team5.mopl.global.dto.FileRequest; import com.codeit.team5.mopl.global.exception.GlobalExceptionHandler; @@ -68,6 +69,9 @@ class ContentControllerTest { @MockitoBean private ContentService contentService; + @MockitoBean + private ContentFacade contentFacade; + @Captor private ArgumentCaptor requestCaptor; @@ -93,7 +97,7 @@ void postContent_success() throws Exception { 0.0, 0, 0 ); - given(contentService.create(any(ContentCreateRequest.class), any())).willReturn(response); + given(contentFacade.create(any(ContentCreateRequest.class), any())).willReturn(response); MockMultipartFile requestPart = new MockMultipartFile( "request", @@ -125,7 +129,7 @@ void postContent_success() throws Exception { .andExpect(jsonPath("$.watcherCount").value(0)); ArgumentCaptor thumbnailCaptor = ArgumentCaptor.forClass(FileRequest.class); - verify(contentService).create(requestCaptor.capture(), thumbnailCaptor.capture()); + verify(contentFacade).create(requestCaptor.capture(), thumbnailCaptor.capture()); assertThat(thumbnailCaptor.getValue().filename()).isEqualTo("test.jpg"); assertThat(thumbnailCaptor.getValue().bytes()).containsExactly(1, 2, 3); ContentCreateRequest captured = requestCaptor.getValue(); @@ -155,7 +159,7 @@ void postContent_withoutThumbnail_success() throws Exception { 0.0, 0, 0 ); - given(contentService.create(any(ContentCreateRequest.class), isNull())).willReturn(response); + given(contentFacade.create(any(ContentCreateRequest.class), isNull())).willReturn(response); MockMultipartFile requestPart = new MockMultipartFile( "request", "", MediaType.APPLICATION_JSON_VALUE, @@ -174,7 +178,7 @@ void postContent_withoutThumbnail_success() throws Exception { .andExpect(jsonPath("$.averageRating").value(0.0)) .andExpect(jsonPath("$.reviewCount").value(0)) .andExpect(jsonPath("$.watcherCount").value(0)); - verify(contentService).create(requestCaptor.capture(), isNull()); + verify(contentFacade).create(requestCaptor.capture(), isNull()); ContentCreateRequest captured = requestCaptor.getValue(); assertThat(captured.type()).isEqualTo(ContentType.TV_SERIES); assertThat(captured.title()).isEqualTo("테스트 드라마"); @@ -204,7 +208,7 @@ void postContent_missingType_returnsBadRequest() throws Exception { .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) .andExpect(jsonPath("$.details.type[0]").value("콘텐츠 타입은 필수입니다.")); - verify(contentService, never()).create(any(), any()); + verify(contentFacade, never()).create(any(), any()); } @Test @@ -230,7 +234,7 @@ void postContent_blankTitle_returnsBadRequest() throws Exception { .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) .andExpect(jsonPath("$.details.title[0]").value("제목은 필수입니다.")); - verify(contentService, never()).create(any(), any()); + verify(contentFacade, never()).create(any(), any()); } @Test @@ -256,7 +260,7 @@ void postContent_emptyTags_returnsBadRequest() throws Exception { .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) .andExpect(jsonPath("$.details.tags[0]").value("콘텐츠 태그 목록은 비어있을 수 없습니다.")); - verify(contentService, never()).create(any(), any()); + verify(contentFacade, never()).create(any(), any()); } @Test @@ -269,7 +273,7 @@ void postContent_unexpectedException_returnsInternalServerError() throws Excepti null, List.of("액션") ); - given(contentService.create(any(ContentCreateRequest.class), any())) + given(contentFacade.create(any(ContentCreateRequest.class), any())) .willThrow(new IllegalStateException("unexpected")); MockMultipartFile requestPart = new MockMultipartFile( @@ -306,7 +310,7 @@ void patchContent_success() throws Exception { 0.0, 0, 0 ); - given(contentService.update(eq(contentId), any(ContentUpdateRequest.class), any())) + given(contentFacade.update(eq(contentId), any(ContentUpdateRequest.class), any())) .willReturn(response); MockMultipartFile requestPart = new MockMultipartFile( @@ -334,7 +338,7 @@ void patchContent_success() throws Exception { ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ContentUpdateRequest.class); ArgumentCaptor thumbnailCaptor = ArgumentCaptor.forClass(FileRequest.class); - verify(contentService).update(eq(contentId), requestCaptor.capture(), thumbnailCaptor.capture()); + verify(contentFacade).update(eq(contentId), requestCaptor.capture(), thumbnailCaptor.capture()); assertThat(requestCaptor.getValue().title()).isEqualTo("수정된 영화"); assertThat(requestCaptor.getValue().description()).isEqualTo("수정된 설명"); assertThat(requestCaptor.getValue().tags()).containsExactly("액션", "SF"); @@ -362,7 +366,7 @@ void patchContent_withoutThumbnail_success() throws Exception { 0.0, 0, 0 ); - given(contentService.update(eq(contentId), any(ContentUpdateRequest.class), isNull())) + given(contentFacade.update(eq(contentId), any(ContentUpdateRequest.class), isNull())) .willReturn(response); MockMultipartFile requestPart = new MockMultipartFile( @@ -378,7 +382,7 @@ void patchContent_withoutThumbnail_success() throws Exception { .andExpect(jsonPath("$.title").value("수정된 드라마")) .andExpect(jsonPath("$.tags[0]").value("로맨스")); - verify(contentService).update(eq(contentId), any(ContentUpdateRequest.class), isNull()); + verify(contentFacade).update(eq(contentId), any(ContentUpdateRequest.class), isNull()); } @Test @@ -400,7 +404,7 @@ void patchContent_blankTitle_returnsBadRequest() throws Exception { .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) .andExpect(jsonPath("$.details.title[0]").value("제목은 필수입니다.")); - verify(contentService, never()).update(any(), any(), any()); + verify(contentFacade, never()).update(any(), any(), any()); } @Test @@ -425,7 +429,7 @@ void patchContent_nullTags_returnsBadRequest() throws Exception { .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) .andExpect(jsonPath("$.details.tags", hasItem("콘텐츠 태그는 필수입니다."))); - verify(contentService, never()).update(any(), any(), any()); + verify(contentFacade, never()).update(any(), any(), any()); } @Test @@ -447,7 +451,7 @@ void patchContent_emptyTags_returnsBadRequest() throws Exception { .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) .andExpect(jsonPath("$.details.tags[0]").value("콘텐츠 태그 목록은 비어있을 수 없습니다.")); - verify(contentService, never()).update(any(), any(), any()); + verify(contentFacade, never()).update(any(), any(), any()); } @Test @@ -469,7 +473,7 @@ void patchContent_blankTagItem_returnsBadRequest() throws Exception { .andExpect(jsonPath("$.message").value("잘못된 입력값입니다.")) .andExpect(jsonPath("$.details['tags[0]'][0]").value("태그는 공백일 수 없습니다.")); - verify(contentService, never()).update(any(), any(), any()); + verify(contentFacade, never()).update(any(), any(), any()); } @Test @@ -478,7 +482,7 @@ void patchContent_notFound_returnsNotFound() throws Exception { // given UUID contentId = UUID.randomUUID(); ContentUpdateRequest request = new ContentUpdateRequest("수정된 영화", null, List.of("액션")); - given(contentService.update(eq(contentId), any(ContentUpdateRequest.class), any())) + given(contentFacade.update(eq(contentId), any(ContentUpdateRequest.class), any())) .willThrow(new ContentNotFoundException(contentId)); MockMultipartFile requestPart = new MockMultipartFile( @@ -493,7 +497,7 @@ void patchContent_notFound_returnsNotFound() throws Exception { .andExpect(jsonPath("$.exceptionType").value("ContentNotFoundException")) .andExpect(jsonPath("$.message").value("콘텐츠를 찾을 수 없습니다.")); - verify(contentService).update(eq(contentId), any(ContentUpdateRequest.class), any()); + verify(contentFacade).update(eq(contentId), any(ContentUpdateRequest.class), any()); } @Test @@ -502,7 +506,7 @@ void patchContent_unexpectedException_returnsInternalServerError() throws Except // given UUID contentId = UUID.randomUUID(); ContentUpdateRequest request = new ContentUpdateRequest("수정된 영화", null, List.of("액션")); - given(contentService.update(eq(contentId), any(ContentUpdateRequest.class), any())) + given(contentFacade.update(eq(contentId), any(ContentUpdateRequest.class), any())) .willThrow(new IllegalStateException("unexpected")); MockMultipartFile requestPart = new MockMultipartFile( diff --git a/src/test/java/com/codeit/team5/mopl/content/facade/ContentFacadeTest.java b/src/test/java/com/codeit/team5/mopl/content/facade/ContentFacadeTest.java new file mode 100644 index 00000000..bea9117d --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/content/facade/ContentFacadeTest.java @@ -0,0 +1,143 @@ +package com.codeit.team5.mopl.content.facade; + +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.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.codeit.team5.mopl.binarycontent.dto.UploadedBinaryContent; +import com.codeit.team5.mopl.binarycontent.service.BinaryContentService; +import com.codeit.team5.mopl.binarycontent.event.BinaryContentDeleteEvent; +import com.codeit.team5.mopl.binarycontent.service.UploadWithRollback; +import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; +import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; +import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest; +import com.codeit.team5.mopl.content.dto.response.ContentResponse; +import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.content.service.ContentService; +import com.codeit.team5.mopl.global.dto.FileRequest; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ContentFacadeTest { + + @Mock + private ContentService contentService; + + @Mock + private BinaryContentService binaryContentService; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private ContentFacade contentFacade; + + @BeforeEach + void setUp() { + contentFacade = new ContentFacade( + contentService, new UploadWithRollback(binaryContentService, eventPublisher)); + } + + private final ContentCreateRequest createRequest = + new ContentCreateRequest(ContentType.MOVIE, "제목", "설명", List.of("액션")); + private final ContentUpdateRequest updateRequest = + new ContentUpdateRequest("제목", "설명", List.of("액션")); + private final FileRequest image = new FileRequest(new byte[]{1, 2, 3}, "thumb.jpg"); + private final UploadedBinaryContent uploaded = + new UploadedBinaryContent("thumbnails/key.jpg", "http://localhost/thumbnails/key.jpg"); + + private ContentResponse response() { + return new ContentResponse(UUID.randomUUID(), ContentType.MOVIE, "제목", "설명", + null, List.of("액션"), 0.0, 0, 0); + } + + @Test + @DisplayName("이미지가 있으면 스토리지 업로드 후 생성 성공") + void create_withImage_success() { + // given + ContentResponse expected = response(); + when(binaryContentService.uploadToStorage(eq(StorageDirectory.THUMBNAIL), any())).thenReturn(uploaded); + when(contentService.create(eq(createRequest), eq(uploaded))).thenReturn(expected); + + // when + ContentResponse result = contentFacade.create(createRequest, image); + + // then + assertThat(result).isSameAs(expected); + verify(binaryContentService).uploadToStorage(eq(StorageDirectory.THUMBNAIL), any()); + verify(eventPublisher, never()).publishEvent(any(BinaryContentDeleteEvent.class)); + } + + @Test + @DisplayName("이미지가 없으면 업로드 없이 생성 성공") + void create_noImage_success() { + // given + ContentResponse expected = response(); + when(contentService.create(eq(createRequest), isNull())).thenReturn(expected); + + // when + ContentResponse result = contentFacade.create(createRequest, null); + + // then + assertThat(result).isSameAs(expected); + verify(binaryContentService, never()).uploadToStorage(any(), any()); + } + + @Test + @DisplayName("생성 실패 시 보상 삭제 후 예외 전파 성공") + void create_serviceFails_compensates() { + // given + when(binaryContentService.uploadToStorage(eq(StorageDirectory.THUMBNAIL), any())).thenReturn(uploaded); + when(contentService.create(eq(createRequest), eq(uploaded))) + .thenThrow(new RuntimeException("db failure")); + + // when & then + assertThatThrownBy(() -> contentFacade.create(createRequest, image)) + .isInstanceOf(RuntimeException.class); + verify(eventPublisher).publishEvent(new BinaryContentDeleteEvent(uploaded)); + } + + @Test + @DisplayName("이미지가 있으면 스토리지 업로드 후 수정 성공") + void update_withImage_success() { + // given + UUID contentId = UUID.randomUUID(); + ContentResponse expected = response(); + when(binaryContentService.uploadToStorage(eq(StorageDirectory.THUMBNAIL), any())).thenReturn(uploaded); + when(contentService.update(eq(contentId), eq(updateRequest), eq(uploaded))).thenReturn(expected); + + // when + ContentResponse result = contentFacade.update(contentId, updateRequest, image); + + // then + assertThat(result).isSameAs(expected); + verify(eventPublisher, never()).publishEvent(any(BinaryContentDeleteEvent.class)); + } + + @Test + @DisplayName("수정 실패 시 보상 삭제 후 예외 전파 성공") + void update_serviceFails_compensates() { + // given + UUID contentId = UUID.randomUUID(); + when(binaryContentService.uploadToStorage(eq(StorageDirectory.THUMBNAIL), any())).thenReturn(uploaded); + when(contentService.update(eq(contentId), eq(updateRequest), eq(uploaded))) + .thenThrow(new RuntimeException("db failure")); + + // when & then + assertThatThrownBy(() -> contentFacade.update(contentId, updateRequest, image)) + .isInstanceOf(RuntimeException.class); + verify(eventPublisher).publishEvent(new BinaryContentDeleteEvent(uploaded)); + } +} 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 c25d1762..d5499251 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,7 +11,7 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; -import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; +import com.codeit.team5.mopl.binarycontent.dto.UploadedBinaryContent; import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.binarycontent.service.BinaryContentService; import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; @@ -48,7 +48,6 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; -import com.codeit.team5.mopl.global.dto.FileRequest; @ExtendWith(MockitoExtension.class) class ContentServiceTest { @@ -82,7 +81,8 @@ void create_success() { ContentCreateRequest request = new ContentCreateRequest( ContentType.MOVIE, "테스트 영화", "테스트 설명", List.of("액션", "드라마") ); - FileRequest thumbnail = new FileRequest(new byte[]{1, 2, 3}, "test.jpg"); + UploadedBinaryContent thumbnail = + new UploadedBinaryContent("thumbnails/test.jpg", "http://localhost:8080/thumbnails/test.jpg"); Tag actionTag = Tag.create("액션"); Tag dramaTag = Tag.create("드라마"); ContentResponse expectedResponse = new ContentResponse( @@ -94,7 +94,7 @@ void create_success() { 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(binaryContentService.upload(eq(StorageDirectory.THUMBNAIL), any(), any())) + when(binaryContentService.saveCompleted(thumbnail)) .thenReturn(BinaryContent.completed("http://localhost:8080/thumbnails/test.jpg")); when(contentMapper.toDto(any(Content.class))).thenReturn(expectedResponse); @@ -111,7 +111,7 @@ void create_success() { assertThat(savedContent.getTitle()).isEqualTo("테스트 영화"); assertThat(savedContent.getThumbnail()).isNotNull(); - verify(binaryContentService).upload(eq(StorageDirectory.THUMBNAIL), any(), any()); + verify(binaryContentService).saveCompleted(thumbnail); verify(tagRepository).findByNameIn(List.of("액션", "드라마")); verify(contentStatsRepository).save(any(ContentStats.class)); @@ -276,7 +276,8 @@ void update_withNewThumbnail_noOldThumbnail_success() { // given UUID contentId = UUID.randomUUID(); Content content = Content.createByAdmin(ContentType.MOVIE, "기존 제목", null); - FileRequest image = new FileRequest(new byte[]{1, 2, 3}, "new.jpg"); + UploadedBinaryContent thumbnail = + new UploadedBinaryContent("thumbnails/new.jpg", "http://localhost/thumbnails/new.jpg"); ContentResponse expectedResponse = new ContentResponse( contentId, ContentType.MOVIE, "수정 제목", null, "http://localhost/thumbnails/new.jpg", @@ -285,17 +286,17 @@ void update_withNewThumbnail_noOldThumbnail_success() { when(contentRepository.findWithStatsAndTagsById(contentId)).thenReturn(Optional.of(content)); when(tagRepository.findByNameIn(List.of("액션"))).thenReturn(List.of(Tag.create("액션"))); - when(binaryContentService.upload(eq(StorageDirectory.THUMBNAIL), any(), any())) + when(binaryContentService.saveCompleted(thumbnail)) .thenReturn(BinaryContent.completed("http://localhost/thumbnails/new.jpg")); when(contentMapper.toDto(content)).thenReturn(expectedResponse); // when - ContentResponse result = contentService.update(contentId, new ContentUpdateRequest("수정 제목", null, List.of("액션")), image); + ContentResponse result = contentService.update(contentId, new ContentUpdateRequest("수정 제목", null, List.of("액션")), thumbnail); // then assertThat(result).isSameAs(expectedResponse); assertThat(content.getThumbnail()).isNotNull(); - verify(binaryContentService).upload(eq(StorageDirectory.THUMBNAIL), any(), any()); + verify(binaryContentService).saveCompleted(thumbnail); } @Test @@ -306,16 +307,17 @@ void update_withNewThumbnail_oldThumbnailMarkedDeleted() { Content content = Content.createByAdmin(ContentType.MOVIE, "기존 제목", null); BinaryContent oldThumbnail = BinaryContent.completed("http://localhost/thumbnails/old.jpg"); content.attachThumbnail(oldThumbnail); - FileRequest image = new FileRequest(new byte[]{4, 5, 6}, "new.jpg"); + UploadedBinaryContent thumbnail = + new UploadedBinaryContent("thumbnails/new.jpg", "http://localhost/thumbnails/new.jpg"); when(contentRepository.findWithStatsAndTagsById(contentId)).thenReturn(Optional.of(content)); when(tagRepository.findByNameIn(List.of("액션"))).thenReturn(List.of(Tag.create("액션"))); - when(binaryContentService.upload(eq(StorageDirectory.THUMBNAIL), any(), any())) + when(binaryContentService.saveCompleted(thumbnail)) .thenReturn(BinaryContent.completed("http://localhost/thumbnails/new.jpg")); when(contentMapper.toDto(content)).thenReturn(null); // when - contentService.update(contentId, new ContentUpdateRequest("수정 제목", null, List.of("액션")), image); + contentService.update(contentId, new ContentUpdateRequest("수정 제목", null, List.of("액션")), thumbnail); // then assertThat(oldThumbnail.getUploadStatus()).isEqualTo(BinaryContentUploadStatus.DELETED); diff --git a/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerTest.java b/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerTest.java index b3019c74..391e21e0 100644 --- a/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerTest.java +++ b/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerTest.java @@ -42,6 +42,7 @@ import com.codeit.team5.mopl.user.exception.SameLockStatusException; import com.codeit.team5.mopl.user.exception.SameRoleAssignmentException; import com.codeit.team5.mopl.user.exception.UserNotFoundException; +import com.codeit.team5.mopl.user.facade.UserProfileFacade; import com.codeit.team5.mopl.user.repository.UserRepository; import com.codeit.team5.mopl.user.service.UserService; import com.fasterxml.jackson.databind.ObjectMapper; @@ -104,6 +105,9 @@ class UserControllerTest { @MockitoBean private UserRepository userRepository; + @MockitoBean + private UserProfileFacade userProfileFacade; + @Test @DisplayName("정상적인 회원가입 요청이면 생성된 사용자와 201 응답을 반환한다") void createUser_success() throws Exception { @@ -360,7 +364,7 @@ void updateUser_success() throws Exception { "user@example.com", "새이름", "http://localhost/profiles/key.jpg", "USER", false ); - given(userService.update(eq(userId), eq(userId), any(), any())).willReturn(response); + given(userProfileFacade.updateProfile(eq(userId), eq(userId), any(), any())).willReturn(response); MockMultipartFile requestPart = new MockMultipartFile( "request", "", MediaType.APPLICATION_JSON_VALUE, @@ -378,7 +382,7 @@ void updateUser_success() throws Exception { .andExpect(jsonPath("$.name").value("새이름")) .andExpect(jsonPath("$.profileImageUrl").value("http://localhost/profiles/key.jpg")); - verify(userService).update(eq(userId), eq(userId), any(), any()); + verify(userProfileFacade).updateProfile(eq(userId), eq(userId), any(), any()); } @Test @@ -398,7 +402,7 @@ void updateUser_blankName_returnsBadRequest() throws Exception { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.exceptionType").value("INVALID_INPUT")); - verify(userService, never()).update(any(), any(), any(), any()); + verify(userProfileFacade, never()).updateProfile(any(), any(), any(), any()); } private Authentication authOf(UUID userId) { diff --git a/src/test/java/com/codeit/team5/mopl/user/facade/UserProfileFacadeIntegrationTest.java b/src/test/java/com/codeit/team5/mopl/user/facade/UserProfileFacadeIntegrationTest.java new file mode 100644 index 00000000..66c59941 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/user/facade/UserProfileFacadeIntegrationTest.java @@ -0,0 +1,90 @@ +package com.codeit.team5.mopl.user.facade; + +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.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.codeit.team5.mopl.TestcontainersConfiguration; +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; +import com.codeit.team5.mopl.user.entity.User; +import com.codeit.team5.mopl.user.exception.UserNotFoundException; +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 UserProfileFacadeIntegrationTest { + + @Autowired + private UserProfileFacade userProfileFacade; + + @Autowired + private UserRepository userRepository; + + @Autowired + private BinaryContentRepository binaryContentRepository; + + @MockitoBean + private BinaryContentStorage binaryContentStorage; + + @AfterEach + void cleanup() { + userRepository.deleteAll(); + binaryContentRepository.deleteAll(); + } + + @Test + @DisplayName("정상 흐름에서 스토리지 저장과 프로필 이미지 연결 성공") + void updateProfile_success_persistsImage() { + // given + User saved = userRepository.save(User.create("owner@example.com", "encoded-password", "기존이름")); + UUID userId = saved.getId(); + FileRequest image = new FileRequest(new byte[]{1, 2, 3}, "profile.jpg"); + when(binaryContentStorage.toUrl(any())).thenReturn("http://localhost/profiles/key.jpg"); + + // when + userProfileFacade.updateProfile(userId, userId, new UserUpdateRequest("새이름"), image); + + // then + User reloaded = userRepository.findWithProfileImageById(userId).orElseThrow(); + assertThat(reloaded.getName()).isEqualTo("새이름"); + assertThat(reloaded.getProfileImage()).isNotNull(); + verify(binaryContentStorage).store(any(), any(), any()); + verify(binaryContentStorage, never()).delete(any()); + } + + @Test + @DisplayName("업데이트 실패 시 스토리지 보상 삭제 후 DB 저장 없음 성공") + void updateProfile_updateFails_compensatesAndNoDbWrite() { + // given + UUID missingUserId = UUID.randomUUID(); + FileRequest image = new FileRequest(new byte[]{1, 2, 3}, "profile.jpg"); + when(binaryContentStorage.toUrl(any())).thenReturn("http://localhost/profiles/key.jpg"); + + // when & then + assertThatThrownBy(() -> userProfileFacade.updateProfile( + missingUserId, missingUserId, new UserUpdateRequest("새이름"), image)) + .isInstanceOf(UserNotFoundException.class); + + // 업로드한 스토리지 객체는 비동기로 보상 삭제되고, BinaryContent는 저장되지 않아야 한다 + verify(binaryContentStorage).store(any(), any(), any()); + verify(binaryContentStorage, timeout(2000)).delete(any()); + assertThat(binaryContentRepository.count()).isZero(); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/user/facade/UserProfileFacadeTest.java b/src/test/java/com/codeit/team5/mopl/user/facade/UserProfileFacadeTest.java new file mode 100644 index 00000000..a5eeabba --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/user/facade/UserProfileFacadeTest.java @@ -0,0 +1,137 @@ +package com.codeit.team5.mopl.user.facade; + +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.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.codeit.team5.mopl.binarycontent.dto.UploadedBinaryContent; +import com.codeit.team5.mopl.binarycontent.event.BinaryContentDeleteEvent; +import com.codeit.team5.mopl.binarycontent.service.BinaryContentService; +import com.codeit.team5.mopl.binarycontent.service.UploadWithRollback; +import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; +import com.codeit.team5.mopl.global.dto.FileRequest; +import com.codeit.team5.mopl.user.dto.request.UserUpdateRequest; +import com.codeit.team5.mopl.user.dto.response.UserResponse; +import com.codeit.team5.mopl.user.exception.UserForbiddenException; +import com.codeit.team5.mopl.user.service.UserService; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +@ExtendWith(MockitoExtension.class) +class UserProfileFacadeTest { + + @Mock + private UserService userService; + + @Mock + private BinaryContentService binaryContentService; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private UserProfileFacade userProfileFacade; + + @BeforeEach + void setUp() { + userProfileFacade = new UserProfileFacade( + userService, new UploadWithRollback(binaryContentService, eventPublisher)); + } + + private UserResponse response(UUID userId) { + return new UserResponse( + userId, Instant.parse("2026-06-25T00:00:00Z"), + "user@example.com", "새이름", null, "USER", false); + } + + @Test + @DisplayName("본인이 아니면 업로드 전에 실패") + void updateProfile_notOwner_throwsBeforeUpload() { + // given + UUID currentUserId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + FileRequest image = new FileRequest(new byte[]{1, 2, 3}, "profile.jpg"); + + // when & then + assertThatThrownBy(() -> userProfileFacade.updateProfile( + currentUserId, userId, new UserUpdateRequest("새이름"), image)) + .isInstanceOf(UserForbiddenException.class); + + verifyNoInteractions(binaryContentService, userService, eventPublisher); + } + + @Test + @DisplayName("이미지가 있으면 스토리지 업로드 후 업데이트 성공") + void updateProfile_withImage_success() { + // given + UUID userId = UUID.randomUUID(); + FileRequest image = new FileRequest(new byte[]{1, 2, 3}, "profile.jpg"); + UploadedBinaryContent uploaded = + new UploadedBinaryContent("profiles/key", "http://localhost/profiles/key.jpg"); + UserResponse expected = response(userId); + when(binaryContentService.uploadToStorage(eq(StorageDirectory.PROFILE), any())) + .thenReturn(uploaded); + when(userService.update(eq(userId), any(), eq(uploaded))).thenReturn(expected); + + // when + UserResponse result = userProfileFacade.updateProfile( + userId, userId, new UserUpdateRequest("새이름"), image); + + // then + assertThat(result).isSameAs(expected); + verify(binaryContentService).uploadToStorage(eq(StorageDirectory.PROFILE), any()); + verify(userService).update(eq(userId), any(), eq(uploaded)); + verify(eventPublisher, never()).publishEvent(any(BinaryContentDeleteEvent.class)); + } + + @Test + @DisplayName("이미지가 없으면 업로드 없이 업데이트 성공") + void updateProfile_noImage_success() { + // given + UUID userId = UUID.randomUUID(); + UserResponse expected = response(userId); + when(userService.update(eq(userId), any(), isNull())).thenReturn(expected); + + // when + UserResponse result = userProfileFacade.updateProfile( + userId, userId, new UserUpdateRequest("새이름"), null); + + // then + assertThat(result).isSameAs(expected); + verify(userService).update(eq(userId), any(), isNull()); + verify(binaryContentService, never()).uploadToStorage(any(), any()); + } + + @Test + @DisplayName("업로드 후 업데이트가 실패하면 삭제 이벤트 발행 후 예외 전파 성공") + void updateProfile_updateFails_publishesDeleteEventAndPropagates() { + // given + UUID userId = UUID.randomUUID(); + FileRequest image = new FileRequest(new byte[]{1, 2, 3}, "profile.jpg"); + UploadedBinaryContent uploaded = + new UploadedBinaryContent("profiles/key", "http://localhost/profiles/key.jpg"); + when(binaryContentService.uploadToStorage(eq(StorageDirectory.PROFILE), any())) + .thenReturn(uploaded); + when(userService.update(eq(userId), any(), eq(uploaded))) + .thenThrow(new RuntimeException("db failure")); + + // when & then + assertThatThrownBy(() -> userProfileFacade.updateProfile( + userId, userId, new UserUpdateRequest("새이름"), image)) + .isInstanceOf(RuntimeException.class); + + verify(eventPublisher).publishEvent(new BinaryContentDeleteEvent(uploaded)); + } +} 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 deleted file mode 100644 index df4f2a59..00000000 --- a/src/test/java/com/codeit/team5/mopl/user/service/UserServiceImageRollbackIntegrationTest.java +++ /dev/null @@ -1,94 +0,0 @@ -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.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; -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; - - @Autowired - private BinaryContentRepository binaryContentRepository; - - @MockitoBean - private BinaryContentStorage binaryContentStorage; - - @AfterEach - void cleanup() { - userRepository.deleteAll(); - binaryContentRepository.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(); - } - - @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"); - } -} 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 fd2ed259..0c1ed2a0 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 @@ -4,7 +4,6 @@ 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.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -12,12 +11,11 @@ import com.codeit.team5.mopl.auth.service.RefreshTokenStore; import com.codeit.team5.mopl.auth.service.TemporaryPasswordService; -import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; +import com.codeit.team5.mopl.binarycontent.dto.UploadedBinaryContent; 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.CursorResponse; -import com.codeit.team5.mopl.global.dto.FileRequest; import com.codeit.team5.mopl.user.dto.request.ChangePasswordRequest; import com.codeit.team5.mopl.user.constant.UserSortBy; import com.codeit.team5.mopl.user.dto.request.UserCursorRequest; @@ -30,9 +28,9 @@ import com.codeit.team5.mopl.user.entity.UserRole; import com.codeit.team5.mopl.user.event.RoleChangedEvent; import com.codeit.team5.mopl.user.exception.DuplicatedEmailException; -import com.codeit.team5.mopl.user.exception.UserForbiddenException; import com.codeit.team5.mopl.user.exception.SameLockStatusException; import com.codeit.team5.mopl.user.exception.SameRoleAssignmentException; +import com.codeit.team5.mopl.user.exception.UserForbiddenException; import com.codeit.team5.mopl.user.exception.UserNotFoundException; import com.codeit.team5.mopl.user.mapper.UserMapper; import com.codeit.team5.mopl.user.repository.UserRepository; @@ -247,7 +245,7 @@ void update_nameOnly_success() { when(userMapper.toDto(user)).thenReturn(expected); // When - UserResponse result = userService.update(userId, userId, new UserUpdateRequest("새이름"), null); + UserResponse result = userService.update(userId, new UserUpdateRequest("새이름"), null); // Then assertThat(result).isSameAs(expected); @@ -262,43 +260,23 @@ void update_withImage_success() { // 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"); + UploadedBinaryContent uploaded = new UploadedBinaryContent("profiles/key", "http://localhost/profiles/key.jpg"); + BinaryContent saved = BinaryContent.completed("http://localhost/profiles/key.jpg"); UserResponse expected = new UserResponse( userId, Instant.parse("2026-06-25T00:00:00Z"), "user@example.com", "새이름", "http://localhost/profiles/key.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/key.jpg")); + when(binaryContentService.saveCompleted(uploaded)).thenReturn(saved); when(userMapper.toDto(user)).thenReturn(expected); // When - UserResponse result = userService.update(userId, userId, new UserUpdateRequest("새이름"), image); + UserResponse result = userService.update(userId, new UserUpdateRequest("새이름"), uploaded); // Then assertThat(result).isSameAs(expected); assertThat(user.getName()).isEqualTo("새이름"); - assertThat(user.getProfileImage()).isNotNull(); - 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); + assertThat(user.getProfileImage()).isSameAs(saved); } @Test @@ -309,18 +287,18 @@ void update_replaceImage_oldImageDeleted() { 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"); + UploadedBinaryContent uploaded = new UploadedBinaryContent("profiles/new", "http://localhost/profiles/new.jpg"); + BinaryContent newSaved = BinaryContent.completed("http://localhost/profiles/new.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(binaryContentService.saveCompleted(uploaded)).thenReturn(newSaved); when(userMapper.toDto(user)).thenReturn(expected); // When - userService.update(userId, userId, new UserUpdateRequest("새이름"), image); + userService.update(userId, new UserUpdateRequest("새이름"), uploaded); // Then assertThat(oldImage.getUploadStatus()).isEqualTo(BinaryContentUploadStatus.DELETED); @@ -335,25 +313,11 @@ void update_notFound_throwsException() { when(userRepository.findWithProfileImageById(userId)).thenReturn(Optional.empty()); // When & Then - assertThatThrownBy(() -> userService.update(userId, userId, new UserUpdateRequest("새이름"), null)) + assertThatThrownBy(() -> userService.update(userId, new UserUpdateRequest("새이름"), null)) .isInstanceOf(UserNotFoundException.class); verify(userRepository).findWithProfileImageById(userId); - verifyNoInteractions(userMapper, binaryContentService, eventPublisher); - } - - @Test - @DisplayName("본인이 아닌 사용자의 프로필 변경 실패") - void update_notOwner_throwsException() { - // Given - UUID currentUserId = UUID.randomUUID(); - UUID userId = UUID.randomUUID(); - - // When & Then - assertThatThrownBy(() -> userService.update(currentUserId, userId, new UserUpdateRequest("새이름"), null)) - .isInstanceOf(UserForbiddenException.class); - - verifyNoInteractions(userRepository, userMapper, binaryContentService, eventPublisher); + verifyNoInteractions(userMapper, eventPublisher); } @Test From d93b68b13d3c798cd2d1ceae6ac984db831c601a Mon Sep 17 00:00:00 2001 From: dustle Date: Fri, 3 Jul 2026 13:57:47 +0900 Subject: [PATCH 2/4] =?UTF-8?q?test:=20CodeRabbit=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ContentFacade update의 이미지 없음 케이스 테스트 추가 - UploadWithRollback 전용 단위테스트 추가 --- .../service/UploadWithRollbackTest.java | 97 +++++++++++++++++++ .../content/facade/ContentFacadeTest.java | 18 ++++ 2 files changed, 115 insertions(+) create mode 100644 src/test/java/com/codeit/team5/mopl/binarycontent/service/UploadWithRollbackTest.java diff --git a/src/test/java/com/codeit/team5/mopl/binarycontent/service/UploadWithRollbackTest.java b/src/test/java/com/codeit/team5/mopl/binarycontent/service/UploadWithRollbackTest.java new file mode 100644 index 00000000..9cf9c638 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/binarycontent/service/UploadWithRollbackTest.java @@ -0,0 +1,97 @@ +package com.codeit.team5.mopl.binarycontent.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.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.codeit.team5.mopl.binarycontent.dto.UploadedBinaryContent; +import com.codeit.team5.mopl.binarycontent.event.BinaryContentDeleteEvent; +import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; +import com.codeit.team5.mopl.global.dto.FileRequest; +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.context.ApplicationEventPublisher; + +@ExtendWith(MockitoExtension.class) +class UploadWithRollbackTest { + + @Mock + private BinaryContentService binaryContentService; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private UploadWithRollback uploadWithRollback; + + private final FileRequest image = new FileRequest(new byte[]{1, 2, 3}, "file.jpg"); + private final UploadedBinaryContent uploaded = + new UploadedBinaryContent("profiles/key.jpg", "http://localhost/profiles/key.jpg"); + + @Test + @DisplayName("이미지가 없으면 업로드 없이 후속 작업 실행 성공") + void execute_noImage_skipsUpload() { + // when + String result = uploadWithRollback.execute( + StorageDirectory.PROFILE, null, u -> u == null ? "null" : "not-null"); + + // then + assertThat(result).isEqualTo("null"); + verify(binaryContentService, never()).uploadToStorage(any(), any()); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("업로드와 후속 작업 성공 시 이벤트 미발행 성공") + void execute_success_noEvent() { + // given + when(binaryContentService.uploadToStorage(eq(StorageDirectory.PROFILE), any())).thenReturn(uploaded); + + // when + String result = uploadWithRollback.execute( + StorageDirectory.PROFILE, image, u -> "done:" + u.key()); + + // then + assertThat(result).isEqualTo("done:profiles/key.jpg"); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("후속 작업 실패 시 삭제 이벤트 발행 후 예외 전파 성공") + void execute_persistFails_publishesDeleteEvent() { + // given + when(binaryContentService.uploadToStorage(eq(StorageDirectory.PROFILE), any())).thenReturn(uploaded); + + // when & then + assertThatThrownBy(() -> uploadWithRollback.execute( + StorageDirectory.PROFILE, image, u -> { + throw new RuntimeException("persist fail"); + })) + .isInstanceOf(RuntimeException.class); + + verify(eventPublisher).publishEvent(new BinaryContentDeleteEvent(uploaded)); + } + + @Test + @DisplayName("업로드 실패 시 이벤트 미발행하고 예외 전파 성공") + void execute_uploadFails_noEvent() { + // given + when(binaryContentService.uploadToStorage(eq(StorageDirectory.PROFILE), any())) + .thenThrow(new RuntimeException("upload fail")); + + // when & then + assertThatThrownBy(() -> uploadWithRollback.execute( + StorageDirectory.PROFILE, image, u -> "never")) + .isInstanceOf(RuntimeException.class); + + verify(eventPublisher, never()).publishEvent(any()); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/content/facade/ContentFacadeTest.java b/src/test/java/com/codeit/team5/mopl/content/facade/ContentFacadeTest.java index bea9117d..69cf94c3 100644 --- a/src/test/java/com/codeit/team5/mopl/content/facade/ContentFacadeTest.java +++ b/src/test/java/com/codeit/team5/mopl/content/facade/ContentFacadeTest.java @@ -123,9 +123,27 @@ void update_withImage_success() { // then assertThat(result).isSameAs(expected); + verify(binaryContentService).uploadToStorage(eq(StorageDirectory.THUMBNAIL), any()); verify(eventPublisher, never()).publishEvent(any(BinaryContentDeleteEvent.class)); } + @Test + @DisplayName("이미지가 없으면 업로드 없이 수정 성공") + void update_noImage_success() { + // given + UUID contentId = UUID.randomUUID(); + ContentResponse expected = response(); + when(contentService.update(eq(contentId), eq(updateRequest), isNull())).thenReturn(expected); + + // when + ContentResponse result = contentFacade.update(contentId, updateRequest, null); + + // then + assertThat(result).isSameAs(expected); + verify(contentService).update(eq(contentId), eq(updateRequest), isNull()); + verify(binaryContentService, never()).uploadToStorage(any(), any()); + } + @Test @DisplayName("수정 실패 시 보상 삭제 후 예외 전파 성공") void update_serviceFails_compensates() { From 78ab8d3fb5b519b3fdf7bf8231348416deaf851f Mon Sep 17 00:00:00 2001 From: dustle Date: Fri, 3 Jul 2026 15:08:15 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20-=20=EB=8D=B0=EB=93=9C=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20=EB=B3=B4=EC=83=81=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EC=A0=84=EC=9A=A9=20=EC=8A=A4=EB=A0=88=EB=93=9C=ED=92=80,?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=B3=80=ED=99=98=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BinaryContentDeleteEventListener.java | 2 +- .../service/BinaryContentService.java | 7 ---- .../codeit/team5/mopl/config/AsyncConfig.java | 13 +++++++ .../mopl/user/controller/UserController.java | 4 +- .../service/BinaryContentServiceTest.java | 38 ------------------- 5 files changed, 15 insertions(+), 49 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/eventlistener/BinaryContentDeleteEventListener.java b/src/main/java/com/codeit/team5/mopl/binarycontent/eventlistener/BinaryContentDeleteEventListener.java index 0b38c6eb..5a7d5158 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/eventlistener/BinaryContentDeleteEventListener.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/eventlistener/BinaryContentDeleteEventListener.java @@ -13,7 +13,7 @@ public class BinaryContentDeleteEventListener { private final BinaryContentService binaryContentService; - @Async + @Async("binaryContentTaskExecutor") @EventListener public void handle(BinaryContentDeleteEvent event) { binaryContentService.deleteQuietly(event.uploaded()); 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 c5f72a1e..f09737a4 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 @@ -22,13 +22,6 @@ public class BinaryContentService { private final StorageKeyFactory storageKeyFactory; private final BinaryContentRepository binaryContentRepository; - public BinaryContent upload(StorageDirectory directory, FileRequest image) { - GeneratedKey generated = storageKeyFactory.generate(directory, image.filename()); - binaryContentStorage.store(generated.key(), image.bytes(), generated.contentType()); - return binaryContentRepository.save( - BinaryContent.completed(binaryContentStorage.toUrl(generated.key()))); - } - public UploadedBinaryContent uploadToStorage(StorageDirectory directory, FileRequest image) { GeneratedKey generated = storageKeyFactory.generate(directory, image.filename()); binaryContentStorage.store(generated.key(), image.bytes(), generated.contentType()); diff --git a/src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java b/src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java index 429b1363..a5a50e3d 100644 --- a/src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java +++ b/src/main/java/com/codeit/team5/mopl/config/AsyncConfig.java @@ -35,4 +35,17 @@ public ThreadPoolTaskExecutor batchJobTaskExecutor() { executor.initialize(); return executor; } + + @Bean + public ThreadPoolTaskExecutor binaryContentTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(1); + executor.setMaxPoolSize(2); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("bin-delete-"); + executor.setTaskDecorator(new MdcTaskDecorator()); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } } diff --git a/src/main/java/com/codeit/team5/mopl/user/controller/UserController.java b/src/main/java/com/codeit/team5/mopl/user/controller/UserController.java index 33048efb..2568b893 100644 --- a/src/main/java/com/codeit/team5/mopl/user/controller/UserController.java +++ b/src/main/java/com/codeit/team5/mopl/user/controller/UserController.java @@ -73,9 +73,7 @@ public ResponseEntity updateUser( ) { log.info("User update request: PATCH /api/users/{}", userId); - FileRequest imageRequest = (image != null && !image.isEmpty()) - ? MultipartFiles.toImageResource(image) - : null; + FileRequest imageRequest = MultipartFiles.toImageResource(image); UserResponse response = userProfileFacade.updateProfile( userDetails.getId(), 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 fdbab113..06774666 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 @@ -1,7 +1,6 @@ package com.codeit.team5.mopl.binarycontent.service; 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; @@ -41,43 +40,6 @@ class BinaryContentServiceTest { @InjectMocks private BinaryContentService binaryContentService; - @Test - @DisplayName("이미지 업로드 후 COMPLETED 상태로 저장에 성공한다") - void upload_success() { - // given - FileRequest image = new FileRequest(new byte[]{1, 2, 3}, "profile.jpg"); - when(storageKeyFactory.generate(eq(StorageDirectory.PROFILE), 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 - BinaryContent result = binaryContentService.upload(StorageDirectory.PROFILE, image); - - // 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); - } - - @Test - @DisplayName("스토리지 업로드 실패 시 예외를 전파하고 저장하지 않는다") - void upload_storeFails_throwsAndDoesNotSave() { - // given - FileRequest image = new FileRequest(new byte[]{1, 2, 3}, "profile.jpg"); - when(storageKeyFactory.generate(eq(StorageDirectory.PROFILE), 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.upload(StorageDirectory.PROFILE, image)) - .isInstanceOf(RuntimeException.class); - verify(binaryContentRepository, never()).save(any()); - } - @Test @DisplayName("스토리지에만 업로드하고 DB 미저장 성공") void uploadToStorage_success() { From 7b93f97f29962fd1c1743912a6f42fb43973af4c Mon Sep 17 00:00:00 2001 From: dustle Date: Fri, 3 Jul 2026 15:30:57 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20CodeRabbit=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20-=20S3=20=ED=83=80=EC=9E=84=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EB=8C=80=EC=86=8C=20=EA=B2=80=EC=A6=9D,=20?= =?UTF-8?q?=EB=A1=9C=EC=BB=AC=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../storage/local/LocalBinaryContentStorage.java | 9 +++++++-- .../binarycontent/storage/s3/S3StorageProperties.java | 5 +++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java index e9fd22aa..f1af1996 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/local/LocalBinaryContentStorage.java @@ -45,12 +45,17 @@ public void store(String key, byte[] bytes, String contentType) { @Override public void delete(String key) { Path destination = uploadDir.resolve(key); + boolean deleted; try { - Files.deleteIfExists(destination); + deleted = Files.deleteIfExists(destination); } catch (IOException e) { throw new FileStorageException(key, e); } - log.debug("파일 삭제 완료: {}", destination); + if (deleted) { + log.debug("파일 삭제 완료: {}", destination); + } else { + log.debug("삭제 대상 파일 없음: {}", destination); + } } private void init() { diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java index 96cc3e62..5becd6d1 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3StorageProperties.java @@ -20,5 +20,10 @@ public record S3StorageProperties( if (apiCallAttemptTimeout == null) { apiCallAttemptTimeout = Duration.ofSeconds(5); } + if (apiCallAttemptTimeout.compareTo(apiCallTimeout) > 0) { + throw new IllegalArgumentException( + "apiCallAttemptTimeout(" + apiCallAttemptTimeout + + ")은 apiCallTimeout(" + apiCallTimeout + ")보다 클 수 없습니다."); + } } }