From 1a9799efdd538a0340358af0930b5bb465137a46 Mon Sep 17 00:00:00 2001 From: leegwichan Date: Wed, 23 Jul 2025 15:40:39 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat=20:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B0=9D=EC=B2=B4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/eatda/domain/Image.java | 43 ++++++++++++ src/main/java/eatda/domain/ImageKey.java | 25 +++++++ src/test/java/eatda/domain/ImageKeyTest.java | 37 ++++++++++ src/test/java/eatda/domain/ImageTest.java | 71 ++++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 src/main/java/eatda/domain/Image.java create mode 100644 src/main/java/eatda/domain/ImageKey.java create mode 100644 src/test/java/eatda/domain/ImageKeyTest.java create mode 100644 src/test/java/eatda/domain/ImageTest.java diff --git a/src/main/java/eatda/domain/Image.java b/src/main/java/eatda/domain/Image.java new file mode 100644 index 00000000..c080212b --- /dev/null +++ b/src/main/java/eatda/domain/Image.java @@ -0,0 +1,43 @@ +package eatda.domain; + +import eatda.exception.BusinessErrorCode; +import eatda.exception.BusinessException; +import java.util.Set; +import org.springframework.web.multipart.MultipartFile; + +public class Image { + + private static final Set ALLOWED_CONTENT_TYPES = Set.of("image/jpg", "image/jpeg", "image/png"); + private static final String EXTENSION_DELIMITER = "."; + private static final String DEFAULT_CONTENT_TYPE = "bin"; + + private final ImageDomain domain; + private final MultipartFile file; + + public Image(ImageDomain domain, MultipartFile file) { + validateContentType(file); + this.domain = domain; + this.file = file; + } + + private void validateContentType(MultipartFile file) { + if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) { + throw new BusinessException(BusinessErrorCode.INVALID_IMAGE_TYPE); + } + } + + public String getExtension() { + String filename = file.getOriginalFilename(); + if (filename == null + || filename.lastIndexOf(EXTENSION_DELIMITER) == -1 + || filename.startsWith(EXTENSION_DELIMITER) + || filename.endsWith(EXTENSION_DELIMITER)) { + return DEFAULT_CONTENT_TYPE; + } + return filename.substring(filename.lastIndexOf(EXTENSION_DELIMITER) + 1); + } + + public String getDomainName() { + return domain.getName(); + } +} diff --git a/src/main/java/eatda/domain/ImageKey.java b/src/main/java/eatda/domain/ImageKey.java new file mode 100644 index 00000000..ad15de9f --- /dev/null +++ b/src/main/java/eatda/domain/ImageKey.java @@ -0,0 +1,25 @@ +package eatda.domain; + +import eatda.exception.BusinessErrorCode; +import eatda.exception.BusinessException; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ImageKey { + + @Column(name = "image_key", nullable = false) + private String value; + + public ImageKey(String value) { + if (value == null || value.isBlank()) { + throw new BusinessException(BusinessErrorCode.INVALID_IMAGE_KEY); + } + this.value = value; + } +} diff --git a/src/test/java/eatda/domain/ImageKeyTest.java b/src/test/java/eatda/domain/ImageKeyTest.java new file mode 100644 index 00000000..1f3d0dc1 --- /dev/null +++ b/src/test/java/eatda/domain/ImageKeyTest.java @@ -0,0 +1,37 @@ +package eatda.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import eatda.exception.BusinessErrorCode; +import eatda.exception.BusinessException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class ImageKeyTest { + + @Nested + class Validate { + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t", "\n\t"}) + void 이미지_키값이_비어있다면_예외를_던진다(String value) { + BusinessException exception = assertThrows(BusinessException.class, () -> new ImageKey(value)); + + assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.INVALID_IMAGE_KEY); + } + + @ParameterizedTest + @ValueSource(strings = { + "story/550e8400-e29b-41d4-a716-446655440000.jpg", + "cheer/550e8400-e29b-41d4-a716-446655440111.png"}) + void 이미지_키값이_유효하다면_예외를_던지지_않는다(String value) { + ImageKey imageKey = new ImageKey(value); + + assertThat(imageKey.getValue()).isEqualTo(value); + } + } +} diff --git a/src/test/java/eatda/domain/ImageTest.java b/src/test/java/eatda/domain/ImageTest.java new file mode 100644 index 00000000..d1e5537c --- /dev/null +++ b/src/test/java/eatda/domain/ImageTest.java @@ -0,0 +1,71 @@ +package eatda.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import eatda.exception.BusinessErrorCode; +import eatda.exception.BusinessException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +class ImageTest { + + @Nested + class Validate { + + @ValueSource(strings = {"image/jpg", "image/jpeg", "image/png"}) + @ParameterizedTest + void 파일_타입은_이미지_타입이어야_한다(String contentType) { + ImageDomain domain = ImageDomain.STORY; + MultipartFile file = new MockMultipartFile("file", "test.jpg", contentType, new byte[0]); + + assertThatCode(() -> new Image(domain, file)) + .doesNotThrowAnyException(); + } + + @ValueSource(strings = {"application/pdf", "text/plain", "image/gif"}) + @ParameterizedTest + void 파일_타입은_이미지가_아닐_경우_예외를_던진다(String contentType) { + ImageDomain domain = ImageDomain.STORY; + MultipartFile file = new MockMultipartFile("file", "test.pdf", contentType, new byte[0]); + + BusinessException exception = assertThrows(BusinessException.class, () -> new Image(domain, file)); + + assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.INVALID_IMAGE_TYPE); + } + } + + @Nested + class GetExtension { + + @CsvSource({"test.jpg, jpg", "iamIronMan.jpeg, jpeg", "legend1_developer123.png, png"}) + @ParameterizedTest + void 파일_이름에_확장자가_존재하는_경우_해당_확장자를_반환한다(String filename, String expected) { + ImageDomain domain = ImageDomain.STORY; + MultipartFile file = new MockMultipartFile("file", filename, "image/jpeg", new byte[0]); + Image image = new Image(domain, file); + + String actual = image.getExtension(); + + assertThat(actual).isEqualTo(expected); + } + + @ValueSource(strings = {"test", ".jpg", "test."}) + @ParameterizedTest + void 파일_이름에_확장자가_존재하지_않는_경우_기본값을_반환한다(String filename) { + ImageDomain domain = ImageDomain.STORY; + MultipartFile file = new MockMultipartFile("file", filename, "image/jpeg", new byte[0]); + Image image = new Image(domain, file); + String expected = "bin"; + + String actual = image.getExtension(); + + assertThat(actual).isEqualTo(expected); + } + } +} From ec6995a24e51f4c831a14e6b565ce23fcc781759 Mon Sep 17 00:00:00 2001 From: leegwichan Date: Wed, 23 Jul 2025 16:37:25 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat=20:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=8B=B4=EB=8B=B9=20Client=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/eatda/client/file/FileClient.java | 65 +++++++++++ .../eatda/client/file/FileClientTest.java | 104 ++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 src/main/java/eatda/client/file/FileClient.java create mode 100644 src/test/java/eatda/client/file/FileClientTest.java diff --git a/src/main/java/eatda/client/file/FileClient.java b/src/main/java/eatda/client/file/FileClient.java new file mode 100644 index 00000000..90230cc8 --- /dev/null +++ b/src/main/java/eatda/client/file/FileClient.java @@ -0,0 +1,65 @@ +package eatda.client.file; + +import eatda.exception.BusinessErrorCode; +import eatda.exception.BusinessException; +import java.time.Duration; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +@Component +public class FileClient { + + private final S3Client s3Client; + private final String bucket; + private final S3Presigner s3Presigner; + + public FileClient(S3Client s3Client, + @Value("${spring.cloud.aws.s3.bucket}") String bucket, + S3Presigner s3Presigner) { + this.s3Client = s3Client; + this.bucket = bucket; + this.s3Presigner = s3Presigner; + } + + public String upload(MultipartFile file, String fileKey) { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(fileKey) + .contentType(file.getContentType()) + .build(); + + try { + s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + return fileKey; + } catch (Exception exception) { + throw new BusinessException(BusinessErrorCode.FILE_UPLOAD_FAILED); + } + // TODO 발생 예외 별로 처리하기 + // InvalidRequestException, InvalidWriteOffsetException, TooManyPartsException, EncryptionTypeMismatchException, + // AwsServiceException, SdkClientException, S3Exception + } + + public String getPreSignedUrl(String fileKey, Duration signatureDuration) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucket) + .key(fileKey) + .build(); + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .getObjectRequest(getObjectRequest) + .signatureDuration(signatureDuration) + .build(); + + try { + return s3Presigner.presignGetObject(presignRequest).url().toString(); + } catch (Exception exception) { + throw new BusinessException(BusinessErrorCode.PRESIGNED_URL_GENERATION_FAILED); + } + } +} diff --git a/src/test/java/eatda/client/file/FileClientTest.java b/src/test/java/eatda/client/file/FileClientTest.java new file mode 100644 index 00000000..873ceb17 --- /dev/null +++ b/src/test/java/eatda/client/file/FileClientTest.java @@ -0,0 +1,104 @@ +package eatda.client.file; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; + +import eatda.exception.BusinessErrorCode; +import eatda.exception.BusinessException; +import java.net.MalformedURLException; +import java.net.URL; +import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; +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; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; + +class FileClientTest { + + private S3Client s3Client; + private String bucket; + private S3Presigner s3Presigner; + private FileClient fileClient; + + @BeforeEach + void setUp() { + this.s3Client = mock(S3Client.class); + this.bucket = "test-bucket"; + this.s3Presigner = mock(S3Presigner.class); + this.fileClient = new FileClient(s3Client, bucket, s3Presigner); + } + + @Nested + class Upload { + + @Test + void 주어진_속성을_바탕으로_파일을_업로드한다() { + MultipartFile file = new MockMultipartFile("file", "test-file.jpg", "image/jpeg", new byte[0]); + String fileKey = "test-file-key.jpg"; + doReturn(PutObjectResponse.builder().build()) + .when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + + String actual = fileClient.upload(file, fileKey); + + assertThat(actual).isEqualTo(fileKey); + } + + @Test + void 업로드에_문제가_생길_경우_서비스_에러_처리를_한다() { + MultipartFile file = new MockMultipartFile("file", "test-file.jpg", "image/jpeg", new byte[0]); + String fileKey = "test-file-key.jpg"; + doThrow(SdkClientException.create("Upload failed")) + .when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + + BusinessException exception = assertThrows(BusinessException.class, () -> fileClient.upload(file, fileKey)); + + assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.FILE_UPLOAD_FAILED); + } + } + + @Nested + class GetPreSignedUrl { + + @Test + void 주어진_파일_Key에_대해_사전_서명된_URL을_반환한다() throws MalformedURLException { + String fileKey = "test-file-key.jpg"; + String expected = "https://example.com/test-file-key.jpg"; + doReturn(mockRequest(expected)).when(s3Presigner).presignGetObject(any(GetObjectPresignRequest.class)); + + String actual = fileClient.getPreSignedUrl(fileKey, Duration.ofMinutes(10)); + + assertThat(actual).isEqualTo(expected); + } + + private PresignedGetObjectRequest mockRequest(String url) throws MalformedURLException { + PresignedGetObjectRequest request = mock(PresignedGetObjectRequest.class); + doReturn(new URL(url)).when(request).url(); + return request; + } + + @Test + void 문제가_생길_경우_서비스_에러_처리를_한다() { + String fileKey = "test-file-key.jpg"; + doThrow(SdkClientException.create("Presigned URL generation failed")) + .when(s3Presigner).presignGetObject(any(GetObjectPresignRequest.class)); + + BusinessException exception = assertThrows(BusinessException.class, + () -> fileClient.getPreSignedUrl(fileKey, Duration.ofMinutes(10))); + + assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.PRESIGNED_URL_GENERATION_FAILED); + } + } +} From 3bf5be63cb0b527c84c03cc61229f1854dbbec76 Mon Sep 17 00:00:00 2001 From: leegwichan Date: Wed, 23 Jul 2025 17:38:17 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor=20:=20ExternalImageStorage=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EC=8B=A4=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 역할을 여러 객체로 나눔 --- src/main/java/eatda/domain/Image.java | 3 + .../eatda/exception/BusinessErrorCode.java | 1 + .../eatda/service/article/ArticleService.java | 4 +- .../eatda/service/store/CheerService.java | 10 +- .../eatda/service/store/StoreService.java | 3 +- .../eatda/service/story/StoryService.java | 10 +- .../image/CachePreSignedUrlStorage.java | 9 +- .../storage/image/ExternalImageStorage.java | 89 +++----------- .../eatda/storage/image/ImageStorage.java | 17 ++- .../eatda/controller/BaseControllerTest.java | 7 +- .../java/eatda/service/BaseServiceTest.java | 8 +- .../eatda/service/story/StoryServiceTest.java | 11 +- .../image/CachePreSignedUrlStorageTest.java | 51 ++++++++ .../image/ExternalImageStorageTest.java | 114 ++++-------------- .../eatda/storage/image/ImageStorageTest.java | 50 ++++---- 15 files changed, 166 insertions(+), 221 deletions(-) create mode 100644 src/test/java/eatda/storage/image/CachePreSignedUrlStorageTest.java diff --git a/src/main/java/eatda/domain/Image.java b/src/main/java/eatda/domain/Image.java index c080212b..fc036acd 100644 --- a/src/main/java/eatda/domain/Image.java +++ b/src/main/java/eatda/domain/Image.java @@ -3,8 +3,10 @@ import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; import java.util.Set; +import lombok.Getter; import org.springframework.web.multipart.MultipartFile; +@Getter public class Image { private static final Set ALLOWED_CONTENT_TYPES = Set.of("image/jpg", "image/jpeg", "image/png"); @@ -22,6 +24,7 @@ public Image(ImageDomain domain, MultipartFile file) { private void validateContentType(MultipartFile file) { if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) { + System.out.println("Invalid content type: " + file.getContentType()); throw new BusinessException(BusinessErrorCode.INVALID_IMAGE_TYPE); } } diff --git a/src/main/java/eatda/exception/BusinessErrorCode.java b/src/main/java/eatda/exception/BusinessErrorCode.java index b4a3b4e3..3e29f0e8 100644 --- a/src/main/java/eatda/exception/BusinessErrorCode.java +++ b/src/main/java/eatda/exception/BusinessErrorCode.java @@ -43,6 +43,7 @@ public enum BusinessErrorCode { FILE_UPLOAD_FAILED("SERVER002", "파일 업로드에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), FILE_URL_GENERATION_FAILED("SERVER003", "파일 URL 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), PRESIGNED_URL_GENERATION_FAILED("SERVER004", "Presigned URL 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + INVALID_IMAGE_KEY("IMG001", "이미지 Key 값이 비어있습니다", HttpStatus.INTERNAL_SERVER_ERROR), //story INVALID_STORY_DESCRIPTION("STY001", "스토리 본문은 필수입니다."), diff --git a/src/main/java/eatda/service/article/ArticleService.java b/src/main/java/eatda/service/article/ArticleService.java index 825bc8d6..cf0e836d 100644 --- a/src/main/java/eatda/service/article/ArticleService.java +++ b/src/main/java/eatda/service/article/ArticleService.java @@ -2,6 +2,8 @@ import eatda.controller.article.ArticleResponse; import eatda.controller.article.ArticlesResponse; +import eatda.domain.Image; +import eatda.domain.ImageKey; import eatda.repository.article.ArticleRepository; import eatda.storage.image.ImageStorage; import java.util.List; @@ -24,7 +26,7 @@ public ArticlesResponse getAllArticles(int size) { article.getTitle(), article.getSubtitle(), article.getArticleUrl(), - imageStorage.getPresignedUrl(article.getImageKey()) + imageStorage.getPreSignedUrl(new ImageKey(article.getImageKey())) )) .toList(); diff --git a/src/main/java/eatda/service/store/CheerService.java b/src/main/java/eatda/service/store/CheerService.java index 8050b9fa..6e28a8f9 100644 --- a/src/main/java/eatda/service/store/CheerService.java +++ b/src/main/java/eatda/service/store/CheerService.java @@ -6,7 +6,9 @@ import eatda.controller.store.CheerRegisterRequest; import eatda.controller.store.CheerResponse; import eatda.controller.store.CheersResponse; +import eatda.domain.Image; import eatda.domain.ImageDomain; +import eatda.domain.ImageKey; import eatda.domain.member.Member; import eatda.domain.store.Cheer; import eatda.domain.store.Store; @@ -37,18 +39,18 @@ public class CheerService { private final ImageStorage imageStorage; @Transactional - public CheerResponse registerCheer(CheerRegisterRequest request, MultipartFile image, long memberId) { + public CheerResponse registerCheer(CheerRegisterRequest request, MultipartFile imageFile, long memberId) { Member member = memberRepository.getById(memberId); validateRegisterCheer(member, request.storeKakaoId()); List searchResults = mapClient.searchShops(request.storeName()); StoreSearchResult result = storeSearchFilter.filterStoreByKakaoId(searchResults, request.storeKakaoId()); - String imageKey = imageStorage.upload(image, ImageDomain.CHEER); + String imageKey = imageStorage.upload(new Image(ImageDomain.CHEER, imageFile)).getValue(); Store store = storeRepository.findByKakaoId(result.kakaoId()) .orElseGet(() -> storeRepository.save(result.toStore())); // TODO 상점 조회/저장 동시성 이슈 해결 Cheer cheer = cheerRepository.save(new Cheer(member, store, request.description(), imageKey)); - return new CheerResponse(cheer, imageStorage.getPresignedUrl(imageKey), store); + return new CheerResponse(cheer, imageStorage.getPreSignedUrl(new ImageKey(imageKey)), store); } private void validateRegisterCheer(Member member, String storeKakaoId) { @@ -69,7 +71,7 @@ public CheersResponse getCheers(int size) { private CheersResponse toCheersResponse(List cheers) { return new CheersResponse(cheers.stream() .map(cheer -> new CheerPreviewResponse(cheer, cheer.getStore(), - imageStorage.getPresignedUrl(cheer.getImageKey()))) + imageStorage.getPreSignedUrl(new ImageKey(cheer.getImageKey())))) .toList()); } } diff --git a/src/main/java/eatda/service/store/StoreService.java b/src/main/java/eatda/service/store/StoreService.java index 7fd0927d..c4fad847 100644 --- a/src/main/java/eatda/service/store/StoreService.java +++ b/src/main/java/eatda/service/store/StoreService.java @@ -8,6 +8,7 @@ import eatda.controller.store.StorePreviewResponse; import eatda.controller.store.StoreSearchResponses; import eatda.controller.store.StoresResponse; +import eatda.domain.ImageKey; import eatda.domain.store.Store; import eatda.repository.store.CheerRepository; import eatda.repository.store.StoreRepository; @@ -38,7 +39,7 @@ public StoresResponse getStores(int size) { private Optional getStoreImageUrl(Store store) { return cheerRepository.findRecentImageKey(store) - .map(imageStorage::getPresignedUrl); + .map(key -> imageStorage.getPreSignedUrl(new ImageKey(key))); } public StoreSearchResponses searchStores(String query) { diff --git a/src/main/java/eatda/service/story/StoryService.java b/src/main/java/eatda/service/story/StoryService.java index 5f1183f0..b3a14ffe 100644 --- a/src/main/java/eatda/service/story/StoryService.java +++ b/src/main/java/eatda/service/story/StoryService.java @@ -7,7 +7,9 @@ import eatda.controller.story.StoryRegisterRequest; import eatda.controller.story.StoryRegisterResponse; import eatda.controller.story.StoryResponse; +import eatda.domain.Image; import eatda.domain.ImageDomain; +import eatda.domain.ImageKey; import eatda.domain.member.Member; import eatda.domain.story.Story; import eatda.exception.BusinessErrorCode; @@ -36,11 +38,11 @@ public class StoryService { private final MemberRepository memberRepository; @Transactional - public StoryRegisterResponse registerStory(StoryRegisterRequest request, MultipartFile image, Long memberId) { + public StoryRegisterResponse registerStory(StoryRegisterRequest request, MultipartFile imageFile, Long memberId) { Member member = memberRepository.getById(memberId); List searchResponses = mapClient.searchShops(request.query()); FilteredSearchResult matchedStore = filteredSearchResponse(searchResponses, request.storeKakaoId()); - String imageKey = imageStorage.upload(image, ImageDomain.STORY); + String imageKey = imageStorage.upload(new Image(ImageDomain.STORY, imageFile)).getValue(); Story story = Story.builder() .member(member) @@ -81,7 +83,7 @@ public StoriesResponse getPagedStoryPreviews(int size) { orderByPage.getContent().stream() .map(story -> new StoriesResponse.StoryPreview( story.getId(), - imageStorage.getPresignedUrl(story.getImageKey()) + imageStorage.getPreSignedUrl(new ImageKey(story.getImageKey())) )) .toList() ); @@ -99,7 +101,7 @@ public StoryResponse getStory(long storyId) { story.getAddressDistrict(), story.getAddressNeighborhood(), story.getDescription(), - imageStorage.getPresignedUrl(story.getImageKey()) + imageStorage.getPreSignedUrl(new ImageKey(story.getImageKey())) ); } } diff --git a/src/main/java/eatda/storage/image/CachePreSignedUrlStorage.java b/src/main/java/eatda/storage/image/CachePreSignedUrlStorage.java index 675b07e2..e20b8c91 100644 --- a/src/main/java/eatda/storage/image/CachePreSignedUrlStorage.java +++ b/src/main/java/eatda/storage/image/CachePreSignedUrlStorage.java @@ -1,5 +1,6 @@ package eatda.storage.image; +import eatda.domain.ImageKey; import eatda.repository.CacheSetting; import java.util.Optional; import org.springframework.cache.Cache; @@ -17,11 +18,11 @@ public CachePreSignedUrlStorage(CacheManager cacheManager) { this.cache = cacheManager.getCache(CACHE_NAME); } - public void put(String imageKey, String preSignedUrl) { - cache.put(imageKey, preSignedUrl); + public void put(ImageKey imageKey, String preSignedUrl) { + cache.put(imageKey.getValue(), preSignedUrl); } - public Optional get(String imageKey) { - return Optional.ofNullable(cache.get(imageKey, String.class)); + public Optional get(ImageKey imageKey) { + return Optional.ofNullable(cache.get(imageKey.getValue(), String.class)); } } diff --git a/src/main/java/eatda/storage/image/ExternalImageStorage.java b/src/main/java/eatda/storage/image/ExternalImageStorage.java index 68b896a0..5cc6369f 100644 --- a/src/main/java/eatda/storage/image/ExternalImageStorage.java +++ b/src/main/java/eatda/storage/image/ExternalImageStorage.java @@ -1,96 +1,37 @@ package eatda.storage.image; -import eatda.domain.ImageDomain; -import eatda.exception.BusinessErrorCode; -import eatda.exception.BusinessException; -import java.io.IOException; +import eatda.client.file.FileClient; +import eatda.domain.Image; +import eatda.domain.ImageKey; import java.time.Duration; -import java.util.Set; import java.util.UUID; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.GetObjectRequest; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; @Component public class ExternalImageStorage { - private static final Set ALLOWED_CONTENT_TYPES = Set.of("image/jpg", "image/jpeg", "image/png"); - private static final String DEFAULT_CONTENT_TYPE = "bin"; private static final String PATH_DELIMITER = "/"; private static final String EXTENSION_DELIMITER = "."; private static final Duration PRESIGNED_URL_DURATION = Duration.ofMinutes(30); - private final S3Client s3Client; - private final String bucket; - private final S3Presigner s3Presigner; + private final FileClient fileClient; - public ExternalImageStorage( - S3Client s3Client, - @Value("${spring.cloud.aws.s3.bucket}") String bucket, - S3Presigner s3Presigner) { - this.s3Client = s3Client; - this.bucket = bucket; - this.s3Presigner = s3Presigner; + public ExternalImageStorage(FileClient fileClient) { + this.fileClient = fileClient; } - public String upload(MultipartFile file, ImageDomain domain) { - validateContentType(file); - String extension = getExtension(file.getOriginalFilename()); - String uuid = UUID.randomUUID().toString(); - String key = domain.getName() + PATH_DELIMITER + uuid + EXTENSION_DELIMITER + extension; - - try { - PutObjectRequest request = PutObjectRequest.builder() - .bucket(bucket) - .key(key) - .contentType(file.getContentType()) - .build(); - - s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); - return key; - } catch (IOException exception) { - throw new BusinessException(BusinessErrorCode.FILE_UPLOAD_FAILED); - } - } - - private void validateContentType(MultipartFile file) { - String contentType = file.getContentType(); - if (!ALLOWED_CONTENT_TYPES.contains(contentType)) { - throw new BusinessException(BusinessErrorCode.INVALID_IMAGE_TYPE); - } + public ImageKey upload(Image image) { + String createdKey = createKey(image.getDomainName(), image.getExtension()); + fileClient.upload(image.getFile(), createdKey); + return new ImageKey(createdKey); } - private String getExtension(String filename) { - if (filename == null - || filename.lastIndexOf(EXTENSION_DELIMITER) == -1 - || filename.startsWith(EXTENSION_DELIMITER)) { - return DEFAULT_CONTENT_TYPE; - } - return filename.substring(filename.lastIndexOf(EXTENSION_DELIMITER) + 1); + private String createKey(String domainName, String extension) { + String uuid = UUID.randomUUID().toString(); + return domainName + PATH_DELIMITER + uuid + EXTENSION_DELIMITER + extension; } - - public String getPresignedUrl(String key) { - try { - GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(bucket) - .key(key) - .build(); - - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .getObjectRequest(getObjectRequest) - .signatureDuration(PRESIGNED_URL_DURATION) - .build(); - - return s3Presigner.presignGetObject(presignRequest).url().toString(); - } catch (Exception exception) { - throw new BusinessException(BusinessErrorCode.PRESIGNED_URL_GENERATION_FAILED); - } + public String getPreSignedUrl(ImageKey imageKey) { + return fileClient.getPreSignedUrl(imageKey.getValue(), PRESIGNED_URL_DURATION); } } diff --git a/src/main/java/eatda/storage/image/ImageStorage.java b/src/main/java/eatda/storage/image/ImageStorage.java index 2aea0b34..7d623fde 100644 --- a/src/main/java/eatda/storage/image/ImageStorage.java +++ b/src/main/java/eatda/storage/image/ImageStorage.java @@ -1,11 +1,11 @@ package eatda.storage.image; -import eatda.domain.ImageDomain; +import eatda.domain.Image; +import eatda.domain.ImageKey; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; @Component @RequiredArgsConstructor @@ -14,18 +14,17 @@ public class ImageStorage { private final ExternalImageStorage externalImageStorage; private final CachePreSignedUrlStorage cachePreSignedUrlStorage; + public ImageKey upload(Image image) { + ImageKey imageKey = externalImageStorage.upload(image); - public String upload(MultipartFile file, ImageDomain domain) { - String imageKey = externalImageStorage.upload(file, domain); - - String preSignedUrl = externalImageStorage.getPresignedUrl(imageKey); + String preSignedUrl = externalImageStorage.getPreSignedUrl(imageKey); cachePreSignedUrlStorage.put(imageKey, preSignedUrl); return imageKey; } @Nullable - public String getPresignedUrl(@Nullable String imageKey) { - if (imageKey == null || imageKey.isEmpty()) { + public String getPreSignedUrl(@Nullable ImageKey imageKey) { + if (imageKey == null) { return null; } @@ -34,7 +33,7 @@ public String getPresignedUrl(@Nullable String imageKey) { return cachedUrl.get(); } - String preSignedUrl = externalImageStorage.getPresignedUrl(imageKey); + String preSignedUrl = externalImageStorage.getPreSignedUrl(imageKey); cachePreSignedUrlStorage.put(imageKey, preSignedUrl); return preSignedUrl; } diff --git a/src/test/java/eatda/controller/BaseControllerTest.java b/src/test/java/eatda/controller/BaseControllerTest.java index cabb5f88..6d0b3174 100644 --- a/src/test/java/eatda/controller/BaseControllerTest.java +++ b/src/test/java/eatda/controller/BaseControllerTest.java @@ -11,6 +11,7 @@ import eatda.client.oauth.OauthMemberInformation; import eatda.client.oauth.OauthToken; import eatda.controller.web.jwt.JwtManager; +import eatda.domain.ImageKey; import eatda.domain.member.Member; import eatda.fixture.ArticleGenerator; import eatda.fixture.CheerGenerator; @@ -46,7 +47,7 @@ public class BaseControllerTest { private static final OauthToken DEFAULT_OAUTH_TOKEN = new OauthToken("oauth-access-token"); private static final OauthMemberInformation DEFAULT_OAUTH_MEMBER_INFO = new OauthMemberInformation(314159248183772L, "constant@kakao.com", "nickname"); - private static final String MOCKED_IMAGE_KEY = "mocked-image-path"; + private static final ImageKey MOCKED_IMAGE_KEY = new ImageKey("mocked-image-path"); private static final String MOCKED_IMAGE_URL = "https://example.com/image.jpg"; @@ -113,8 +114,8 @@ final void mockingClient() throws URISyntaxException { ); doReturn(searchResults).when(mapClient).searchShops(anyString()); - doReturn(MOCKED_IMAGE_URL).when(imageStorage).getPresignedUrl(anyString()); - doReturn(MOCKED_IMAGE_KEY).when(imageStorage).upload(any(), any()); + doReturn(MOCKED_IMAGE_URL).when(imageStorage).getPreSignedUrl(any()); + doReturn(MOCKED_IMAGE_KEY).when(imageStorage).upload(any()); } protected final RequestSpecification given() { diff --git a/src/test/java/eatda/service/BaseServiceTest.java b/src/test/java/eatda/service/BaseServiceTest.java index 7ec2ca9e..838f6e73 100644 --- a/src/test/java/eatda/service/BaseServiceTest.java +++ b/src/test/java/eatda/service/BaseServiceTest.java @@ -1,12 +1,12 @@ package eatda.service; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doReturn; import eatda.DatabaseCleaner; import eatda.client.map.MapClient; import eatda.client.oauth.OauthClient; +import eatda.domain.ImageKey; import eatda.fixture.ArticleGenerator; import eatda.fixture.CheerGenerator; import eatda.fixture.MemberGenerator; @@ -26,7 +26,7 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) public abstract class BaseServiceTest { - private static final String MOCKED_IMAGE_KEY = "mocked-image-path"; + private static final ImageKey MOCKED_IMAGE_KEY = new ImageKey("mocked-image-key"); private static final String MOCKED_IMAGE_URL = "https://example.com/image.jpg"; @MockitoBean @@ -64,7 +64,7 @@ public abstract class BaseServiceTest { @BeforeEach void mockingImageService() { - doReturn(MOCKED_IMAGE_URL).when(externalImageStorage).getPresignedUrl(anyString()); - doReturn(MOCKED_IMAGE_KEY).when(externalImageStorage).upload(any(), any()); + doReturn(MOCKED_IMAGE_URL).when(externalImageStorage).getPreSignedUrl(any()); + doReturn(MOCKED_IMAGE_KEY).when(externalImageStorage).upload(any()); } } diff --git a/src/test/java/eatda/service/story/StoryServiceTest.java b/src/test/java/eatda/service/story/StoryServiceTest.java index 72562037..c5fd7945 100644 --- a/src/test/java/eatda/service/story/StoryServiceTest.java +++ b/src/test/java/eatda/service/story/StoryServiceTest.java @@ -10,7 +10,9 @@ import eatda.controller.story.StoriesResponse.StoryPreview; import eatda.controller.story.StoryRegisterRequest; import eatda.controller.story.StoryResponse; +import eatda.domain.Image; import eatda.domain.ImageDomain; +import eatda.domain.ImageKey; import eatda.domain.member.Member; import eatda.domain.store.StoreCategory; import eatda.domain.story.Story; @@ -36,7 +38,8 @@ class RegisterStory { void 스토리_등록에_성공한다() { Member member = memberGenerator.generate("12345"); StoryRegisterRequest request = new StoryRegisterRequest("곱창", "123", "미쳤다 여기"); - MultipartFile image = mock(MultipartFile.class); + MultipartFile imageFile = mock(MultipartFile.class); + Image image = new Image(ImageDomain.STORY, imageFile); StoreSearchResult store = new StoreSearchResult( "123", "FD6", "음식점 > 한식", "010-1234-5678", @@ -44,9 +47,9 @@ class RegisterStory { "서울 강남구", "서울 강남구", 37.0, 127.0 ); doReturn(List.of(store)).when(mapClient).searchShops(request.query()); - when(externalImageStorage.upload(image, ImageDomain.STORY)).thenReturn("image-key"); + when(externalImageStorage.upload(image)).thenReturn(new ImageKey("image-key")); - var response = storyService.registerStory(request, image, member.getId()); + var response = storyService.registerStory(request, imageFile, member.getId()); assertThat(storyRepository.existsById(response.storyId())).isTrue(); } @@ -122,7 +125,7 @@ class GetStory { storyRepository.save(story); - when(externalImageStorage.getPresignedUrl("story-image-key")) + when(externalImageStorage.getPreSignedUrl(new ImageKey("story-image-key"))) .thenReturn("https://s3.bucket.com/story/dummy/1.jpg"); StoryResponse response = storyService.getStory(story.getId()); diff --git a/src/test/java/eatda/storage/image/CachePreSignedUrlStorageTest.java b/src/test/java/eatda/storage/image/CachePreSignedUrlStorageTest.java new file mode 100644 index 00000000..f20929b9 --- /dev/null +++ b/src/test/java/eatda/storage/image/CachePreSignedUrlStorageTest.java @@ -0,0 +1,51 @@ +package eatda.storage.image; + +import static org.assertj.core.api.Assertions.assertThat; + +import eatda.domain.ImageKey; +import eatda.repository.CacheSetting; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.cache.concurrent.ConcurrentMapCache; +import org.springframework.cache.support.SimpleCacheManager; + +class CachePreSignedUrlStorageTest { + + private CachePreSignedUrlStorage cachePreSignedUrlStorage; + + @BeforeEach + void setUp() { + SimpleCacheManager cacheManager = new SimpleCacheManager(); + cacheManager.setCaches(List.of(new ConcurrentMapCache(CacheSetting.IMAGE.getName()))); + cacheManager.initializeCaches(); + this.cachePreSignedUrlStorage = new CachePreSignedUrlStorage(cacheManager); + } + + @Nested + class PutAndGet { + + @Test + void 넣은_값을_가져올_수_있다() { + String key = "story/550e8400-e29b-41d4-a716-446655440000.jpg"; + String value = "https://example.com/presigned-url"; + cachePreSignedUrlStorage.put(new ImageKey(key), value); + + Optional result = cachePreSignedUrlStorage.get(new ImageKey(key)); + + assertThat(result).isPresent() + .hasValue(value); + } + + @Test + void 존재하지_않는_키를_가져오면_빈_옵셔널을_반환한다() { + String key = "nonExistentKey"; + + Optional result = cachePreSignedUrlStorage.get(new ImageKey(key)); + + assertThat(result).isEmpty(); + } + } +} diff --git a/src/test/java/eatda/storage/image/ExternalImageStorageTest.java b/src/test/java/eatda/storage/image/ExternalImageStorageTest.java index 43764855..6d58a9cd 100644 --- a/src/test/java/eatda/storage/image/ExternalImageStorageTest.java +++ b/src/test/java/eatda/storage/image/ExternalImageStorageTest.java @@ -1,87 +1,45 @@ package eatda.storage.image; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import eatda.client.file.FileClient; +import eatda.domain.Image; import eatda.domain.ImageDomain; -import eatda.exception.BusinessErrorCode; -import eatda.exception.BusinessException; -import java.net.URL; +import eatda.domain.ImageKey; import java.time.Duration; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; -import org.mockito.ArgumentCaptor; import org.springframework.mock.web.MockMultipartFile; -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; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; -import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; class ExternalImageStorageTest { - private static final String TEST_BUCKET = "test-bucket"; - - private S3Client s3Client; - private S3Presigner s3Presigner; + private FileClient fileClient; private ExternalImageStorage externalImageStorage; @BeforeEach void setUp() { - s3Client = mock(S3Client.class); - s3Presigner = mock(S3Presigner.class); - externalImageStorage = new ExternalImageStorage(s3Client, TEST_BUCKET, s3Presigner); + fileClient = mock(FileClient.class); + externalImageStorage = new ExternalImageStorage(fileClient); } @Nested - class FileUpload { - - @ParameterizedTest - @EnumSource(ImageDomain.class) - void 허용된_이미지_타입이면_정상적으로_업로드되고_생성된_Key를_반환한다(ImageDomain imageDomain) { - String originalFilename = "test-image.jpg"; - String contentType = "image/jpeg"; - - MockMultipartFile file = new MockMultipartFile( - "image", originalFilename, contentType, "image-content".getBytes() - ); - - String key = externalImageStorage.upload(file, imageDomain); - - ArgumentCaptor putObjectRequestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); - verify(s3Client).putObject(putObjectRequestCaptor.capture(), any(RequestBody.class)); - PutObjectRequest capturedRequest = putObjectRequestCaptor.getValue(); - - String expectedPattern = imageDomain.getName() + "/[a-f0-9\\-]{36}\\.jpg"; - - assertAll( - () -> assertThat(key).matches(expectedPattern), - () -> assertThat(capturedRequest.key()).isEqualTo(key), - () -> assertThat(capturedRequest.bucket()).isEqualTo(TEST_BUCKET), - () -> assertThat(capturedRequest.contentType()).isEqualTo(contentType) - ); - } + class Upload { @Test - void 허용되지_않은_파일_타입이면_BusinessException을_던진다() { - MockMultipartFile file = new MockMultipartFile( - "file", "test.txt", "text/plain", "file-content".getBytes() - ); + void 형식에_맞는_이미지_키가_생성된다() { + MockMultipartFile file = new MockMultipartFile("file", "image.jpg", "image/jpeg", new byte[0]); + ImageDomain domain = ImageDomain.STORY; + Image image = new Image(domain, file); + doReturn("uploaded-key").when(fileClient).upload(eq(file), any()); - BusinessException exception = assertThrows(BusinessException.class, - () -> externalImageStorage.upload(file, ImageDomain.STORY)); + ImageKey imageKey = externalImageStorage.upload(image); - assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.INVALID_IMAGE_TYPE); + assertThat(imageKey.getValue()).matches("story/[0-9a-f-]{36}\\.jpg"); } } @@ -89,43 +47,15 @@ class FileUpload { class GeneratePresignedUrl { @Test - void 유효한_key로_요청_시_Presigned_URL을_성공적으로_반환한다() throws Exception { - String key = "stores/image.jpg"; + void 특정_기간에_해당하는_Presigned_URL이_생성된다() { + ImageKey key = new ImageKey("stores/image.jpg"); + Duration preSignedUrlduration = Duration.ofMinutes(30); String expectedUrlString = "https://example.com/presigned-url-for-image.jpg"; - URL expectedUrl = new URL(expectedUrlString); - - PresignedGetObjectRequest presignedRequestResult = mock(PresignedGetObjectRequest.class); - - when(presignedRequestResult.url()).thenReturn(expectedUrl); - when(s3Presigner.presignGetObject(any(GetObjectPresignRequest.class))) - .thenReturn(presignedRequestResult); - - String presignedUrl = externalImageStorage.getPresignedUrl(key); - - ArgumentCaptor presignRequestCaptor = - ArgumentCaptor.forClass(GetObjectPresignRequest.class); - verify(s3Presigner).presignGetObject(presignRequestCaptor.capture()); - GetObjectPresignRequest capturedPresignRequest = presignRequestCaptor.getValue(); - - assertAll( - () -> assertThat(presignedUrl).isEqualTo(expectedUrlString), - () -> assertThat(capturedPresignRequest.getObjectRequest().key()).isEqualTo(key), - () -> assertThat(capturedPresignRequest.getObjectRequest().bucket()).isEqualTo(TEST_BUCKET), - () -> assertThat(capturedPresignRequest.signatureDuration()).isEqualTo(Duration.ofMinutes(30)) - ); - } - - @Test - void Presigner가_예외를_던지면_BusinessException으로_전환하여_던진다() { - String key = "stores/image.jpg"; - - when(s3Presigner.presignGetObject(any(GetObjectPresignRequest.class))) - .thenThrow(SdkClientException.create("AWS SDK 통신 실패")); + doReturn(expectedUrlString).when(fileClient).getPreSignedUrl(key.getValue(), preSignedUrlduration); - BusinessException exception = assertThrows(BusinessException.class, - () -> externalImageStorage.getPresignedUrl(key)); + String actual = externalImageStorage.getPreSignedUrl(key); - assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.PRESIGNED_URL_GENERATION_FAILED); + assertThat(actual).isEqualTo(expectedUrlString); } } } diff --git a/src/test/java/eatda/storage/image/ImageStorageTest.java b/src/test/java/eatda/storage/image/ImageStorageTest.java index 57f21826..fd8e9fff 100644 --- a/src/test/java/eatda/storage/image/ImageStorageTest.java +++ b/src/test/java/eatda/storage/image/ImageStorageTest.java @@ -2,29 +2,31 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import eatda.client.file.FileClient; +import eatda.domain.Image; import eatda.domain.ImageDomain; +import eatda.domain.ImageKey; import eatda.storage.BaseStorageTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.NullAndEmptySource; import org.springframework.mock.web.MockMultipartFile; class ImageStorageTest extends BaseStorageTest { + private FileClient fileClient; private ExternalImageStorage externalImageStorage; private CachePreSignedUrlStorage cachePreSignedUrlStorage; private ImageStorage imageStorage; @BeforeEach void setUp() { + fileClient = mock(FileClient.class); externalImageStorage = mock(ExternalImageStorage.class); cachePreSignedUrlStorage = new CachePreSignedUrlStorage(getCacheManager()); imageStorage = new ImageStorage(externalImageStorage, cachePreSignedUrlStorage); @@ -38,11 +40,13 @@ class Upload { MockMultipartFile file = new MockMultipartFile( "image", "test-image.jpg", "image/jpeg", "image-content".getBytes() ); - doReturn("test-image-key").when(externalImageStorage).upload(file, ImageDomain.MEMBER); + Image image = new Image(ImageDomain.MEMBER, file); + ImageKey expectedImageKey = new ImageKey("test-image-key"); + doReturn(expectedImageKey).when(externalImageStorage).upload(image); - String imageKey = imageStorage.upload(file, ImageDomain.MEMBER); + ImageKey actualImageKey = imageStorage.upload(image); - assertThat(imageKey).isEqualTo("test-image-key"); + assertThat(actualImageKey).isEqualTo(expectedImageKey); } @Test @@ -50,45 +54,49 @@ class Upload { MockMultipartFile file = new MockMultipartFile( "image", "test-image.jpg", "image/jpeg", "image-content".getBytes() ); - doReturn("test-image-key").when(externalImageStorage).upload(file, ImageDomain.MEMBER); - doReturn("https://example.url.com").when(externalImageStorage).getPresignedUrl("test-image-key"); + Image image = new Image(ImageDomain.MEMBER, file); + ImageKey imageKey = new ImageKey("test-image-key"); + doReturn(imageKey).when(externalImageStorage).upload(image); + doReturn("https://example.url.com").when(externalImageStorage).getPreSignedUrl(imageKey); - imageStorage.upload(file, ImageDomain.MEMBER); + imageStorage.upload(image); - assertThat(cachePreSignedUrlStorage.get("test-image-key")).contains("https://example.url.com"); + assertThat(cachePreSignedUrlStorage.get(new ImageKey("test-image-key"))).contains( + "https://example.url.com"); } } @Nested - class GetPresignedUrl { + class GetPreSignedUrl { - @ParameterizedTest - @NullAndEmptySource - void 이미지_키가_null이면__null을_반환한다(String imageKey) { - String actual = imageStorage.getPresignedUrl(imageKey); + @Test + void 이미지_키가_null이면__null을_반환한다() { + ImageKey imageKey = null; + + String actual = imageStorage.getPreSignedUrl(imageKey); assertThat(actual).isNull(); } @Test void 이미지_키가_캐시에_존재하면_s3에_요청하지_않고_PreSignedUrl을_반환한다() { - String imageKey = "test-image-key"; + ImageKey imageKey = new ImageKey("test-image-key"); cachePreSignedUrlStorage.put(imageKey, "https://example.url.com"); - String preSignedUrl = imageStorage.getPresignedUrl(imageKey); + String preSignedUrl = imageStorage.getPreSignedUrl(imageKey); assertAll( () -> assertThat(preSignedUrl).isEqualTo("https://example.url.com"), - () -> verify(externalImageStorage, never()).getPresignedUrl(anyString()) + () -> verify(externalImageStorage, never()).getPreSignedUrl(imageKey) ); } @Test void 이미지_키가_캐시에_존재하지_않으면_S3에서_PreSignedUrl을_조회하고_캐시에_저장한다() { - String imageKey = "test-image-key"; - doReturn("https://example.url.com").when(externalImageStorage).getPresignedUrl(imageKey); + ImageKey imageKey = new ImageKey("test-image-key"); + doReturn("https://example.url.com").when(externalImageStorage).getPreSignedUrl(imageKey); - String preSignedUrl = imageStorage.getPresignedUrl(imageKey); + String preSignedUrl = imageStorage.getPreSignedUrl(imageKey); assertAll( () -> assertThat(preSignedUrl).isEqualTo("https://example.url.com"), From 13aaf2236d7274300e0438395cea39b2f2c73d3f Mon Sep 17 00:00:00 2001 From: leegwichan Date: Wed, 23 Jul 2025 19:34:35 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor=20:=20`Cheer`,=20`Story`=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=20`ImageKey`=20=EB=A5=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/store/CheerController.java | 1 + src/main/java/eatda/domain/Image.java | 10 +- src/main/java/eatda/domain/ImageKey.java | 29 ++- src/main/java/eatda/domain/store/Cheer.java | 17 +- src/main/java/eatda/domain/story/Story.java | 16 +- .../eatda/exception/BusinessErrorCode.java | 1 - .../repository/store/CheerRepository.java | 3 +- .../eatda/service/store/CheerService.java | 6 +- .../eatda/service/store/StoreService.java | 3 +- .../eatda/service/story/StoryService.java | 6 +- .../eatda/storage/image/ImageStorage.java | 6 +- .../controller/store/CheerControllerTest.java | 2 +- src/test/java/eatda/domain/ImageKeyTest.java | 19 +- src/test/java/eatda/domain/ImageTest.java | 8 + .../java/eatda/domain/store/CheerTest.java | 19 +- .../java/eatda/domain/story/StoryTest.java | 168 +++++++----------- .../java/eatda/fixture/CheerGenerator.java | 7 +- .../repository/store/CheerRepositoryTest.java | 7 +- .../eatda/service/store/CheerServiceTest.java | 2 +- .../eatda/service/story/StoryServiceTest.java | 14 +- .../eatda/storage/image/ImageStorageTest.java | 12 +- 21 files changed, 169 insertions(+), 187 deletions(-) diff --git a/src/main/java/eatda/controller/store/CheerController.java b/src/main/java/eatda/controller/store/CheerController.java index d5ee2e82..313f41dd 100644 --- a/src/main/java/eatda/controller/store/CheerController.java +++ b/src/main/java/eatda/controller/store/CheerController.java @@ -24,6 +24,7 @@ public class CheerController { public ResponseEntity registerCheer(@RequestPart("request") CheerRegisterRequest request, @RequestPart(value = "image", required = false) MultipartFile image, LoginMember member) { + System.out.println("Received image: " + image); CheerResponse response = cheerService.registerCheer(request, image, member.id()); return ResponseEntity.status(HttpStatus.CREATED) .body(response); diff --git a/src/main/java/eatda/domain/Image.java b/src/main/java/eatda/domain/Image.java index fc036acd..ef51331b 100644 --- a/src/main/java/eatda/domain/Image.java +++ b/src/main/java/eatda/domain/Image.java @@ -4,6 +4,7 @@ import eatda.exception.BusinessException; import java.util.Set; import lombok.Getter; +import org.springframework.lang.Nullable; import org.springframework.web.multipart.MultipartFile; @Getter @@ -16,15 +17,14 @@ public class Image { private final ImageDomain domain; private final MultipartFile file; - public Image(ImageDomain domain, MultipartFile file) { + public Image(ImageDomain domain, @Nullable MultipartFile file) { validateContentType(file); this.domain = domain; this.file = file; } private void validateContentType(MultipartFile file) { - if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) { - System.out.println("Invalid content type: " + file.getContentType()); + if (file != null && !ALLOWED_CONTENT_TYPES.contains(file.getContentType())) { throw new BusinessException(BusinessErrorCode.INVALID_IMAGE_TYPE); } } @@ -43,4 +43,8 @@ public String getExtension() { public String getDomainName() { return domain.getName(); } + + public boolean isEmpty() { + return file == null || file.isEmpty(); + } } diff --git a/src/main/java/eatda/domain/ImageKey.java b/src/main/java/eatda/domain/ImageKey.java index ad15de9f..45369358 100644 --- a/src/main/java/eatda/domain/ImageKey.java +++ b/src/main/java/eatda/domain/ImageKey.java @@ -1,9 +1,8 @@ package eatda.domain; -import eatda.exception.BusinessErrorCode; -import eatda.exception.BusinessException; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; +import java.util.Objects; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -13,13 +12,31 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ImageKey { - @Column(name = "image_key", nullable = false) + @Column(name = "image_key", length = 511) private String value; public ImageKey(String value) { - if (value == null || value.isBlank()) { - throw new BusinessException(BusinessErrorCode.INVALID_IMAGE_KEY); - } this.value = value; } + + public boolean isEmpty() { + return value == null || value.isBlank(); + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (object == null || getClass() != object.getClass()) { + return false; + } + ImageKey imageKey = (ImageKey) object; + return Objects.equals(value, imageKey.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } } diff --git a/src/main/java/eatda/domain/store/Cheer.java b/src/main/java/eatda/domain/store/Cheer.java index 9339dc4d..93c3b165 100644 --- a/src/main/java/eatda/domain/store/Cheer.java +++ b/src/main/java/eatda/domain/store/Cheer.java @@ -1,10 +1,12 @@ package eatda.domain.store; import eatda.domain.AuditingEntity; +import eatda.domain.ImageKey; import eatda.domain.member.Member; import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; import jakarta.persistence.Column; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -38,15 +40,14 @@ public class Cheer extends AuditingEntity { @Column(nullable = false, columnDefinition = "TEXT") private String description; - @Column(name = "image_key", length = 511) - private String imageKey; + @Embedded + private ImageKey imageKey; @Column(name = "is_admin", nullable = false) private boolean isAdmin; - public Cheer(Member member, Store store, String description, String imageKey) { + public Cheer(Member member, Store store, String description, ImageKey imageKey) { validateDescription(description); - validateImageKey(imageKey); this.member = member; this.store = store; this.description = description; @@ -55,7 +56,7 @@ public Cheer(Member member, Store store, String description, String imageKey) { this.isAdmin = false; } - public Cheer(Member member, Store store, String description, String imageKey, boolean isAdmin) { + public Cheer(Member member, Store store, String description, ImageKey imageKey, boolean isAdmin) { this(member, store, description, imageKey); this.isAdmin = isAdmin; } @@ -65,10 +66,4 @@ private void validateDescription(String description) { throw new BusinessException(BusinessErrorCode.INVALID_CHEER_DESCRIPTION); } } - - private void validateImageKey(String imageKey) { - if (imageKey != null && imageKey.isBlank()) { - throw new BusinessException(BusinessErrorCode.INVALID_CHEER_IMAGE_KEY); - } - } } diff --git a/src/main/java/eatda/domain/story/Story.java b/src/main/java/eatda/domain/story/Story.java index f37121ee..3957d13b 100644 --- a/src/main/java/eatda/domain/story/Story.java +++ b/src/main/java/eatda/domain/story/Story.java @@ -1,11 +1,13 @@ package eatda.domain.story; import eatda.domain.AuditingEntity; +import eatda.domain.ImageKey; import eatda.domain.member.Member; import eatda.domain.store.StoreCategory; import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; import jakarta.persistence.Column; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -54,8 +56,8 @@ public class Story extends AuditingEntity { @Column(name = "description", nullable = false) private String description; - @Column(name = "image_key", nullable = false) - private String imageKey; + @Embedded + private ImageKey imageKey; @Builder private Story( @@ -66,7 +68,7 @@ private Story( String storeRoadAddress, String storeLotNumberAddress, String description, - String imageKey + ImageKey imageKey ) { validateMember(member); validateStore(storeKakaoId, storeCategory, storeName, storeRoadAddress, storeLotNumberAddress); @@ -102,7 +104,7 @@ private void validateStore( validateStoreLotNumberAddress(lotNumberAddress); } - private void validateStory(String description, String imageKey) { + private void validateStory(String description, ImageKey imageKey) { validateDescription(description); validateImage(imageKey); } @@ -120,7 +122,7 @@ private void validateStoreName(String storeName) { } private void validateStoreRoadAddress(String roadAddress) { - if (roadAddress == null || roadAddress.isBlank()) { + if (roadAddress == null) { throw new BusinessException(BusinessErrorCode.INVALID_STORE_ADDRESS); } } @@ -143,8 +145,8 @@ private void validateDescription(String description) { } } - private void validateImage(String imageKey) { - if (imageKey == null || imageKey.isBlank()) { + private void validateImage(ImageKey imageKey) { + if (imageKey == null || imageKey.isEmpty()) { throw new BusinessException(BusinessErrorCode.INVALID_STORY_IMAGE_KEY); } } diff --git a/src/main/java/eatda/exception/BusinessErrorCode.java b/src/main/java/eatda/exception/BusinessErrorCode.java index 3e29f0e8..b4a3b4e3 100644 --- a/src/main/java/eatda/exception/BusinessErrorCode.java +++ b/src/main/java/eatda/exception/BusinessErrorCode.java @@ -43,7 +43,6 @@ public enum BusinessErrorCode { FILE_UPLOAD_FAILED("SERVER002", "파일 업로드에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), FILE_URL_GENERATION_FAILED("SERVER003", "파일 URL 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), PRESIGNED_URL_GENERATION_FAILED("SERVER004", "Presigned URL 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), - INVALID_IMAGE_KEY("IMG001", "이미지 Key 값이 비어있습니다", HttpStatus.INTERNAL_SERVER_ERROR), //story INVALID_STORY_DESCRIPTION("STY001", "스토리 본문은 필수입니다."), diff --git a/src/main/java/eatda/repository/store/CheerRepository.java b/src/main/java/eatda/repository/store/CheerRepository.java index 1a188f41..4758629f 100644 --- a/src/main/java/eatda/repository/store/CheerRepository.java +++ b/src/main/java/eatda/repository/store/CheerRepository.java @@ -1,5 +1,6 @@ package eatda.repository.store; +import eatda.domain.ImageKey; import eatda.domain.member.Member; import eatda.domain.store.Cheer; import eatda.domain.store.Store; @@ -20,7 +21,7 @@ public interface CheerRepository extends Repository { WHERE c.store = :store AND c.imageKey IS NOT NULL ORDER BY c.createdAt DESC LIMIT 1""") - Optional findRecentImageKey(Store store); + Optional findRecentImageKey(Store store); int countByMember(Member member); diff --git a/src/main/java/eatda/service/store/CheerService.java b/src/main/java/eatda/service/store/CheerService.java index 6e28a8f9..5af8ea5b 100644 --- a/src/main/java/eatda/service/store/CheerService.java +++ b/src/main/java/eatda/service/store/CheerService.java @@ -45,12 +45,12 @@ public CheerResponse registerCheer(CheerRegisterRequest request, MultipartFile i List searchResults = mapClient.searchShops(request.storeName()); StoreSearchResult result = storeSearchFilter.filterStoreByKakaoId(searchResults, request.storeKakaoId()); - String imageKey = imageStorage.upload(new Image(ImageDomain.CHEER, imageFile)).getValue(); + ImageKey imageKey = imageStorage.upload(new Image(ImageDomain.CHEER, imageFile)); Store store = storeRepository.findByKakaoId(result.kakaoId()) .orElseGet(() -> storeRepository.save(result.toStore())); // TODO 상점 조회/저장 동시성 이슈 해결 Cheer cheer = cheerRepository.save(new Cheer(member, store, request.description(), imageKey)); - return new CheerResponse(cheer, imageStorage.getPreSignedUrl(new ImageKey(imageKey)), store); + return new CheerResponse(cheer, imageStorage.getPreSignedUrl(imageKey), store); } private void validateRegisterCheer(Member member, String storeKakaoId) { @@ -71,7 +71,7 @@ public CheersResponse getCheers(int size) { private CheersResponse toCheersResponse(List cheers) { return new CheersResponse(cheers.stream() .map(cheer -> new CheerPreviewResponse(cheer, cheer.getStore(), - imageStorage.getPreSignedUrl(new ImageKey(cheer.getImageKey())))) + imageStorage.getPreSignedUrl(cheer.getImageKey()))) .toList()); } } diff --git a/src/main/java/eatda/service/store/StoreService.java b/src/main/java/eatda/service/store/StoreService.java index c4fad847..440cbb83 100644 --- a/src/main/java/eatda/service/store/StoreService.java +++ b/src/main/java/eatda/service/store/StoreService.java @@ -8,7 +8,6 @@ import eatda.controller.store.StorePreviewResponse; import eatda.controller.store.StoreSearchResponses; import eatda.controller.store.StoresResponse; -import eatda.domain.ImageKey; import eatda.domain.store.Store; import eatda.repository.store.CheerRepository; import eatda.repository.store.StoreRepository; @@ -39,7 +38,7 @@ public StoresResponse getStores(int size) { private Optional getStoreImageUrl(Store store) { return cheerRepository.findRecentImageKey(store) - .map(key -> imageStorage.getPreSignedUrl(new ImageKey(key))); + .map(imageStorage::getPreSignedUrl); } public StoreSearchResponses searchStores(String query) { diff --git a/src/main/java/eatda/service/story/StoryService.java b/src/main/java/eatda/service/story/StoryService.java index b3a14ffe..1ab85f1e 100644 --- a/src/main/java/eatda/service/story/StoryService.java +++ b/src/main/java/eatda/service/story/StoryService.java @@ -42,7 +42,7 @@ public StoryRegisterResponse registerStory(StoryRegisterRequest request, Multipa Member member = memberRepository.getById(memberId); List searchResponses = mapClient.searchShops(request.query()); FilteredSearchResult matchedStore = filteredSearchResponse(searchResponses, request.storeKakaoId()); - String imageKey = imageStorage.upload(new Image(ImageDomain.STORY, imageFile)).getValue(); + ImageKey imageKey = imageStorage.upload(new Image(ImageDomain.STORY, imageFile)); Story story = Story.builder() .member(member) @@ -83,7 +83,7 @@ public StoriesResponse getPagedStoryPreviews(int size) { orderByPage.getContent().stream() .map(story -> new StoriesResponse.StoryPreview( story.getId(), - imageStorage.getPreSignedUrl(new ImageKey(story.getImageKey())) + imageStorage.getPreSignedUrl(story.getImageKey()) )) .toList() ); @@ -101,7 +101,7 @@ public StoryResponse getStory(long storyId) { story.getAddressDistrict(), story.getAddressNeighborhood(), story.getDescription(), - imageStorage.getPreSignedUrl(new ImageKey(story.getImageKey())) + imageStorage.getPreSignedUrl(story.getImageKey()) ); } } diff --git a/src/main/java/eatda/storage/image/ImageStorage.java b/src/main/java/eatda/storage/image/ImageStorage.java index 7d623fde..6629f05b 100644 --- a/src/main/java/eatda/storage/image/ImageStorage.java +++ b/src/main/java/eatda/storage/image/ImageStorage.java @@ -15,6 +15,10 @@ public class ImageStorage { private final CachePreSignedUrlStorage cachePreSignedUrlStorage; public ImageKey upload(Image image) { + if (image == null || image.isEmpty()) { + return new ImageKey(null); + } + ImageKey imageKey = externalImageStorage.upload(image); String preSignedUrl = externalImageStorage.getPreSignedUrl(imageKey); @@ -24,7 +28,7 @@ public ImageKey upload(Image image) { @Nullable public String getPreSignedUrl(@Nullable ImageKey imageKey) { - if (imageKey == null) { + if (imageKey == null || imageKey.isEmpty()) { return null; } diff --git a/src/test/java/eatda/controller/store/CheerControllerTest.java b/src/test/java/eatda/controller/store/CheerControllerTest.java index b619006a..1746555d 100644 --- a/src/test/java/eatda/controller/store/CheerControllerTest.java +++ b/src/test/java/eatda/controller/store/CheerControllerTest.java @@ -27,7 +27,7 @@ class RegisterCheer { .header(HttpHeaders.AUTHORIZATION, accessToken()) .contentType("multipart/form-data") .multiPart("request", "request.json", MappingUtils.toJsonBytes(request), "application/json") - .multiPart("image", ImageUtils.getTestImage()) + .multiPart("image", ImageUtils.getTestImage(), "image/png") .when() .post("/api/cheer") .then() diff --git a/src/test/java/eatda/domain/ImageKeyTest.java b/src/test/java/eatda/domain/ImageKeyTest.java index 1f3d0dc1..fafd64ba 100644 --- a/src/test/java/eatda/domain/ImageKeyTest.java +++ b/src/test/java/eatda/domain/ImageKeyTest.java @@ -1,10 +1,7 @@ package eatda.domain; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; -import eatda.exception.BusinessErrorCode; -import eatda.exception.BusinessException; import org.junit.jupiter.api.Nested; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullAndEmptySource; @@ -13,25 +10,29 @@ class ImageKeyTest { @Nested - class Validate { + class IsEmpty { @ParameterizedTest @NullAndEmptySource @ValueSource(strings = {" ", "\t", "\n\t"}) - void 이미지_키값이_비어있다면_예외를_던진다(String value) { - BusinessException exception = assertThrows(BusinessException.class, () -> new ImageKey(value)); + void 이미지_키값이_비어있다(String value) { + ImageKey imageKey = new ImageKey(value); + + boolean actual = imageKey.isEmpty(); - assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.INVALID_IMAGE_KEY); + assertThat(actual).isTrue(); } @ParameterizedTest @ValueSource(strings = { "story/550e8400-e29b-41d4-a716-446655440000.jpg", "cheer/550e8400-e29b-41d4-a716-446655440111.png"}) - void 이미지_키값이_유효하다면_예외를_던지지_않는다(String value) { + void 이미지_키값이_비어있지_않다(String value) { ImageKey imageKey = new ImageKey(value); - assertThat(imageKey.getValue()).isEqualTo(value); + boolean actual = imageKey.isEmpty(); + + assertThat(actual).isFalse(); } } } diff --git a/src/test/java/eatda/domain/ImageTest.java b/src/test/java/eatda/domain/ImageTest.java index d1e5537c..9ca0b1d5 100644 --- a/src/test/java/eatda/domain/ImageTest.java +++ b/src/test/java/eatda/domain/ImageTest.java @@ -7,6 +7,7 @@ import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; @@ -38,6 +39,13 @@ class Validate { assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.INVALID_IMAGE_TYPE); } + + @Test + void 파일은_비어있을_수_있다() { + ImageDomain domain = ImageDomain.STORY; + + assertThatCode(() -> new Image(domain, null)).doesNotThrowAnyException(); + } } @Nested diff --git a/src/test/java/eatda/domain/store/CheerTest.java b/src/test/java/eatda/domain/store/CheerTest.java index 261ffd54..71527d50 100644 --- a/src/test/java/eatda/domain/store/CheerTest.java +++ b/src/test/java/eatda/domain/store/CheerTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.junit.jupiter.api.Assertions.assertThrows; +import eatda.domain.ImageKey; import eatda.domain.member.Member; import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; @@ -11,7 +12,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullAndEmptySource; -import org.junit.jupiter.params.provider.ValueSource; class CheerTest { @@ -34,9 +34,10 @@ class Validate { @ParameterizedTest @NullAndEmptySource void 설명이_비어있으면_안된다(String description) { - BusinessException exception = assertThrows(BusinessException.class, () -> { - new Cheer(DEFAULT_MEMBER, DEFAULT_STORE, description, "imageKey"); - }); + ImageKey imageKey = new ImageKey("imageKey"); + + BusinessException exception = assertThrows(BusinessException.class, + () -> new Cheer(DEFAULT_MEMBER, DEFAULT_STORE, description, imageKey)); assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.INVALID_CHEER_DESCRIPTION); } @@ -46,15 +47,5 @@ class Validate { assertThatCode(() -> new Cheer(DEFAULT_MEMBER, DEFAULT_STORE, "Great store!", null)) .doesNotThrowAnyException(); } - - @ParameterizedTest - @ValueSource(strings = {"", " ", "\t\n"}) - void 이미지_키는_비어있으면_안된다(String imageKey) { - BusinessException exception = assertThrows(BusinessException.class, () -> { - new Cheer(DEFAULT_MEMBER, DEFAULT_STORE, "Great store!", imageKey); - }); - - assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.INVALID_CHEER_IMAGE_KEY); - } } } diff --git a/src/test/java/eatda/domain/story/StoryTest.java b/src/test/java/eatda/domain/story/StoryTest.java index a2d44db7..a1bdd363 100644 --- a/src/test/java/eatda/domain/story/StoryTest.java +++ b/src/test/java/eatda/domain/story/StoryTest.java @@ -3,33 +3,43 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import eatda.domain.ImageKey; import eatda.domain.member.Member; import eatda.domain.store.StoreCategory; import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mockito; class StoryTest { private static final Member MEMBER = Mockito.mock(Member.class); + private Story.StoryBuilder defaultStoryBuilder; + + @BeforeEach + void setUpBuilder() { + this.defaultStoryBuilder = Story.builder() + .member(MEMBER) + .storeKakaoId("123") + .storeName("곱창집") + .storeRoadAddress("서울시 성동구 왕십리로 1길 12") + .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") + .storeCategory(StoreCategory.KOREAN) + .description("정말 맛있어요") + .imageKey(new ImageKey("story/image.jpg")); + } @Nested class RegisterStory { @Test void 스토리를_정상적으로_생성한다() { - Story story = Story.builder() - .member(MEMBER) - .storeKakaoId("123") - .storeName("곱창집") - .storeRoadAddress("서울시 성동구 왕십리로 1길 12") - .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") - .storeCategory(StoreCategory.KOREAN) - .description("정말 맛있어요") - .imageKey("story/image.jpg") - .build(); + Story story = defaultStoryBuilder.build(); assertThat(story.getStoreName()).isEqualTo("곱창집"); assertThat(story.getDescription()).isEqualTo("정말 맛있어요"); @@ -41,17 +51,9 @@ class ValidateMember { @Test void 회원이_null이면_예외가_발생한다() { - assertThatThrownBy(() -> - Story.builder() - .member(null) - .storeKakaoId("123") - .storeName("곱창집") - .storeRoadAddress("서울시 성동구 왕십리로 1길 12") - .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") - .storeCategory(StoreCategory.KOREAN) - .description("정말 맛있어요") - .imageKey("story/image.jpg") - .build() + assertThatThrownBy(() -> defaultStoryBuilder + .member(null) + .build() ).isInstanceOf(BusinessException.class) .hasMessage(BusinessErrorCode.STORY_MEMBER_REQUIRED.getMessage()); } @@ -60,87 +62,53 @@ class ValidateMember { @Nested class ValidateStore { - @Test - void 가게_ID가_비어있으면_예외가_발생한다() { - assertThatThrownBy(() -> - Story.builder() - .member(MEMBER) - .storeKakaoId(" ") - .storeName("곱창집") - .storeRoadAddress("서울시 성동구 왕십리로 1길 12") - .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") - .storeCategory(StoreCategory.KOREAN) - .description("맛있음") - .imageKey("story/image.jpg") - .build() + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"\t", " "}) + void 가게_ID가_비어있으면_예외가_발생한다(String emptyKakaoId) { + assertThatThrownBy(() -> defaultStoryBuilder + .storeKakaoId(emptyKakaoId) + .build() ).isInstanceOf(BusinessException.class) .hasMessage(BusinessErrorCode.INVALID_STORE_KAKAO_ID.getMessage()); } - @Test - void 가게_이름이_비어있으면_예외가_발생한다() { - assertThatThrownBy(() -> - Story.builder() - .member(MEMBER) - .storeKakaoId("123") - .storeCategory(StoreCategory.KOREAN) - .storeName(" ") - .storeRoadAddress("서울시 성동구 왕십리로 1길 12") - .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") - .description("맛있음") - .imageKey("story/image.jpg") - .build() + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"\t", " "}) + void 가게_이름이_비어있으면_예외가_발생한다(String emptyStoreName) { + assertThatThrownBy(() -> defaultStoryBuilder + .storeName(emptyStoreName) + .build() ).isInstanceOf(BusinessException.class) .hasMessage(BusinessErrorCode.INVALID_STORE_NAME.getMessage()); } @Test - void 도로명_주소가_비어있으면_예외가_발생한다() { - assertThatThrownBy(() -> - Story.builder() - .member(MEMBER) - .storeKakaoId("123") - .storeCategory(StoreCategory.KOREAN) - .storeName("곱창집") - .storeRoadAddress(" ") - .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") - .description("맛있음") - .imageKey("story/image.jpg") - .build() + void 도로명_주소가_Null이면_예외가_발생한다() { + assertThatThrownBy(() -> defaultStoryBuilder + .storeRoadAddress(null) + .build() ).isInstanceOf(BusinessException.class) .hasMessage(BusinessErrorCode.INVALID_STORE_ADDRESS.getMessage()); } - @Test - void 지번_주소가_비어있으면_예외가_발생한다() { - assertThatThrownBy(() -> - Story.builder() - .member(MEMBER) - .storeKakaoId("123") - .storeCategory(StoreCategory.KOREAN) - .storeName("곱창집") - .storeRoadAddress("서울시 성동구 왕십리로 1길 12") - .storeLotNumberAddress(" ") - .description("맛있음") - .imageKey("story/image.jpg") - .build() + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"\t", " "}) + void 지번_주소가_비어있으면_예외가_발생한다(String emptyLotNumberAddress) { + assertThatThrownBy(() -> defaultStoryBuilder + .storeLotNumberAddress(emptyLotNumberAddress) + .build() ).isInstanceOf(BusinessException.class) .hasMessage(BusinessErrorCode.INVALID_STORE_ADDRESS.getMessage()); } @Test void 가게_카테고리가_비어있으면_예외가_발생한다() { - assertThatThrownBy(() -> - Story.builder() - .member(MEMBER) - .storeKakaoId("123") - .storeCategory(null) - .storeName("곱창집") - .storeRoadAddress("서울시 성동구 왕십리로 1길 12") - .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") - .description("맛있음") - .imageKey("story/image.jpg") - .build() + assertThatThrownBy(() -> defaultStoryBuilder + .storeCategory(null) + .build() ).isInstanceOf(BusinessException.class) .hasMessage(BusinessErrorCode.INVALID_STORE_CATEGORY.getMessage()); } @@ -149,36 +117,22 @@ class ValidateStore { @Nested class ValidateStory { - @Test - void 설명이_비어있으면_예외가_발생한다() { - assertThatThrownBy(() -> - Story.builder() - .member(MEMBER) - .storeKakaoId("123") - .storeName("곱창집") - .storeRoadAddress("서울시 성동구 왕십리로 1길 12") - .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") - .storeCategory(StoreCategory.KOREAN) - .description(" ") - .imageKey("story/image.jpg") - .build() + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"\t", " "}) + void 설명이_비어있으면_예외가_발생한다(String emptyDescription) { + assertThatThrownBy(() -> defaultStoryBuilder + .description(emptyDescription) + .build() ).isInstanceOf(BusinessException.class) .hasMessage(BusinessErrorCode.INVALID_STORY_DESCRIPTION.getMessage()); } @Test void 이미지가_비어있으면_예외가_발생한다() { - assertThatThrownBy(() -> - Story.builder() - .member(MEMBER) - .storeKakaoId("123") - .storeName("곱창집") - .storeRoadAddress("서울시 성동구 왕십리로 1길 12") - .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") - .storeCategory(StoreCategory.KOREAN) - .description("맛있음") - .imageKey(" ") - .build() + assertThatThrownBy(() -> defaultStoryBuilder + .imageKey(null) + .build() ).isInstanceOf(BusinessException.class) .hasMessage(BusinessErrorCode.INVALID_STORY_IMAGE_KEY.getMessage()); } diff --git a/src/test/java/eatda/fixture/CheerGenerator.java b/src/test/java/eatda/fixture/CheerGenerator.java index 1a3af6a7..ddb7a388 100644 --- a/src/test/java/eatda/fixture/CheerGenerator.java +++ b/src/test/java/eatda/fixture/CheerGenerator.java @@ -1,5 +1,6 @@ package eatda.fixture; +import eatda.domain.ImageKey; import eatda.domain.member.Member; import eatda.domain.store.Cheer; import eatda.domain.store.Store; @@ -9,7 +10,7 @@ @Component public class CheerGenerator { - private static final String DEFAULT_IMAGE_KEY = "default-image-key"; + private static final String DEFAULT_IMAGE_KEY = "generator-cheer-image-key"; private static final String DEFAULT_DESCRIPTION = "응원합니다!"; private final CheerRepository cheerRepository; @@ -19,7 +20,7 @@ public CheerGenerator(CheerRepository cheerRepository) { } public Cheer generateAdmin(Member member, Store store) { - Cheer cheer = new Cheer(member, store, DEFAULT_DESCRIPTION, DEFAULT_IMAGE_KEY, true); + Cheer cheer = new Cheer(member, store, DEFAULT_DESCRIPTION, new ImageKey(DEFAULT_IMAGE_KEY), true); return cheerRepository.save(cheer); } @@ -28,7 +29,7 @@ public Cheer generateCommon(Member member, Store store) { } public Cheer generateCommon(Member member, Store store, String imageKey) { - Cheer cheer = new Cheer(member, store, DEFAULT_DESCRIPTION, imageKey, false); + Cheer cheer = new Cheer(member, store, DEFAULT_DESCRIPTION, new ImageKey(imageKey), false); return cheerRepository.save(cheer); } } diff --git a/src/test/java/eatda/repository/store/CheerRepositoryTest.java b/src/test/java/eatda/repository/store/CheerRepositoryTest.java index 88b8ef31..3130c377 100644 --- a/src/test/java/eatda/repository/store/CheerRepositoryTest.java +++ b/src/test/java/eatda/repository/store/CheerRepositoryTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import eatda.domain.ImageKey; import eatda.domain.member.Member; import eatda.domain.store.Store; import eatda.repository.BaseRepositoryTest; @@ -23,9 +24,9 @@ class FindRecentImageKey { cheerGenerator.generateCommon(member, store, "image-key-2"); cheerGenerator.generateCommon(member, store, null); - Optional imageKey = cheerRepository.findRecentImageKey(store); + Optional imageKey = cheerRepository.findRecentImageKey(store); - assertThat(imageKey).contains("image-key-2"); + assertThat(imageKey).contains(new ImageKey("image-key-2")); } @Test @@ -36,7 +37,7 @@ class FindRecentImageKey { cheerGenerator.generateCommon(member, store, null); cheerGenerator.generateCommon(member, store, null); - Optional imageKey = cheerRepository.findRecentImageKey(store); + Optional imageKey = cheerRepository.findRecentImageKey(store); assertThat(imageKey).isEmpty(); } diff --git a/src/test/java/eatda/service/store/CheerServiceTest.java b/src/test/java/eatda/service/store/CheerServiceTest.java index a89c26ad..95aa2c79 100644 --- a/src/test/java/eatda/service/store/CheerServiceTest.java +++ b/src/test/java/eatda/service/store/CheerServiceTest.java @@ -121,7 +121,7 @@ void mockingImage() { assertAll( () -> assertThat(response.storeId()).isEqualTo(foundStore.getId()), () -> assertThat(response.cheerDescription()).isEqualTo("맛있어요!"), - () -> assertThat(response.imageUrl()).isNotNull() + () -> assertThat(response.imageUrl()).isNull() ); } } diff --git a/src/test/java/eatda/service/story/StoryServiceTest.java b/src/test/java/eatda/service/story/StoryServiceTest.java index c5fd7945..44093f44 100644 --- a/src/test/java/eatda/service/story/StoryServiceTest.java +++ b/src/test/java/eatda/service/story/StoryServiceTest.java @@ -10,8 +10,6 @@ import eatda.controller.story.StoriesResponse.StoryPreview; import eatda.controller.story.StoryRegisterRequest; import eatda.controller.story.StoryResponse; -import eatda.domain.Image; -import eatda.domain.ImageDomain; import eatda.domain.ImageKey; import eatda.domain.member.Member; import eatda.domain.store.StoreCategory; @@ -24,6 +22,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; public class StoryServiceTest extends BaseServiceTest { @@ -38,8 +37,8 @@ class RegisterStory { void 스토리_등록에_성공한다() { Member member = memberGenerator.generate("12345"); StoryRegisterRequest request = new StoryRegisterRequest("곱창", "123", "미쳤다 여기"); - MultipartFile imageFile = mock(MultipartFile.class); - Image image = new Image(ImageDomain.STORY, imageFile); + MultipartFile imageFile = new MockMultipartFile( + "image", "story-image.jpg", "image/jpeg", new byte[]{1, 2}); StoreSearchResult store = new StoreSearchResult( "123", "FD6", "음식점 > 한식", "010-1234-5678", @@ -47,7 +46,6 @@ class RegisterStory { "서울 강남구", "서울 강남구", 37.0, 127.0 ); doReturn(List.of(store)).when(mapClient).searchShops(request.query()); - when(externalImageStorage.upload(image)).thenReturn(new ImageKey("image-key")); var response = storyService.registerStory(request, imageFile, member.getId()); @@ -82,7 +80,7 @@ class GetPagedStoryPreviews { .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") .storeCategory(StoreCategory.KOREAN) .description("미쳤다 진짜") - .imageKey("image-key-1") + .imageKey(new ImageKey("image-key-1")) .build(); Story story2 = Story.builder() .member(member) @@ -92,7 +90,7 @@ class GetPagedStoryPreviews { .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") .storeCategory(StoreCategory.KOREAN) .description("뜨끈한 국밥 최고") - .imageKey("image-key-2") + .imageKey(new ImageKey("image-key-2")) .build(); storyRepository.saveAll(List.of(story1, story2)); @@ -120,7 +118,7 @@ class GetStory { .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") .storeCategory(StoreCategory.KOREAN) .description("곱창은 여기") - .imageKey("story-image-key") + .imageKey(new ImageKey("story-image-key")) .build(); storyRepository.save(story); diff --git a/src/test/java/eatda/storage/image/ImageStorageTest.java b/src/test/java/eatda/storage/image/ImageStorageTest.java index fd8e9fff..99b6af32 100644 --- a/src/test/java/eatda/storage/image/ImageStorageTest.java +++ b/src/test/java/eatda/storage/image/ImageStorageTest.java @@ -7,7 +7,6 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import eatda.client.file.FileClient; import eatda.domain.Image; import eatda.domain.ImageDomain; import eatda.domain.ImageKey; @@ -19,14 +18,12 @@ class ImageStorageTest extends BaseStorageTest { - private FileClient fileClient; private ExternalImageStorage externalImageStorage; private CachePreSignedUrlStorage cachePreSignedUrlStorage; private ImageStorage imageStorage; @BeforeEach void setUp() { - fileClient = mock(FileClient.class); externalImageStorage = mock(ExternalImageStorage.class); cachePreSignedUrlStorage = new CachePreSignedUrlStorage(getCacheManager()); imageStorage = new ImageStorage(externalImageStorage, cachePreSignedUrlStorage); @@ -78,6 +75,15 @@ class GetPreSignedUrl { assertThat(actual).isNull(); } + @Test + void 이미지_키가_비어있으면_null을_반환한다() { + ImageKey imageKey = new ImageKey(""); + + String actual = imageStorage.getPreSignedUrl(imageKey); + + assertThat(actual).isNull(); + } + @Test void 이미지_키가_캐시에_존재하면_s3에_요청하지_않고_PreSignedUrl을_반환한다() { ImageKey imageKey = new ImageKey("test-image-key"); From d2460673e38f7b49e1431ea5f9f284bc2a06307c Mon Sep 17 00:00:00 2001 From: leegwichan Date: Wed, 23 Jul 2025 19:42:42 +0900 Subject: [PATCH 5/6] =?UTF-8?q?test:=20DocumentTest=20=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=98=95=EC=8B=9D=20=EC=A7=80?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/eatda/controller/store/CheerController.java | 1 - src/test/java/eatda/controller/story/StoryControllerTest.java | 2 +- src/test/java/eatda/document/store/CheerDocumentTest.java | 4 ++-- src/test/java/eatda/document/story/StoryDocumentTest.java | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/java/eatda/controller/store/CheerController.java b/src/main/java/eatda/controller/store/CheerController.java index 313f41dd..d5ee2e82 100644 --- a/src/main/java/eatda/controller/store/CheerController.java +++ b/src/main/java/eatda/controller/store/CheerController.java @@ -24,7 +24,6 @@ public class CheerController { public ResponseEntity registerCheer(@RequestPart("request") CheerRegisterRequest request, @RequestPart(value = "image", required = false) MultipartFile image, LoginMember member) { - System.out.println("Received image: " + image); CheerResponse response = cheerService.registerCheer(request, image, member.id()); return ResponseEntity.status(HttpStatus.CREATED) .body(response); diff --git a/src/test/java/eatda/controller/story/StoryControllerTest.java b/src/test/java/eatda/controller/story/StoryControllerTest.java index 39c0aedc..d8846e66 100644 --- a/src/test/java/eatda/controller/story/StoryControllerTest.java +++ b/src/test/java/eatda/controller/story/StoryControllerTest.java @@ -42,7 +42,7 @@ class RegisterStory { .contentType("multipart/form-data") .header("Authorization", accessToken()) .multiPart("request", "request.json", MappingUtils.toJsonBytes(request), "application/json") - .multiPart("image", ImageUtils.getTestImage()) + .multiPart("image", ImageUtils.getTestImage(), "image/png") .when() .post("/api/stories"); diff --git a/src/test/java/eatda/document/store/CheerDocumentTest.java b/src/test/java/eatda/document/store/CheerDocumentTest.java index d9fabda7..65f3a5f7 100644 --- a/src/test/java/eatda/document/store/CheerDocumentTest.java +++ b/src/test/java/eatda/document/store/CheerDocumentTest.java @@ -76,7 +76,7 @@ class RegisterCheer { .header(HttpHeaders.AUTHORIZATION, accessToken()) .contentType("multipart/form-data") .multiPart("request", "request.json", MappingUtils.toJsonBytes(request), "application/json") - .multiPart("image", ImageUtils.getTestImage()) + .multiPart("image", ImageUtils.getTestImage(), "image/png") .when().post("/api/cheer") .then().statusCode(201); } @@ -105,7 +105,7 @@ class RegisterCheer { .header(HttpHeaders.AUTHORIZATION, accessToken()) .contentType(ContentType.MULTIPART) .multiPart("request", "request.json", MappingUtils.toJsonBytes(request), "application/json") - .multiPart("image", ImageUtils.getTestImage()) + .multiPart("image", ImageUtils.getTestImage(), "image/png") .when().post("/api/cheer") .then().statusCode(errorCode.getStatus().value()); } diff --git a/src/test/java/eatda/document/story/StoryDocumentTest.java b/src/test/java/eatda/document/story/StoryDocumentTest.java index f8fde542..146d7865 100644 --- a/src/test/java/eatda/document/story/StoryDocumentTest.java +++ b/src/test/java/eatda/document/story/StoryDocumentTest.java @@ -68,7 +68,7 @@ class RegisterStory { .header(HttpHeaders.AUTHORIZATION, accessToken()) .contentType("multipart/form-data") .multiPart("request", "request.json", MappingUtils.toJsonBytes(request), "application/json") - .multiPart("image", ImageUtils.getTestImage()) + .multiPart("image", ImageUtils.getTestImage(), "image/png") .when().post("/api/stories") .then().statusCode(201); } From 00e4f3d8757e68526bfda8c520c3e178a1dfdab0 Mon Sep 17 00:00:00 2001 From: leegwichan Date: Wed, 23 Jul 2025 19:54:16 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor=20:=20Article=20=EC=97=90=20ImageK?= =?UTF-8?q?ey=20=EA=B0=9D=EC=B2=B4=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/eatda/domain/article/Article.java | 10 +++++++--- src/main/java/eatda/domain/story/Story.java | 2 ++ .../java/eatda/service/article/ArticleService.java | 4 +--- src/test/java/eatda/fixture/ArticleGenerator.java | 13 ++----------- 4 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/main/java/eatda/domain/article/Article.java b/src/main/java/eatda/domain/article/Article.java index 9e59e72a..ba70616d 100644 --- a/src/main/java/eatda/domain/article/Article.java +++ b/src/main/java/eatda/domain/article/Article.java @@ -1,12 +1,15 @@ package eatda.domain.article; import eatda.domain.AuditingEntity; +import eatda.domain.ImageKey; import jakarta.persistence.Column; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -30,10 +33,11 @@ public class Article extends AuditingEntity { @Column(name = "article_url", nullable = false, length = 511) private String articleUrl; - @Column(name = "image_key", nullable = false, length = 511) - private String imageKey; + @NotNull + @Embedded + private ImageKey imageKey; - public Article(String title, String subtitle, String articleUrl, String imageKey) { + public Article(String title, String subtitle, String articleUrl, ImageKey imageKey) { this.title = title; this.subtitle = subtitle; this.articleUrl = articleUrl; diff --git a/src/main/java/eatda/domain/story/Story.java b/src/main/java/eatda/domain/story/Story.java index 3957d13b..112df8ae 100644 --- a/src/main/java/eatda/domain/story/Story.java +++ b/src/main/java/eatda/domain/story/Story.java @@ -18,6 +18,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -56,6 +57,7 @@ public class Story extends AuditingEntity { @Column(name = "description", nullable = false) private String description; + @NotNull @Embedded private ImageKey imageKey; diff --git a/src/main/java/eatda/service/article/ArticleService.java b/src/main/java/eatda/service/article/ArticleService.java index cf0e836d..0fd947a9 100644 --- a/src/main/java/eatda/service/article/ArticleService.java +++ b/src/main/java/eatda/service/article/ArticleService.java @@ -2,8 +2,6 @@ import eatda.controller.article.ArticleResponse; import eatda.controller.article.ArticlesResponse; -import eatda.domain.Image; -import eatda.domain.ImageKey; import eatda.repository.article.ArticleRepository; import eatda.storage.image.ImageStorage; import java.util.List; @@ -26,7 +24,7 @@ public ArticlesResponse getAllArticles(int size) { article.getTitle(), article.getSubtitle(), article.getArticleUrl(), - imageStorage.getPreSignedUrl(new ImageKey(article.getImageKey())) + imageStorage.getPreSignedUrl(article.getImageKey()) )) .toList(); diff --git a/src/test/java/eatda/fixture/ArticleGenerator.java b/src/test/java/eatda/fixture/ArticleGenerator.java index 538133f7..87c051fd 100644 --- a/src/test/java/eatda/fixture/ArticleGenerator.java +++ b/src/test/java/eatda/fixture/ArticleGenerator.java @@ -1,6 +1,6 @@ package eatda.fixture; -import eatda.controller.article.ArticleResponse; +import eatda.domain.ImageKey; import eatda.domain.article.Article; import eatda.repository.article.ArticleRepository; import org.springframework.stereotype.Component; @@ -36,16 +36,7 @@ public Article generate(String title, String subtitle, String articleUrl) { } public Article generate(String title, String subtitle, String articleUrl, String imageKey) { - Article article = new Article(title, subtitle, articleUrl, imageKey); + Article article = new Article(title, subtitle, articleUrl, new ImageKey(imageKey)); return articleRepository.save(article); } - - public ArticleResponse toResponse(Article article) { - return new ArticleResponse( - article.getTitle(), - article.getSubtitle(), - article.getArticleUrl(), - "https://s3.bucket.com/" + article.getImageKey() - ); - } }