diff --git a/build.gradle b/build.gradle index 9d8c3a8..9260838 100755 --- a/build.gradle +++ b/build.gradle @@ -53,6 +53,7 @@ dependencies { // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.testcontainers:localstack' // AWS implementation("software.amazon.awssdk:s3:2.21.0") diff --git a/src/main/java/be/dash/dashserver/api/shceduler/S3CleanUpScheduler.java b/src/main/java/be/dash/dashserver/api/shceduler/S3CleanUpScheduler.java new file mode 100644 index 0000000..b6bb71f --- /dev/null +++ b/src/main/java/be/dash/dashserver/api/shceduler/S3CleanUpScheduler.java @@ -0,0 +1,33 @@ +package be.dash.dashserver.api.shceduler; + +import java.time.LocalDateTime; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import be.dash.dashserver.core.exception.ImageStorageException; +import be.dash.dashserver.core.image.ImageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Profile("dev") +@Component +@Slf4j +@RequiredArgsConstructor +public class S3CleanUpScheduler { + + private final ImageService imageService; + + @Scheduled(cron = "0 0 3 * * *") + public void cleanUp() { + log.info("S3 정리 작업 시작 {}", LocalDateTime.now()); + try { + imageService.cleanUpUnusedProfileImages(); + } catch (ImageStorageException e) { + log.error("S3 정리 작업 실패: {}", e.getMessage()); + if (!e.getFailedKeys().isEmpty()) { + log.error("삭제 실패한 키: {}", e.getFailedKeys()); + } + } + log.info("S3 정리 작업 끝 {}", LocalDateTime.now()); + } +} diff --git a/src/main/java/be/dash/dashserver/api/support/GlobalExceptionHandler.java b/src/main/java/be/dash/dashserver/api/support/GlobalExceptionHandler.java index 1d46c45..03e65ca 100755 --- a/src/main/java/be/dash/dashserver/api/support/GlobalExceptionHandler.java +++ b/src/main/java/be/dash/dashserver/api/support/GlobalExceptionHandler.java @@ -17,6 +17,7 @@ import be.dash.dashserver.core.exception.BadRequestException; import be.dash.dashserver.core.exception.ConflictException; import be.dash.dashserver.core.exception.ForbiddenException; +import be.dash.dashserver.core.exception.ImageStorageException; import be.dash.dashserver.core.exception.NotFoundException; import be.dash.dashserver.core.exception.PaymentClientException; import be.dash.dashserver.core.log.LogForm; @@ -118,6 +119,12 @@ public ResponseEntity handleDashApiException(DashApiException e) { return ResponseEntity.badRequest().body(new ErrorMessage(e.getMessage())); } + @ExceptionHandler(ImageStorageException.class) + public ResponseEntity handleImageStorageException(ImageStorageException e) { + log.error("handleImageStorageException in GlobalExceptionHandler throw {} : {}", e.getClass(), e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(new ErrorMessage(e.getMessage())); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e) { log.error(LogForm.ERROR_LOGGING_FORM, e.getClass(), e.getMessage(), e.getStackTrace()); diff --git a/src/main/java/be/dash/dashserver/core/domain/member/service/MemberRepository.java b/src/main/java/be/dash/dashserver/core/domain/member/service/MemberRepository.java index ee0086c..b526b22 100755 --- a/src/main/java/be/dash/dashserver/core/domain/member/service/MemberRepository.java +++ b/src/main/java/be/dash/dashserver/core/domain/member/service/MemberRepository.java @@ -25,4 +25,6 @@ public interface MemberRepository { void update(Member member); Optional findNicknameById(long memberId); + + List findAllProfileImages(); } diff --git a/src/main/java/be/dash/dashserver/core/exception/ImageStorageException.java b/src/main/java/be/dash/dashserver/core/exception/ImageStorageException.java new file mode 100644 index 0000000..a467f5e --- /dev/null +++ b/src/main/java/be/dash/dashserver/core/exception/ImageStorageException.java @@ -0,0 +1,17 @@ +package be.dash.dashserver.core.exception; + +import java.util.List; +import lombok.Getter; + +@Getter +public class ImageStorageException extends RuntimeException { + private List failedKeys; + public ImageStorageException(String message) { + super(message); + } + + public ImageStorageException(String message, List failedKeys) { + super(message); + this.failedKeys = failedKeys; + } +} diff --git a/src/main/java/be/dash/dashserver/core/image/ImageDeleter.java b/src/main/java/be/dash/dashserver/core/image/ImageDeleter.java new file mode 100644 index 0000000..47f2767 --- /dev/null +++ b/src/main/java/be/dash/dashserver/core/image/ImageDeleter.java @@ -0,0 +1,7 @@ +package be.dash.dashserver.core.image; + +import java.util.List; + +public interface ImageDeleter { + void deleteAllByKeys(List keysToDelete); +} diff --git a/src/main/java/be/dash/dashserver/core/image/ImageReader.java b/src/main/java/be/dash/dashserver/core/image/ImageReader.java new file mode 100644 index 0000000..ac96742 --- /dev/null +++ b/src/main/java/be/dash/dashserver/core/image/ImageReader.java @@ -0,0 +1,7 @@ +package be.dash.dashserver.core.image; + +import java.util.List; + +public interface ImageReader { + List getAllKeys(); +} diff --git a/src/main/java/be/dash/dashserver/core/image/ImageService.java b/src/main/java/be/dash/dashserver/core/image/ImageService.java index bbb6fa2..412fe31 100755 --- a/src/main/java/be/dash/dashserver/core/image/ImageService.java +++ b/src/main/java/be/dash/dashserver/core/image/ImageService.java @@ -1,9 +1,10 @@ package be.dash.dashserver.core.image; -import java.io.IOException; +import java.util.List; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -import be.dash.dashserver.core.exception.BadGatewayException; +import be.dash.dashserver.core.domain.member.service.MemberRepository; +import be.dash.dashserver.core.exception.BadRequestException; import be.dash.dashserver.core.log.annotation.Trace; import lombok.RequiredArgsConstructor; @@ -11,13 +12,33 @@ @Service @RequiredArgsConstructor public class ImageService { + + private static final List IMAGE_EXTENSIONS = List.of( + "image/jpeg", "image/png", "image/jpg", "image/webp", "image/heic", "image/heif" + ); + private final ImageUploader imageUploader; + private final ImageReader imageReader; + private final ImageDeleter imageDeleter; + private final MemberRepository memberRepository; public String upload(MultipartFile file) { - try { - return imageUploader.uploadImage(file); - } catch (IOException e) { - throw new BadGatewayException("이미지 업로드 중에 오류가 발생했습니다."); + validateExtension(file); + return imageUploader.upload(file); + } + + public void cleanUpUnusedProfileImages() { + List profileImages = memberRepository.findAllProfileImages(); + List keysToDelete = imageReader.getAllKeys().stream() + // images 도메인 용도별로 분리 후 도메인 로직으로 넣기 + .filter(key -> !profileImages.contains(key)).toList(); + imageDeleter.deleteAllByKeys(keysToDelete); + } + + private void validateExtension(MultipartFile image) { + String contentType = image.getContentType(); + if (!IMAGE_EXTENSIONS.contains(contentType)) { + throw new BadRequestException("이미지 확장자는 jpg, png, webp, heic, heif만 가능합니다."); } } } diff --git a/src/main/java/be/dash/dashserver/core/image/ImageUploader.java b/src/main/java/be/dash/dashserver/core/image/ImageUploader.java index ad6ab33..eef67de 100755 --- a/src/main/java/be/dash/dashserver/core/image/ImageUploader.java +++ b/src/main/java/be/dash/dashserver/core/image/ImageUploader.java @@ -1,8 +1,7 @@ package be.dash.dashserver.core.image; -import java.io.IOException; import org.springframework.web.multipart.MultipartFile; public interface ImageUploader { - String uploadImage(MultipartFile file) throws IOException; + String upload(MultipartFile file); } diff --git a/src/main/java/be/dash/dashserver/database/core/member/MemberJpaRepository.java b/src/main/java/be/dash/dashserver/database/core/member/MemberJpaRepository.java index ee1c280..c22dbbe 100755 --- a/src/main/java/be/dash/dashserver/database/core/member/MemberJpaRepository.java +++ b/src/main/java/be/dash/dashserver/database/core/member/MemberJpaRepository.java @@ -1,5 +1,6 @@ package be.dash.dashserver.database.core.member; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -16,4 +17,7 @@ public interface MemberJpaRepository extends JpaRepository findNicknameById(long memberId); + + @Query("select m.profileImageUrl from MemberJpaEntity m") + List findAllProfileImages(); } diff --git a/src/main/java/be/dash/dashserver/database/core/member/MemberRepositoryAdapter.java b/src/main/java/be/dash/dashserver/database/core/member/MemberRepositoryAdapter.java index d8dbf10..5a38d0b 100755 --- a/src/main/java/be/dash/dashserver/database/core/member/MemberRepositoryAdapter.java +++ b/src/main/java/be/dash/dashserver/database/core/member/MemberRepositoryAdapter.java @@ -85,4 +85,9 @@ public void update(Member member) { public Optional findNicknameById(long memberId) { return memberJpaRepository.findNicknameById(memberId); } + + @Override + public List findAllProfileImages() { + return memberJpaRepository.findAllProfileImages(); + } } diff --git a/src/main/java/be/dash/dashserver/external/config/s3/LocalStackConfig.java b/src/main/java/be/dash/dashserver/external/config/s3/LocalStackConfig.java new file mode 100644 index 0000000..21c0ce6 --- /dev/null +++ b/src/main/java/be/dash/dashserver/external/config/s3/LocalStackConfig.java @@ -0,0 +1,47 @@ +package be.dash.dashserver.external.config.s3; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.testcontainers.containers.localstack.LocalStackContainer; +import org.testcontainers.utility.DockerImageName; +import lombok.RequiredArgsConstructor; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +import static org.testcontainers.containers.localstack.LocalStackContainer.Service.S3; + +@Configuration +@Profile({"local", "test"}) +@RequiredArgsConstructor +@EnableConfigurationProperties(S3Properties.class) +public class LocalStackConfig { + + private static final DockerImageName LOCALSTACK_IMAGE = DockerImageName.parse("localstack/localstack:3.5.0"); + + private final S3Properties s3Properties; + + @Bean(initMethod = "start", destroyMethod = "stop") + public LocalStackContainer localStackContainer() { + return new LocalStackContainer(LOCALSTACK_IMAGE) + .withServices(S3); + } + + @Bean + public S3Client s3Client(LocalStackContainer localStackContainer) { + S3Client client = S3Client.builder() + .endpointOverride(localStackContainer.getEndpoint()) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(localStackContainer.getAccessKey(), localStackContainer.getSecretKey()) + ) + ) + .region(Region.of(localStackContainer.getRegion())) + .build(); + client.createBucket(b -> b.bucket(s3Properties.s3BucketName())); + return client; + } +} diff --git a/src/main/java/be/dash/dashserver/external/config/s3/S3Config.java b/src/main/java/be/dash/dashserver/external/config/s3/S3Config.java index 0c1108d..a573f9b 100755 --- a/src/main/java/be/dash/dashserver/external/config/s3/S3Config.java +++ b/src/main/java/be/dash/dashserver/external/config/s3/S3Config.java @@ -1,16 +1,23 @@ package be.dash.dashserver.external.config.s3; -import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import java.time.Duration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import lombok.RequiredArgsConstructor; import software.amazon.awssdk.auth.credentials.SystemPropertyCredentialsProvider; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.core.retry.RetryPolicy; +import software.amazon.awssdk.core.retry.backoff.BackoffStrategy; +import software.amazon.awssdk.core.retry.conditions.RetryCondition; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; @Configuration +@Profile("develop") @RequiredArgsConstructor -@ConfigurationPropertiesScan(basePackages = "be.dash.dashserver.external.config.s3") +@EnableConfigurationProperties(S3Properties.class) public class S3Config { private static final String AWS_ACCESS_KEY_ID = "aws.accessKeyId"; @@ -31,9 +38,21 @@ public Region getRegion() { @Bean public S3Client getS3Client() { + ClientOverrideConfiguration overrideConfig = ClientOverrideConfiguration.builder() + .apiCallAttemptTimeout(Duration.ofSeconds(3)) + .apiCallTimeout(Duration.ofSeconds(14)) + .retryPolicy( + RetryPolicy.builder() + .numRetries(3) + .retryCondition(RetryCondition.defaultRetryCondition()) + .backoffStrategy(BackoffStrategy.defaultStrategy()) + .build() + ) + .build(); return S3Client.builder() .region(getRegion()) .credentialsProvider(systemPropertyCredentialsProvider()) + .overrideConfiguration(overrideConfig) .build(); } } diff --git a/src/main/java/be/dash/dashserver/external/s3/S3ImageDeleter.java b/src/main/java/be/dash/dashserver/external/s3/S3ImageDeleter.java new file mode 100755 index 0000000..96cb5f3 --- /dev/null +++ b/src/main/java/be/dash/dashserver/external/s3/S3ImageDeleter.java @@ -0,0 +1,63 @@ +package be.dash.dashserver.external.s3; + +import java.util.List; +import org.springframework.stereotype.Component; +import be.dash.dashserver.core.exception.ImageStorageException; +import be.dash.dashserver.core.image.ImageDeleter; +import be.dash.dashserver.external.config.s3.S3Properties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.Delete; +import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse; +import software.amazon.awssdk.services.s3.model.ObjectIdentifier; +import software.amazon.awssdk.services.s3.model.S3Error; + +@Component +@RequiredArgsConstructor +@Slf4j +public class S3ImageDeleter implements ImageDeleter { + private final S3Properties s3Properties; + private final S3Client s3Client; + + @Override + public void deleteAllByKeys(List keysToDelete) { + if (keysToDelete.isEmpty()) { + return; + } + DeleteObjectsRequest request = buildRequest(keysToDelete); + DeleteObjectsResponse response = performDeleteAllByKeys(request); + handlePartialDeleteErrors(response); + } + + private DeleteObjectsResponse performDeleteAllByKeys(DeleteObjectsRequest deleteObjectRequest) { + try { + return s3Client.deleteObjects(deleteObjectRequest); + } catch (AwsServiceException e) { + log.error("S3 삭제 실패 - 상태코드 : {}, 에러메시지 : {}", e.statusCode(), e.awsErrorDetails().errorMessage()); + throw new ImageStorageException("이미지 삭제에 실패했습니다.(서비스 오류)"); + } catch (SdkClientException e) { + log.error("S3 삭제 실패 - 에러메시지 : {}", e.getMessage()); + throw new ImageStorageException("이미지 삭제에 실패했습니다.(내부 네트워크 오류)"); + } + } + + private DeleteObjectsRequest buildRequest(List keysToDelete) { + List list = keysToDelete.stream() + .map(key -> ObjectIdentifier.builder().key(key).build()).toList(); + return DeleteObjectsRequest.builder() + .bucket(s3Properties.s3BucketName()) + .delete(Delete.builder().objects(list).build()) + .build(); + } + + private void handlePartialDeleteErrors(DeleteObjectsResponse response) { + List errors = response.errors(); + if(!errors.isEmpty()) { + throw new ImageStorageException("이미지 일부 삭제에 실패했습니다.", errors.stream().map(S3Error::key).toList()); + } + } +} diff --git a/src/main/java/be/dash/dashserver/external/s3/S3ImageReader.java b/src/main/java/be/dash/dashserver/external/s3/S3ImageReader.java new file mode 100755 index 0000000..fd3043c --- /dev/null +++ b/src/main/java/be/dash/dashserver/external/s3/S3ImageReader.java @@ -0,0 +1,62 @@ +package be.dash.dashserver.external.s3; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.stereotype.Component; +import be.dash.dashserver.core.exception.ImageStorageException; +import be.dash.dashserver.core.image.ImageReader; +import be.dash.dashserver.external.config.s3.S3Properties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; +import software.amazon.awssdk.services.s3.model.S3Object; + +@Component +@RequiredArgsConstructor +@Slf4j +public class S3ImageReader implements ImageReader { + private final S3Properties s3Properties; + private final S3Client s3Client; + + @Override + public List getAllKeys() { + try { + return performGetAllKeys(); + } catch (AwsServiceException e) { + log.error("S3 키 목록 조회 실패 - 상태코드 : {}, 에러메시지 : {}", e.statusCode(), e.awsErrorDetails().errorMessage()); + throw new ImageStorageException("키 조회에 실패했습니다.(서비스 오류)"); + } catch (SdkClientException e) { + log.error("S3 키 목록 조회 실패 - 에러메시지 : {}", e.getMessage()); + throw new ImageStorageException("키 조회에 실패했습니다.(내부 네트워크 오류)"); + } + } + + private List performGetAllKeys() {; + ListObjectsV2Request request = buildRequest(); + List keys = new ArrayList<>(); + ListObjectsV2Response response; + do { + response = s3Client.listObjectsV2(request); + keys.addAll( + response.contents() + .stream() + .map(S3Object::key) + .toList() + ); + request = request.toBuilder() + .continuationToken(response.nextContinuationToken()) + .build(); + } while (response.isTruncated()); + return keys; + } + + private ListObjectsV2Request buildRequest() { + return ListObjectsV2Request.builder() + .bucket(s3Properties.s3BucketName()) + .build(); + } +} diff --git a/src/main/java/be/dash/dashserver/external/s3/S3ImageUploader.java b/src/main/java/be/dash/dashserver/external/s3/S3ImageUploader.java index d26ac01..9c40121 100755 --- a/src/main/java/be/dash/dashserver/external/s3/S3ImageUploader.java +++ b/src/main/java/be/dash/dashserver/external/s3/S3ImageUploader.java @@ -1,55 +1,71 @@ package be.dash.dashserver.external.s3; import java.io.IOException; -import java.util.Arrays; -import java.util.List; import java.util.UUID; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; -import be.dash.dashserver.core.exception.BadRequestException; +import be.dash.dashserver.core.exception.ImageStorageException; import be.dash.dashserver.core.image.ImageUploader; -import be.dash.dashserver.external.config.s3.S3Config; import be.dash.dashserver.external.config.s3.S3Properties; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +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; @Component @RequiredArgsConstructor +@Slf4j public class S3ImageUploader implements ImageUploader { + private static final String IMAGE_EXTENSION = ".jpg"; + private static final String CONTENT_DISPOSITION = "inline"; + private final S3Properties s3Properties; - private final S3Config s3Config; - private static final List IMAGE_EXTENSIONS = Arrays.asList("image/jpeg", "image/png", "image/jpg", "image/webp", "image/heic", "image/heif"); + private final S3Client s3Client; @Override - public String uploadImage(MultipartFile image) throws IOException { + public String upload(MultipartFile image) { final String key = generateImageFileName(); - final S3Client s3Client = s3Config.getS3Client(); + PutObjectRequest request = buildRequest(image, key); + RequestBody requestBody = toRequestBody(image); + performUpload(request, requestBody); + return s3Properties.s3Endpoint() + key; + } - validateExtension(image); + private String generateImageFileName() { + return UUID.randomUUID() + IMAGE_EXTENSION; + } - PutObjectRequest request = PutObjectRequest.builder() + private PutObjectRequest buildRequest(MultipartFile image, String key) { + return PutObjectRequest.builder() .bucket(s3Properties.s3BucketName()) .key(key) .contentType(image.getContentType()) - .contentDisposition("inline") + .contentDisposition(CONTENT_DISPOSITION) .build(); - - RequestBody requestBody = RequestBody.fromBytes(image.getBytes()); - s3Client.putObject(request, requestBody); - return s3Properties.s3Endpoint() + key; } - private String generateImageFileName() { - return UUID.randomUUID() + ".jpg"; + private static RequestBody toRequestBody(MultipartFile image) { + RequestBody requestBody; + try { + requestBody = RequestBody.fromBytes(image.getBytes()); + } catch (IOException e) { + throw new ImageStorageException("이미지 저장에 실패했습니다."); + } + return requestBody; } - - private void validateExtension(MultipartFile image) { - String contentType = image.getContentType(); - if (!IMAGE_EXTENSIONS.contains(contentType)) { - throw new BadRequestException("이미지 확장자는 jpg, png, webp, heic, heif만 가능합니다."); + private void performUpload(PutObjectRequest request, RequestBody requestBody) { + try { + s3Client.putObject(request, requestBody); + } catch (AwsServiceException e) { + log.error("S3 업로드 실패 - 상태코드 : {}, 에러메시지 : {}", e.statusCode(), e.awsErrorDetails().errorMessage()); + throw new ImageStorageException("이미지 저장에 실패했습니다.(서비스 오류)"); + } catch (SdkClientException e) { + log.error("S3 업로드 실패 - 에러메시지 : {}", e.getMessage()); + throw new ImageStorageException("이미지 저장에 실패했습니다.(내부 네트워크 오류)"); } } } diff --git a/src/test/java/be/dash/dashserver/core/image/ImageServiceTest.java b/src/test/java/be/dash/dashserver/core/image/ImageServiceTest.java new file mode 100644 index 0000000..a50609e --- /dev/null +++ b/src/test/java/be/dash/dashserver/core/image/ImageServiceTest.java @@ -0,0 +1,81 @@ +package be.dash.dashserver.core.image; + +import java.nio.charset.StandardCharsets; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockMultipartFile; +import be.dash.dashserver.ServiceSliceTest; +import be.dash.dashserver.database.core.member.MemberJpaEntity; +import be.dash.dashserver.database.core.member.MemberJpaRepository; +import be.dash.dashserver.database.fixture.MemberJpaEntityFixture; +import be.dash.dashserver.external.config.s3.S3Properties; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.sync.ResponseTransformer; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; + +class ImageServiceTest extends ServiceSliceTest { + + @Autowired + private ImageService imageService; + @Autowired + private S3Client s3Client; + @Autowired + private S3Properties s3Properties; + @Autowired + private MemberJpaRepository memberJpaRepository; + + @Test + @DisplayName("이미지를 업도드하면 S3에 저장된다.") + void upload() { + // given + MockMultipartFile file = createMultipartFile("testImage"); + // when + String url = imageService.upload(file); + + // then + ResponseBytes object = s3Client.getObject( + GetObjectRequest.builder() + .bucket(s3Properties.s3BucketName()) + .key(url.substring(url.lastIndexOf("/") + 1)) + .build(), + ResponseTransformer.toBytes() + ); + String downloaded = new String(object.asByteArray(), StandardCharsets.UTF_8); + Assertions.assertThat(downloaded).isEqualTo("testImage"); + } + + @Test + @DisplayName("사용자의 프로필에 사용하지 않는 이미지를 삭제한다.") + void cleanUpUnusedProfileImages() { + // given + String url = imageService.upload(createMultipartFile("testImage")); // dangling + MemberJpaEntity member = MemberJpaEntityFixture.create(); + memberJpaRepository.save(member); + + // when + imageService.cleanUpUnusedProfileImages(); + + // then + ListObjectsV2Response listObjectsV2Response = s3Client.listObjectsV2(ListObjectsV2Request.builder() + .bucket(s3Properties.s3BucketName()) + .build()); + + Assertions.assertThat(listObjectsV2Response.contents().size()).isEqualTo(0); + } + + + private MockMultipartFile createMultipartFile(String imageKey) { + return new MockMultipartFile( + "file", + "test.jpg", + "image/jpeg", + imageKey.getBytes(StandardCharsets.UTF_8) + ); + } +} diff --git a/src/test/java/be/dash/dashserver/database/fixture/MemberJpaEntityFixture.java b/src/test/java/be/dash/dashserver/database/fixture/MemberJpaEntityFixture.java index 1efcda3..b895cda 100755 --- a/src/test/java/be/dash/dashserver/database/fixture/MemberJpaEntityFixture.java +++ b/src/test/java/be/dash/dashserver/database/fixture/MemberJpaEntityFixture.java @@ -18,6 +18,7 @@ public static MemberJpaEntity create() { .name("김영희") .phoneNumber("010-8765-4321") .nickname("younghee") + .profileImageUrl("http://localhost/profile.jpg") .build(); } diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 18fd6a2..255becd 100755 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -30,9 +30,9 @@ kakao: s3: secret-key: 1234 access-key: 1234 - s3-endpoint: www.s3.com + s3-endpoint: http://localhost/ region: ap-northeast-2 - s3-bucket-name: s3 + s3-bucket-name: test naver: client-id: 1234