diff --git a/build.gradle b/build.gradle index 5324be93..88bc6db7 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,9 @@ dependencies { implementation 'org.flywaydb:flyway-database-postgresql' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.17' implementation 'org.mapstruct:mapstruct:1.6.3' + // AWS SDK (S3) + implementation platform('software.amazon.awssdk:bom:2.46.7') + implementation 'software.amazon.awssdk:s3' // Spring security implementation 'org.springframework.boot:spring-boot-starter-security' // JWT 기능을 위한 jjwt 라이브러리 diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/BinaryContentStorage.java b/src/main/java/com/codeit/team5/mopl/binarycontent/BinaryContentStorage.java deleted file mode 100644 index b1f88d92..00000000 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/BinaryContentStorage.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.codeit.team5.mopl.binarycontent; - -import java.util.Locale; -import java.util.Set; -import java.util.UUID; -import org.springframework.util.StringUtils; - -public interface BinaryContentStorage { - - Set ALLOWED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif", "webp"); - - default String generateKey(UUID contentId, String originalFilename) { - String extension = StringUtils.getFilenameExtension(originalFilename); - String lowerExt = extension != null ? extension.toLowerCase(Locale.ROOT) : null; - String normalizedExt = (lowerExt != null && ALLOWED_EXTENSIONS.contains(lowerExt)) - ? "." + lowerExt - : ""; - return "thumbnails/" + contentId + "/" + UUID.randomUUID() + normalizedExt; - } - - String toUrl(String key); - - void store(String key, byte[] bytes); -} diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/ImageExtension.java b/src/main/java/com/codeit/team5/mopl/binarycontent/ImageExtension.java new file mode 100644 index 00000000..d6de6571 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/ImageExtension.java @@ -0,0 +1,37 @@ +package com.codeit.team5.mopl.binarycontent; + +import java.util.Arrays; +import java.util.Locale; +import java.util.Optional; + +/** + * 허용 이미지 확장자와 그에 대응하는 Content-Type을 한 곳에서 관리한다. + * 확장자를 추가하려면 이 enum에 상수만 추가하면 된다. + */ +public enum ImageExtension { + JPG("image/jpeg"), + JPEG("image/jpeg"), + PNG("image/png"), + GIF("image/gif"), + WEBP("image/webp"); + + private final String contentType; + + ImageExtension(String contentType) { + this.contentType = contentType; + } + + public String contentType() { + return contentType; + } + + public static Optional from(String extension) { + if (extension == null || extension.isBlank()) { + return Optional.empty(); + } + String normalized = extension.toLowerCase(Locale.ROOT); + return Arrays.stream(values()) + .filter(e -> e.name().toLowerCase(Locale.ROOT).equals(normalized)) + .findFirst(); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java b/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java index c9f56729..cfdabcdb 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/event/BinaryContentUploadEvent.java @@ -5,6 +5,7 @@ public record BinaryContentUploadEvent( UUID binaryContentId, String key, - byte[] bytes + byte[] bytes, + String contentType ) { } diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/exception/InvalidImageExtensionException.java b/src/main/java/com/codeit/team5/mopl/binarycontent/exception/InvalidImageExtensionException.java new file mode 100644 index 00000000..8a4e15b6 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/exception/InvalidImageExtensionException.java @@ -0,0 +1,11 @@ +package com.codeit.team5.mopl.binarycontent.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +public class InvalidImageExtensionException extends BusinessException { + + public InvalidImageExtensionException(String extension) { + super(HttpStatus.BAD_REQUEST, "허용되지 않는 이미지 확장자입니다: " + extension); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java b/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java index 72dd9ce8..c848d5f6 100644 --- a/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListener.java @@ -1,6 +1,6 @@ package com.codeit.team5.mopl.binarycontent.listener; -import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; import com.codeit.team5.mopl.binarycontent.service.BinaryContentService; @@ -23,7 +23,7 @@ public class BinaryContentUploadListener { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(BinaryContentUploadEvent event) { try { - binaryContentStorage.store(event.key(), event.bytes()); + binaryContentStorage.store(event.key(), event.bytes(), event.contentType()); binaryContentService.updateUploadStatus(event.binaryContentId(), BinaryContentUploadStatus.COMPLETED); log.debug("파일 업로드 완료 - binaryContentId: {}", event.binaryContentId()); } catch (Exception 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 new file mode 100644 index 00000000..1185de47 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/BinaryContentStorage.java @@ -0,0 +1,8 @@ +package com.codeit.team5.mopl.binarycontent.storage; + +public interface BinaryContentStorage { + + String toUrl(String key); + + void store(String key, byte[] bytes, String contentType); +} diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/GeneratedKey.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/GeneratedKey.java new file mode 100644 index 00000000..f0726c3c --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/GeneratedKey.java @@ -0,0 +1,10 @@ +package com.codeit.team5.mopl.binarycontent.storage; + +/** + * 저장소 키와 그에 대응하는 Content-Type을 함께 전달하는 값 객체. + */ +public record GeneratedKey( + String key, + String contentType +) { +} diff --git a/src/main/java/com/codeit/team5/mopl/binarycontent/storage/StorageDirectory.java b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/StorageDirectory.java new file mode 100644 index 00000000..bd1cb9ec --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/StorageDirectory.java @@ -0,0 +1,19 @@ +package com.codeit.team5.mopl.binarycontent.storage; + +/** + * 저장소 키의 최상위 폴더(prefix). 용도별로 추가하려면 상수만 추가하면 된다. + */ +public enum StorageDirectory { + THUMBNAIL("thumbnails"), + PROFILE("profiles"); + + private final String value; + + StorageDirectory(String value) { + this.value = value; + } + + public String value() { + return value; + } +} 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 new file mode 100644 index 00000000..009a3025 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/storage/StorageKeyFactory.java @@ -0,0 +1,25 @@ +package com.codeit.team5.mopl.binarycontent.storage; + +import com.codeit.team5.mopl.binarycontent.ImageExtension; +import com.codeit.team5.mopl.binarycontent.exception.InvalidImageExtensionException; +import java.util.Locale; +import java.util.UUID; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** + * 저장소 키 생성 책임을 담당한다. + * 확장자 검증 → 폴더 prefix/소유자/난수 조합 → Content-Type 결정을 한 곳에서 처리한다. + */ +@Component +public class StorageKeyFactory { + + public GeneratedKey generate(StorageDirectory directory, UUID ownerId, String originalFilename) { + String extension = StringUtils.getFilenameExtension(originalFilename); + ImageExtension imageExtension = ImageExtension.from(extension) + .orElseThrow(() -> new InvalidImageExtensionException(extension)); + String key = directory.value() + "/" + ownerId + "/" + 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 cf4dfa8e..2b66ea8e 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 @@ -1,6 +1,6 @@ package com.codeit.team5.mopl.binarycontent.storage.local; -import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; import com.codeit.team5.mopl.binarycontent.exception.FileStorageException; import com.codeit.team5.mopl.binarycontent.exception.UploadDirectoryInitException; import java.io.IOException; @@ -31,7 +31,7 @@ public String toUrl(String key) { } @Override - public void store(String key, byte[] bytes) { + public void store(String key, byte[] bytes, String contentType) { Path destination = uploadDir.resolve(key); try { Files.createDirectories(destination.getParent()); 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 ee7e30fb..9fb469b1 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 @@ -1,17 +1,27 @@ package com.codeit.team5.mopl.binarycontent.storage.s3; -import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.exception.BinaryContentStorageException; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; +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.PutObjectRequest; +@Slf4j @Component @ConditionalOnProperty(name = "mopl.storage.type", havingValue = "s3") public class S3BinaryContentStorage implements BinaryContentStorage { private final S3StorageProperties properties; + private final S3Client s3Client; - public S3BinaryContentStorage(S3StorageProperties properties) { + public S3BinaryContentStorage(S3StorageProperties properties, S3Client s3Client) { this.properties = properties; + this.s3Client = s3Client; } @Override @@ -20,7 +30,19 @@ public String toUrl(String key) { } @Override - public void store(String key, byte[] bytes) { - throw new UnsupportedOperationException("S3 스토리지가 아직 구현되지 않았습니다."); + public void store(String key, byte[] bytes, String contentType) { + try { + s3Client.putObject( + PutObjectRequest.builder() + .bucket(properties.bucketName()) + .key(key) + .contentType(contentType) + .build(), + RequestBody.fromBytes(bytes)); + log.debug("S3 업로드 완료: key={}, contentType={}", key, contentType); + } 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/support/MultipartFiles.java b/src/main/java/com/codeit/team5/mopl/binarycontent/support/MultipartFiles.java new file mode 100644 index 00000000..af102226 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/binarycontent/support/MultipartFiles.java @@ -0,0 +1,36 @@ +package com.codeit.team5.mopl.binarycontent.support; + +import com.codeit.team5.mopl.binarycontent.ImageExtension; +import com.codeit.team5.mopl.binarycontent.exception.BinaryContentStorageException; +import com.codeit.team5.mopl.binarycontent.exception.InvalidImageExtensionException; +import com.codeit.team5.mopl.global.dto.FileRequest; +import java.io.IOException; +import lombok.experimental.UtilityClass; +import org.springframework.http.HttpStatus; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +/** + * MultipartFile(web 타입)을 서비스 계층용 {@link FileRequest}로 변환한다. + * 변환 시점에 확장자를 검증(fail-fast)하고 바이트를 읽어, web/IO 의존을 컨트롤러 경계에서 차단한다. + */ +@UtilityClass +public class MultipartFiles { + + public FileRequest toImageResource(MultipartFile file) { + if (file == null || file.isEmpty()) { + return null; + } + String extension = StringUtils.getFilenameExtension(file.getOriginalFilename()); + if (ImageExtension.from(extension).isEmpty()) { + throw new InvalidImageExtensionException(extension); + } + try { + return new FileRequest(file.getBytes(), file.getOriginalFilename()); + } catch (IOException e) { + throw new BinaryContentStorageException( + HttpStatus.INTERNAL_SERVER_ERROR, + "파일을 읽을 수 없습니다: " + file.getOriginalFilename(), e); + } + } +} 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 2ad32ec1..3bb84dbe 100644 --- a/src/main/java/com/codeit/team5/mopl/config/StorageConfig.java +++ b/src/main/java/com/codeit/team5/mopl/config/StorageConfig.java @@ -2,11 +2,44 @@ import com.codeit.team5.mopl.binarycontent.storage.local.LocalStorageProperties; import com.codeit.team5.mopl.binarycontent.storage.s3.S3StorageProperties; +import java.util.Objects; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +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.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; @Configuration @EnableConfigurationProperties({LocalStorageProperties.class, S3StorageProperties.class}) public class StorageConfig { + // mopl.storage.type=s3 일 때만 S3Client 빈 생성 + @Bean + @ConditionalOnProperty(name = "mopl.storage.type", havingValue = "s3") + public S3Client s3Client(S3StorageProperties props) { + String region = Objects.requireNonNull(props.region(), "mopl.storage.s3.region 설정이 필요합니다"); + + String accessKey = props.accessKey(); + String secretKey = props.secretKey(); + + boolean hasAccessKey = accessKey != null && !accessKey.isBlank(); + boolean hasSecretKey = secretKey != null && !secretKey.isBlank(); + if (hasAccessKey != hasSecretKey) { + throw new IllegalStateException("accessKey와 secretKey는 함께 설정해야 합니다"); + } + + AwsCredentialsProvider credentialsProvider = hasAccessKey + ? StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)) + : DefaultCredentialsProvider.builder().build(); // prod: ECS task role 자격증명 + + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(credentialsProvider) + .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 dcacec44..8a5a4d49 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 @@ -4,6 +4,7 @@ import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest; import com.codeit.team5.mopl.content.dto.response.ContentResponse; import com.codeit.team5.mopl.content.service.ContentService; +import com.codeit.team5.mopl.binarycontent.support.MultipartFiles; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -31,7 +32,7 @@ public ResponseEntity postContent( @RequestPart(value = "thumbnail", required = false) MultipartFile thumbnail ) { log.info("Content Create request: POST /api/contents"); - ContentResponse response = contentService.create(request, thumbnail); + ContentResponse response = contentService.create(request, MultipartFiles.toImageResource(thumbnail)); return ResponseEntity.status(HttpStatus.CREATED).body(response); } } 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 6fc9c29a..128baa14 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,6 +1,9 @@ package com.codeit.team5.mopl.content.service; -import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.storage.GeneratedKey; +import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; +import com.codeit.team5.mopl.binarycontent.storage.StorageKeyFactory; import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; @@ -13,9 +16,9 @@ import com.codeit.team5.mopl.content.repository.ContentRepository; import com.codeit.team5.mopl.content.repository.ContentStatsRepository; import com.codeit.team5.mopl.content.entity.ContentTag; +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.io.IOException; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -25,7 +28,6 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; @Service @Transactional(readOnly = true) @@ -38,27 +40,25 @@ public class ContentService { private final TagRepository tagRepository; private final ContentMapper contentMapper; private final BinaryContentStorage binaryContentStorage; + private final StorageKeyFactory storageKeyFactory; private final BinaryContentRepository binaryContentRepository; private final ApplicationEventPublisher eventPublisher; @Transactional - public ContentResponse create(ContentCreateRequest request, MultipartFile thumbnail) { + public ContentResponse create(ContentCreateRequest request, FileRequest thumbnail) { Content content = contentRepository.save(Content.createByAdmin( request.type(), request.title(), request.description() )); - if (thumbnail != null && !thumbnail.isEmpty()) { - try { - String key = binaryContentStorage.generateKey(content.getId(), thumbnail.getOriginalFilename()); - BinaryContent binaryContent = binaryContentRepository.save( - BinaryContent.pending(binaryContentStorage.toUrl(key))); - content.attachThumbnail(binaryContent); - eventPublisher.publishEvent(new BinaryContentUploadEvent(binaryContent.getId(), key, thumbnail.getBytes())); - } catch (IOException e) { - log.warn("썸네일 바이트 읽기 실패 - contentId: {}", content.getId(), e); - } + if (thumbnail != null) { + GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.THUMBNAIL, content.getId(), thumbnail.filename()); + BinaryContent binaryContent = binaryContentRepository.save( + BinaryContent.pending(binaryContentStorage.toUrl(generated.key()))); + content.attachThumbnail(binaryContent); + eventPublisher.publishEvent(new BinaryContentUploadEvent( + binaryContent.getId(), generated.key(), thumbnail.bytes(), generated.contentType())); } attachTags(content, request.tags()); diff --git a/src/main/java/com/codeit/team5/mopl/global/dto/FileRequest.java b/src/main/java/com/codeit/team5/mopl/global/dto/FileRequest.java new file mode 100644 index 00000000..8c1700c2 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/global/dto/FileRequest.java @@ -0,0 +1,37 @@ +package com.codeit.team5.mopl.global.dto; + +import java.util.Arrays; +import java.util.Objects; + +/** + * 업로드 파일을 웹 계층(MultipartFile)에서 분리한 순수 데이터 홀더. + * 서비스 계층이 web 타입에 의존하지 않도록 컨트롤러에서 변환하여 전달한다. + * Content-Type은 클라이언트 값을 신뢰하지 않고 저장 시점에 확장자로 결정한다. + */ +public record FileRequest( + byte[] bytes, + String filename +) { + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof FileRequest other)) { + return false; + } + return Arrays.equals(bytes, other.bytes) && Objects.equals(filename, other.filename); + } + + @Override + public int hashCode() { + return Objects.hash(Arrays.hashCode(bytes), filename); + } + + @Override + public String toString() { + return "FileRequest{filename='" + filename + "', size=" + + (bytes != null ? bytes.length : 0) + "}"; + } +} 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 46a0102e..c669e45d 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 @@ -1,7 +1,9 @@ package com.codeit.team5.mopl.user.controller; +import com.codeit.team5.mopl.binarycontent.support.MultipartFiles; import com.codeit.team5.mopl.user.controller.api.UserApi; import com.codeit.team5.mopl.user.dto.request.UserRegisterRequest; +import com.codeit.team5.mopl.user.dto.request.UserUpdateRequest; import com.codeit.team5.mopl.user.dto.response.UserResponse; import com.codeit.team5.mopl.user.service.UserService; import jakarta.validation.Valid; @@ -9,13 +11,17 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/api/users") @@ -44,4 +50,17 @@ public ResponseEntity getUser(@PathVariable UUID userId) { return ResponseEntity.ok(response); } + + @Override + @PatchMapping(value = "/{userId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity updateUser( + @PathVariable UUID userId, + @Valid @RequestPart("request") UserUpdateRequest request, + @RequestPart(value = "image", required = false) MultipartFile image) { + log.info("User update request: PATCH /api/users/{}", userId); + + UserResponse response = userService.update(userId, request, MultipartFiles.toImageResource(image)); + + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/codeit/team5/mopl/user/controller/api/UserApi.java b/src/main/java/com/codeit/team5/mopl/user/controller/api/UserApi.java index 25428d52..d212dff4 100644 --- a/src/main/java/com/codeit/team5/mopl/user/controller/api/UserApi.java +++ b/src/main/java/com/codeit/team5/mopl/user/controller/api/UserApi.java @@ -2,19 +2,23 @@ import com.codeit.team5.mopl.global.dto.suggestion.ErrorResponseSuggestion; import com.codeit.team5.mopl.user.dto.request.UserRegisterRequest; +import com.codeit.team5.mopl.user.dto.request.UserUpdateRequest; import com.codeit.team5.mopl.user.dto.response.UserResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Encoding; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import java.util.UUID; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.multipart.MultipartFile; @Tag(name = "사용자 관리", description = "사용자 관련 API") public interface UserApi { @@ -46,4 +50,36 @@ ResponseEntity registerUser( ResponseEntity getUser( @Parameter(description = "사용자 ID", required = true) @PathVariable UUID userId); + + @Schema(name = "UserUpdateMultipartRequest") + class UpdateMultipartRequest { + @Schema(implementation = UserUpdateRequest.class) + public UserUpdateRequest request; + + @Schema(type = "string", format = "binary", description = "프로필 이미지") + public MultipartFile image; + } + + @Operation(summary = "프로필 변경", description = "본인의 프로필(이름/이미지)을 변경합니다. 본인만 변경할 수 있습니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공", + content = @Content(schema = @Schema(implementation = UserResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "401", description = "인증 오류", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "403", description = "권한 오류(본인만 변경 가능)", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))) + }) + @io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content( + mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + schema = @Schema(implementation = UpdateMultipartRequest.class), + encoding = @Encoding(name = "request", contentType = MediaType.APPLICATION_JSON_VALUE) + )) + ResponseEntity updateUser( + @Parameter(description = "사용자 ID", required = true) @PathVariable UUID userId, + @Parameter(hidden = true) UserUpdateRequest request, + @Parameter(hidden = true) MultipartFile image); } diff --git a/src/main/java/com/codeit/team5/mopl/user/dto/request/UserUpdateRequest.java b/src/main/java/com/codeit/team5/mopl/user/dto/request/UserUpdateRequest.java new file mode 100644 index 00000000..a9d342ef --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/user/dto/request/UserUpdateRequest.java @@ -0,0 +1,11 @@ +package com.codeit.team5.mopl.user.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UserUpdateRequest( + @NotBlank + @Size(max = 100) + String name +) { +} diff --git a/src/main/java/com/codeit/team5/mopl/user/entity/User.java b/src/main/java/com/codeit/team5/mopl/user/entity/User.java index a90ebb48..6e7710f8 100644 --- a/src/main/java/com/codeit/team5/mopl/user/entity/User.java +++ b/src/main/java/com/codeit/team5/mopl/user/entity/User.java @@ -60,11 +60,14 @@ public static User create( return user; } - public void updateProfile(String newName, BinaryContent profileImage) { + public void updateName(String newName) { if (newName == null || newName.isBlank()) { throw new InvalidUsernameException(); } this.name = newName; + } + + public void updateProfileImage(BinaryContent profileImage) { this.profileImage = profileImage; } @@ -89,4 +92,4 @@ public void updateLocked(boolean locked) { } this.locked = locked; } -} \ No newline at end of file +} 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 9a320fd3..6a0f5e60 100644 --- a/src/main/java/com/codeit/team5/mopl/user/service/UserService.java +++ b/src/main/java/com/codeit/team5/mopl/user/service/UserService.java @@ -1,6 +1,15 @@ package com.codeit.team5.mopl.user.service; +import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.storage.GeneratedKey; +import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; +import com.codeit.team5.mopl.binarycontent.storage.StorageKeyFactory; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; +import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; +import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; +import com.codeit.team5.mopl.global.dto.FileRequest; import com.codeit.team5.mopl.user.dto.request.UserRegisterRequest; +import com.codeit.team5.mopl.user.dto.request.UserUpdateRequest; import com.codeit.team5.mopl.user.dto.response.UserResponse; import com.codeit.team5.mopl.user.entity.User; import com.codeit.team5.mopl.user.exception.DuplicatedEmailException; @@ -11,6 +20,7 @@ import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,6 +34,10 @@ public class UserService { private final UserRepository userRepository; private final UserMapper userMapper; private final PasswordEncoder passwordEncoder; + private final BinaryContentStorage binaryContentStorage; + private final StorageKeyFactory storageKeyFactory; + private final BinaryContentRepository binaryContentRepository; + private final ApplicationEventPublisher eventPublisher; @Transactional public UserResponse create(UserRegisterRequest request) { @@ -53,6 +67,34 @@ public UserResponse getById(UUID userId) { return userMapper.toDto(user); } + @Transactional + public UserResponse update(UUID userId, UserUpdateRequest request, FileRequest image) { + User user = getUser(userId); + + // TODO: 인증 구현 후 본인 확인(현재 로그인 사용자 == userId) 추가, 불일치 시 403 + + user.updateName(request.name()); + if (image != null) { + user.updateProfileImage(storeProfileImage(user.getId(), image)); + } + + log.info("User updated: userId={}", userId); + return userMapper.toDto(user); + } + + private BinaryContent storeProfileImage(UUID userId, FileRequest image) { + // TODO(고아 정리): 기존 profileImage를 삭제 대상 상태로 표시 후 배치로 정리 (DB/S3 누적 방지) + GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.PROFILE, userId, image.filename()); + BinaryContent profileImage = binaryContentRepository.save( + BinaryContent.pending(binaryContentStorage.toUrl(generated.key()))); + + eventPublisher.publishEvent( + new BinaryContentUploadEvent(profileImage.getId(), generated.key(), image.bytes(), generated.contentType())); + // TODO(업로드 실패 대응): 비동기 업로드 실패 시 보상 트랜잭션으로 프로필 이미지 롤백 + + return profileImage; + } + private User getUser(UUID userId) { return userRepository.findById(userId) .orElseThrow(() -> { diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index c26a585c..dd991981 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -44,12 +44,8 @@ spring: resources: static-locations: classpath:/static/, file:./uploads/ -# 이미지 저장소 전략 (구현체는 mopl.storage.type 값으로 선택) -# - local: 로컬/테스트용 저장 -# - s3 : 배포용 S3 저장 mopl: storage: - type: ${STORAGE_TYPE:local} # 기본값: 로컬 local: base-url: http://localhost:8080 upload-dir: ./uploads diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 11c49046..f0e93291 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -54,7 +54,7 @@ mopl: storage: type: s3 s3: - bucket: ${S3_BUCKET:mopl-bucket-433041915727} + bucket-name: ${S3_BUCKET:mopl-bucket-433041915727} region: ${AWS_REGION:ap-northeast-2} logging: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5652531b..6d6aa5f2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,11 +27,11 @@ management: show-details: when-authorized # 이미지 저장소 전략 (구현체는 mopl.storage.type 값으로 선택) -# - memory: 로컬/테스트용 인메모리 저장 +# - local: 로컬/테스트용 저장 # - s3 : 배포용 S3 저장 mopl: storage: - type: ${MOPL_STORAGE_TYPE:memory} # 기본값: 인메모리 + type: ${MOPL_STORAGE_TYPE:local} # 기본값: 로컬 jwt: access-secret-key: ${JWT_ACCESS_SECRET_KEY} diff --git a/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java b/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java index 3920fa38..ab06eac8 100644 --- a/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java +++ b/src/test/java/com/codeit/team5/mopl/binarycontent/listener/BinaryContentUploadListenerTest.java @@ -5,7 +5,7 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; -import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus; import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; import com.codeit.team5.mopl.binarycontent.service.BinaryContentService; @@ -34,13 +34,13 @@ class BinaryContentUploadListenerTest { void handle_uploadSuccess_updatesCompleted() { // given UUID binaryContentId = UUID.randomUUID(); - BinaryContentUploadEvent event = new BinaryContentUploadEvent(binaryContentId, "thumbnails/test.jpg", new byte[]{1, 2, 3}); + BinaryContentUploadEvent event = new BinaryContentUploadEvent(binaryContentId, "thumbnails/test.jpg", new byte[]{1, 2, 3}, "image/jpeg"); // when listener.handle(event); // then - verify(binaryContentStorage).store("thumbnails/test.jpg", event.bytes()); + verify(binaryContentStorage).store("thumbnails/test.jpg", event.bytes(), "image/jpeg"); verify(binaryContentService).updateUploadStatus(binaryContentId, BinaryContentUploadStatus.COMPLETED); } @@ -49,8 +49,8 @@ void handle_uploadSuccess_updatesCompleted() { void handle_uploadFails_updatesFailed() { // given UUID binaryContentId = UUID.randomUUID(); - BinaryContentUploadEvent event = new BinaryContentUploadEvent(binaryContentId, "thumbnails/test.jpg", new byte[]{1, 2, 3}); - doThrow(new RuntimeException("S3 연결 실패")).when(binaryContentStorage).store(any(), any()); + BinaryContentUploadEvent event = new BinaryContentUploadEvent(binaryContentId, "thumbnails/test.jpg", new byte[]{1, 2, 3}, "image/jpeg"); + doThrow(new RuntimeException("S3 연결 실패")).when(binaryContentStorage).store(any(), any(), any()); // when listener.handle(event); 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 new file mode 100644 index 00000000..eeb438bc --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/binarycontent/storage/StorageKeyFactoryTest.java @@ -0,0 +1,79 @@ +package com.codeit.team5.mopl.binarycontent.storage; + +import static org.assertj.core.api.Assertions.assertThat; +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; + +class StorageKeyFactoryTest { + + private final StorageKeyFactory storageKeyFactory = new StorageKeyFactory(); + + @Test + @DisplayName("generate는 {prefix}/{ownerId}/{uuid}.{ext} 형식의 키와 contentType 반환 성공") + void generate_returnsKeyAndContentType() { + // given + UUID ownerId = UUID.randomUUID(); + + // when + GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.THUMBNAIL, ownerId, "test.jpg"); + + // then + assertThat(generated.key()).startsWith("thumbnails/" + ownerId + "/"); + assertThat(generated.key()).endsWith(".jpg"); + assertThat(generated.contentType()).isEqualTo("image/jpeg"); + } + + @Test + @DisplayName("generate는 StorageDirectory에 맞는 prefix 적용 성공") + void generate_appliesDirectoryPrefix() { + // given + UUID ownerId = UUID.randomUUID(); + + // when + GeneratedKey generated = storageKeyFactory.generate(StorageDirectory.PROFILE, ownerId, "test.png"); + + // then + assertThat(generated.key()).startsWith("profiles/" + ownerId + "/"); + 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"); + + // then + assertThat(generated.key()).endsWith(".jpg"); + assertThat(generated.contentType()).isEqualTo("image/jpeg"); + } + + @Test + @DisplayName("generate는 확장자가 없는 파일명이면 예외 발생 실패") + void generate_noExtension_throwsException() { + // given + UUID ownerId = UUID.randomUUID(); + + // when & then + assertThatThrownBy(() -> storageKeyFactory.generate(StorageDirectory.THUMBNAIL, ownerId, "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")) + .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 9a2ccc56..a31d0c31 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 @@ -30,61 +30,6 @@ void setUp() { storage = new LocalBinaryContentStorage(properties); } - @Test - @DisplayName("generateKey는 thumbnails/{contentId}/{uuid}.{ext} 형식의 키를 반환한다") - void generateKey_returnsCorrectFormat() { - // given - UUID contentId = UUID.randomUUID(); - - // when - String key = storage.generateKey(contentId, "test.jpg"); - - // then - assertThat(key).startsWith("thumbnails/" + contentId + "/"); - assertThat(key).endsWith(".jpg"); - } - - @Test - @DisplayName("generateKey는 확장자가 없는 파일명도 처리한다") - void generateKey_noExtension_returnsKeyWithoutExtension() { - // given - UUID contentId = UUID.randomUUID(); - - // when - String key = storage.generateKey(contentId, "testfile"); - - // then - assertThat(key).startsWith("thumbnails/" + contentId + "/"); - assertThat(key).doesNotContain("."); - } - - @Test - @DisplayName("generateKey는 허용되지 않는 확장자는 제거한다") - void generateKey_disallowedExtension_returnsKeyWithoutExtension() { - // given - UUID contentId = UUID.randomUUID(); - - // when - String key = storage.generateKey(contentId, "malicious.exe"); - - // then - assertThat(key).startsWith("thumbnails/" + contentId + "/"); - assertThat(key).doesNotContain(".exe"); - } - - @Test - @DisplayName("generateKey는 확장자를 소문자로 정규화한다") - void generateKey_uppercaseExtension_normalizesToLowercase() { - // given - UUID contentId = UUID.randomUUID(); - - // when - String key = storage.generateKey(contentId, "photo.JPG"); - - // then - assertThat(key).endsWith(".jpg"); - } - @Test @DisplayName("toUrl은 baseUrl과 key를 조합한 URL을 반환한다") void toUrl_returnsCombinedUrl() { @@ -102,12 +47,11 @@ void toUrl_returnsCombinedUrl() { @DisplayName("store는 파일을 uploadDir 하위 경로에 저장한다") void store_savesFileToCorrectPath() throws IOException { // given - UUID contentId = UUID.randomUUID(); - String key = storage.generateKey(contentId, "test.jpg"); + String key = "thumbnails/" + UUID.randomUUID() + "/test.jpg"; byte[] bytes = new byte[]{1, 2, 3}; // when - storage.store(key, bytes); + storage.store(key, bytes, "image/jpeg"); // then Path saved = tempDir.resolve(key); @@ -123,7 +67,7 @@ void store_createsMissingDirectories() { byte[] bytes = new byte[]{1, 2, 3}; // when - storage.store(key, bytes); + storage.store(key, bytes, "image/jpeg"); // then assertThat(Files.exists(tempDir.resolve(key))).isTrue(); @@ -158,7 +102,7 @@ void store_ioFailure_throwsFileStorageException() throws IOException { byte[] bytes = new byte[]{1, 2, 3}; // when & then - assertThatThrownBy(() -> storage.store(key, bytes)) + assertThatThrownBy(() -> storage.store(key, bytes, "image/jpeg")) .isInstanceOf(FileStorageException.class); } } 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 new file mode 100644 index 00000000..d49f147e --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/binarycontent/storage/s3/S3BinaryContentStorageTest.java @@ -0,0 +1,74 @@ +package com.codeit.team5.mopl.binarycontent.storage.s3; + +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.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.codeit.team5.mopl.binarycontent.exception.BinaryContentStorageException; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +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.PutObjectRequest; + +@ExtendWith(MockitoExtension.class) +class S3BinaryContentStorageTest { + + @Mock + private S3Client s3Client; + + private S3BinaryContentStorage storage; + + @BeforeEach + void setUp() { + S3StorageProperties properties = + new S3StorageProperties(null, null, "mopl-bucket", "ap-northeast-2"); + storage = new S3BinaryContentStorage(properties, s3Client); + } + + @Test + @DisplayName("S3 객체 업로드 성공") + void store_success() { + // When + storage.store("profiles/abc.jpg", new byte[]{1, 2, 3}, "image/jpeg"); + + // Then + ArgumentCaptor captor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(captor.capture(), any(RequestBody.class)); + PutObjectRequest request = captor.getValue(); + assertThat(request.bucket()).isEqualTo("mopl-bucket"); + assertThat(request.key()).isEqualTo("profiles/abc.jpg"); + assertThat(request.contentType()).isEqualTo("image/jpeg"); + } + + @Test + @DisplayName("SDK 예외가 발생하면 BinaryContentStorageException으로 변환하며 S3 업로드 실패") + void store_sdkException_throwsBinaryContentStorageException() { + // Given + given(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .willThrow(SdkClientException.create("S3 연결 실패")); + + // When & Then + assertThatThrownBy(() -> storage.store("profiles/abc.jpg", new byte[]{1, 2, 3}, "image/jpeg")) + .isInstanceOf(BinaryContentStorageException.class); + } + + @Test + @DisplayName("S3 URL 생성 성공") + void toUrl_success() { + // When + String url = storage.toUrl("profiles/abc.jpg"); + + // Then + assertThat(url) + .isEqualTo("https://mopl-bucket.s3.ap-northeast-2.amazonaws.com/profiles/abc.jpg"); + } +} 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 25634e3d..449367a5 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 @@ -34,7 +34,7 @@ import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.web.multipart.MultipartFile; +import com.codeit.team5.mopl.global.dto.FileRequest; @WebMvcTest( controllers = ContentController.class, @@ -112,7 +112,10 @@ void postContent_success() throws Exception { .andExpect(jsonPath("$.reviewCount").value(0)) .andExpect(jsonPath("$.watcherCount").value(0)); - verify(contentService).create(requestCaptor.capture(), any(MultipartFile.class)); + ArgumentCaptor thumbnailCaptor = ArgumentCaptor.forClass(FileRequest.class); + verify(contentService).create(requestCaptor.capture(), thumbnailCaptor.capture()); + assertThat(thumbnailCaptor.getValue().filename()).isEqualTo("test.jpg"); + assertThat(thumbnailCaptor.getValue().bytes()).containsExactly(1, 2, 3); ContentCreateRequest captured = requestCaptor.getValue(); assertThat(captured.type()).isEqualTo(ContentType.MOVIE); assertThat(captured.title()).isEqualTo("테스트 영화"); 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 a3fe5fde..7e39ea29 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,10 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; -import com.codeit.team5.mopl.binarycontent.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.storage.GeneratedKey; +import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; +import com.codeit.team5.mopl.binarycontent.storage.StorageKeyFactory; import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; @@ -36,8 +39,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.web.multipart.MultipartFile; +import com.codeit.team5.mopl.global.dto.FileRequest; @ExtendWith(MockitoExtension.class) class ContentServiceTest { @@ -57,6 +59,9 @@ class ContentServiceTest { @Mock private BinaryContentStorage binaryContentStorage; + @Mock + private StorageKeyFactory storageKeyFactory; + @Mock private BinaryContentRepository binaryContentRepository; @@ -73,7 +78,7 @@ void create_success() { ContentCreateRequest request = new ContentCreateRequest( ContentType.MOVIE, "테스트 영화", "테스트 설명", List.of("액션", "드라마") ); - MultipartFile thumbnail = new MockMultipartFile("thumbnail", "test.jpg", "image/jpeg", new byte[]{1, 2, 3}); + FileRequest thumbnail = new FileRequest(new byte[]{1, 2, 3}, "test.jpg"); Tag actionTag = Tag.create("액션"); Tag dramaTag = Tag.create("드라마"); ContentResponse expectedResponse = new ContentResponse( @@ -85,7 +90,8 @@ 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(binaryContentStorage.generateKey(any(), eq("test.jpg"))).thenReturn("thumbnails/test.jpg"); + when(storageKeyFactory.generate(eq(StorageDirectory.THUMBNAIL), any(), eq("test.jpg"))) + .thenReturn(new GeneratedKey("thumbnails/test.jpg", "image/jpeg")); when(binaryContentStorage.toUrl("thumbnails/test.jpg")).thenReturn("http://localhost:8080/thumbnails/test.jpg"); when(binaryContentRepository.save(any(BinaryContent.class))).then(returnsFirstArg()); when(contentMapper.toDto(any(Content.class))).thenReturn(expectedResponse); @@ -134,7 +140,7 @@ void create_withoutThumbnail_success() { // then assertThat(result).isSameAs(expectedResponse); - verifyNoInteractions(binaryContentStorage, binaryContentRepository, eventPublisher); + verifyNoInteractions(storageKeyFactory, binaryContentStorage, binaryContentRepository, eventPublisher); } @Test diff --git a/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerIntegrationTest.java b/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerIntegrationTest.java index 77bb3b5e..558ce3d3 100644 --- a/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerIntegrationTest.java +++ b/src/test/java/com/codeit/team5/mopl/user/controller/UserControllerIntegrationTest.java @@ -2,12 +2,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.codeit.team5.mopl.TestcontainersConfiguration; import com.codeit.team5.mopl.user.dto.request.UserRegisterRequest; +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 com.fasterxml.jackson.databind.ObjectMapper; @@ -19,7 +21,9 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -202,4 +206,45 @@ void getUser_invalidUuid() throws Exception { .andExpect(result -> assertThat(result.getResponse().getStatus()).isGreaterThanOrEqualTo(400)); } + + @Test + @DisplayName("프로필 이름 변경 성공") + void updateUser_nameOnly_success() throws Exception { + // Given + User saved = userRepository.saveAndFlush( + User.create("user@example.com", "encoded-password", "기존이름")); + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(new UserUpdateRequest("변경된이름"))); + + // When & Then + mockMvc.perform(multipart(HttpMethod.PATCH, "/api/users/{userId}", saved.getId()) + .file(requestPart)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(saved.getId().toString())) + .andExpect(jsonPath("$.name").value("변경된이름")); + + User updated = userRepository.findById(saved.getId()).orElseThrow(); + assertThat(updated.getName()).isEqualTo("변경된이름"); + } + + @Test + @DisplayName("프로필 변경 시 이름 공백 검증 실패") + void updateUser_blankName_returnsBadRequest() throws Exception { + // Given + User saved = userRepository.saveAndFlush( + User.create("user@example.com", "encoded-password", "기존이름")); + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(new UserUpdateRequest(" "))); + + // When & Then + mockMvc.perform(multipart(HttpMethod.PATCH, "/api/users/{userId}", saved.getId()) + .file(requestPart)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType").value("INVALID_INPUT")); + + User notUpdated = userRepository.findById(saved.getId()).orElseThrow(); + assertThat(notUpdated.getName()).isEqualTo("기존이름"); + } } 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 d6955e51..8ec16458 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 @@ -6,6 +6,7 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -19,6 +20,7 @@ import com.codeit.team5.mopl.auth.security.provider.MoplAuthenticationProvider; import com.codeit.team5.mopl.global.exception.GlobalExceptionHandler; import com.codeit.team5.mopl.user.dto.request.UserRegisterRequest; +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.DuplicatedEmailException; import com.codeit.team5.mopl.user.exception.UserNotFoundException; @@ -33,7 +35,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.context.annotation.Import; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -300,4 +304,51 @@ void getUser_notFound() throws Exception { verify(userService).getById(userId); } + @Test + @DisplayName("프로필 변경 성공") + void updateUser_success() throws Exception { + // Given + UUID userId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UserResponse response = new UserResponse( + userId, Instant.parse("2026-06-25T00:00:00Z"), + "user@example.com", "새이름", + "http://localhost/profiles/key.jpg", "USER", false + ); + given(userService.update(any(), any(), any())).willReturn(response); + + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(new UserUpdateRequest("새이름"))); + MockMultipartFile imagePart = new MockMultipartFile( + "image", "profile.jpg", MediaType.IMAGE_JPEG_VALUE, new byte[]{1, 2, 3}); + + // When & Then + mockMvc.perform(multipart(HttpMethod.PATCH, "/api/users/{userId}", userId) + .file(requestPart).file(imagePart)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(userId.toString())) + .andExpect(jsonPath("$.name").value("새이름")) + .andExpect(jsonPath("$.profileImageUrl").value("http://localhost/profiles/key.jpg")); + + verify(userService).update(any(), any(), any()); + } + + @Test + @DisplayName("프로필 변경 시 이름 공백 검증 실패") + void updateUser_blankName_returnsBadRequest() throws Exception { + // Given + UUID userId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + MockMultipartFile requestPart = new MockMultipartFile( + "request", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(new UserUpdateRequest(" "))); + + // When & Then + mockMvc.perform(multipart(HttpMethod.PATCH, "/api/users/{userId}", userId) + .file(requestPart)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.exceptionType").value("INVALID_INPUT")); + + verify(userService, never()).update(any(), any(), any()); + } + } 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 34794422..7064115f 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 @@ -2,14 +2,25 @@ 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.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; import static org.mockito.Mockito.when; +import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage; +import com.codeit.team5.mopl.binarycontent.storage.GeneratedKey; +import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory; +import com.codeit.team5.mopl.binarycontent.storage.StorageKeyFactory; +import com.codeit.team5.mopl.binarycontent.entity.BinaryContent; +import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent; +import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository; +import com.codeit.team5.mopl.global.dto.FileRequest; +import com.codeit.team5.mopl.global.exception.ErrorCode; import com.codeit.team5.mopl.user.dto.request.UserRegisterRequest; +import com.codeit.team5.mopl.user.dto.request.UserUpdateRequest; import com.codeit.team5.mopl.user.dto.response.UserResponse; import com.codeit.team5.mopl.user.entity.User; import com.codeit.team5.mopl.user.exception.DuplicatedEmailException; @@ -26,6 +37,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; @@ -41,6 +53,18 @@ class UserServiceTest { @Mock private PasswordEncoder passwordEncoder; + @Mock + private BinaryContentStorage binaryContentStorage; + + @Mock + private StorageKeyFactory storageKeyFactory; + + @Mock + private BinaryContentRepository binaryContentRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + @InjectMocks private UserService userService; @@ -148,4 +172,72 @@ void getById_notFound_throwsException() { verify(userRepository).findById(userId); verifyNoInteractions(userMapper); } + + @Test + @DisplayName("프로필 이름만 변경 성공") + void update_nameOnly_success() { + // Given + UUID userId = UUID.randomUUID(); + User user = User.create("user@example.com", "encoded-password", "기존이름"); + UserResponse expected = new UserResponse( + userId, Instant.parse("2026-06-25T00:00:00Z"), + "user@example.com", "새이름", null, "USER", false + ); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userMapper.toDto(user)).thenReturn(expected); + + // When + UserResponse result = userService.update(userId, new UserUpdateRequest("새이름"), null); + + // Then + assertThat(result).isSameAs(expected); + assertThat(user.getName()).isEqualTo("새이름"); + assertThat(user.getProfileImage()).isNull(); + verifyNoInteractions(storageKeyFactory, binaryContentStorage, binaryContentRepository, eventPublisher); + } + + @Test + @DisplayName("프로필 이미지 포함 변경 성공") + 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"); + UserResponse expected = new UserResponse( + userId, Instant.parse("2026-06-25T00:00:00Z"), + "user@example.com", "새이름", "http://localhost/profiles/key.jpg", "USER", false + ); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(storageKeyFactory.generate(eq(StorageDirectory.PROFILE), eq(user.getId()), eq("profile.jpg"))) + .thenReturn(new GeneratedKey("profiles/key.jpg", "image/jpeg")); + when(binaryContentStorage.toUrl("profiles/key.jpg")) + .thenReturn("http://localhost/profiles/key.jpg"); + when(binaryContentRepository.save(any(BinaryContent.class))).then(returnsFirstArg()); + when(userMapper.toDto(user)).thenReturn(expected); + + // When + UserResponse result = userService.update(userId, new UserUpdateRequest("새이름"), image); + + // Then + assertThat(result).isSameAs(expected); + assertThat(user.getName()).isEqualTo("새이름"); + assertThat(user.getProfileImage()).isNotNull(); + verify(binaryContentRepository).save(any(BinaryContent.class)); + verify(eventPublisher).publishEvent(any(BinaryContentUploadEvent.class)); + } + + @Test + @DisplayName("존재하지 않는 사용자 프로필 변경 실패") + void update_notFound_throwsException() { + // Given + UUID userId = UUID.randomUUID(); + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> userService.update(userId, new UserUpdateRequest("새이름"), null)) + .isInstanceOf(UserNotFoundException.class); + + verify(userRepository).findById(userId); + verifyNoInteractions(userMapper, binaryContentRepository, eventPublisher); + } }