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..5a7d5158 --- /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("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 28e63151..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 @@ -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,39 @@ 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 UploadedBinaryContent uploadToStorage(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()))); + 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..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 @@ -42,6 +42,22 @@ public void store(String key, byte[] bytes, String contentType) { log.debug("파일 저장 완료: {}", destination); } + @Override + public void delete(String key) { + Path destination = uploadDir.resolve(key); + boolean deleted; + try { + deleted = Files.deleteIfExists(destination); + } catch (IOException e) { + throw new FileStorageException(key, e); + } + if (deleted) { + log.debug("파일 삭제 완료: {}", destination); + } else { + 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..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 @@ -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,22 @@ 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); + } + if (apiCallAttemptTimeout.compareTo(apiCallTimeout) > 0) { + throw new IllegalArgumentException( + "apiCallAttemptTimeout(" + apiCallAttemptTimeout + + ")은 apiCallTimeout(" + apiCallTimeout + ")보다 클 수 없습니다."); + } + } } 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/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..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 @@ -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,18 @@ 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 = MultipartFiles.toImageResource(image); + + UserResponse response = userProfileFacade.updateProfile( + userDetails.getId(), + userId, + request, + imageRequest + ); return ResponseEntity.ok(response); } @@ -78,7 +89,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 +102,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 +127,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..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; @@ -10,6 +9,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 +18,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; @@ -42,41 +41,77 @@ class BinaryContentServiceTest { private BinaryContentService binaryContentService; @Test - @DisplayName("이미지 업로드 후 COMPLETED 상태로 저장에 성공한다") - void upload_success() { + @DisplayName("스토리지에만 업로드하고 DB 미저장 성공") + void uploadToStorage_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); + 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 upload_storeFails_throwsAndDoesNotSave() { + @DisplayName("보상 삭제로 스토리지 객체 삭제 성공") + void deleteQuietly_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"))) - .thenReturn(new GeneratedKey("profiles/key.jpg", "image/jpeg")); - doThrow(new RuntimeException("S3 연결 실패")) - .when(binaryContentStorage).store(any(), any(), any()); + 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 - assertThatThrownBy(() -> binaryContentService.upload(StorageDirectory.PROFILE, ownerId, image)) - .isInstanceOf(RuntimeException.class); - verify(binaryContentRepository, never()).save(any()); + binaryContentService.deleteQuietly(uploaded); + verify(binaryContentStorage).delete("profiles/key.jpg"); } } 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/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..69cf94c3 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/content/facade/ContentFacadeTest.java @@ -0,0 +1,161 @@ +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(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() { + // 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