From 3a2b8f21d017011add510ef1e73397babdc956f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Wed, 5 Aug 2026 20:34:59 +0900 Subject: [PATCH] =?UTF-8?q?test:=20BannerService/S3Service=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?+=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=20=ED=81=AC=EB=9E=98=EC=8B=9C=20=EB=B2=84=EA=B7=B8=202?= =?UTF-8?q?=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BannerService 2개, S3Service.uploadImage 7개, 총 9개 신규 테스트. 테스트 작성 중 발견해서 수정한 버그 (S3Service.getFileExtension): 1. 파일명이 null이면 length() 호출에서 NPE → 500. null/빈 문자열 체크 추가. 2. 파일명에 "."가 없으면 lastIndexOf가 -1을 반환하고 그 값 그대로 substring(-1)을 호출해 StringIndexOutOfBoundsException → 500. "."가 없는 경우를 먼저 걸러내도록 수정. 둘 다 원래는 "잘못된 이미지 파일입니다"(400/404)로 처리됐어야 할 케이스가 서버 에러로 새어나가고 있었음. ## 별도로 확인만 하고 고치지 않은 것 S3Service.deleteFile()의 `imageUrl.substring(49)`는 폴더명/버킷명 길이에 따라 늘 달라지는 값이라 하드코딩된 49는 구조적으로 항상 맞을 수 없음. 다만 이 메서드를 실제로 호출하는 곳이 코드베이스 전체에 없어(dead code) 지금 당장 영향은 없음. 실제 URL 포맷을 확인 없이 숫자만 바꾸는 건 오히려 위험해서, 이번엔 고치지 않고 플래그만 해둠 — deleteFile을 실제로 쓰기 전에 반드시 점검 필요. --- .../server/external/aws/S3Service.java | 12 +- .../banner/service/BannerServiceTest.java | 58 +++++++++ .../server/external/aws/S3ServiceTest.java | 120 ++++++++++++++++++ 3 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 src/test/java/org/runnect/server/banner/service/BannerServiceTest.java create mode 100644 src/test/java/org/runnect/server/external/aws/S3ServiceTest.java diff --git a/src/main/java/org/runnect/server/external/aws/S3Service.java b/src/main/java/org/runnect/server/external/aws/S3Service.java index bf4e48b..bb6d65c 100644 --- a/src/main/java/org/runnect/server/external/aws/S3Service.java +++ b/src/main/java/org/runnect/server/external/aws/S3Service.java @@ -70,9 +70,15 @@ private String createFileName(String fileName) { // 파일 유효성 검사 private String getFileExtension(String fileName) { - if (fileName.length() == 0) { + if (fileName == null || fileName.isEmpty()) { throw new NotFoundException(ErrorStatus.NOT_FOUND_IMAGE_EXCEPTION, ErrorStatus.NOT_FOUND_IMAGE_EXCEPTION.getMessage()); } + int dotIndex = fileName.lastIndexOf("."); + // 파일명에 "."가 아예 없으면(lastIndexOf가 -1) substring(-1)에서 그대로 + // StringIndexOutOfBoundsException(500)이 나던 부분 — 확장자 없는 파일로 명확히 처리한다. + if (dotIndex == -1) { + throw new BadRequestException(ErrorStatus.NOT_FOUND_IMAGE_EXCEPTION, ErrorStatus.NOT_FOUND_IMAGE_EXCEPTION.getMessage()); + } ArrayList fileValidate = new ArrayList<>(); fileValidate.add(".jpg"); fileValidate.add(".jpeg"); @@ -80,11 +86,11 @@ private String getFileExtension(String fileName) { fileValidate.add(".JPG"); fileValidate.add(".JPEG"); fileValidate.add(".PNG"); - String idxFileName = fileName.substring(fileName.lastIndexOf(".")); + String idxFileName = fileName.substring(dotIndex); if (!fileValidate.contains(idxFileName)) { throw new BadRequestException(ErrorStatus.NOT_FOUND_IMAGE_EXCEPTION, ErrorStatus.NOT_FOUND_IMAGE_EXCEPTION.getMessage()); } - return fileName.substring(fileName.lastIndexOf(".")); + return idxFileName; } // 이미지 삭제 diff --git a/src/test/java/org/runnect/server/banner/service/BannerServiceTest.java b/src/test/java/org/runnect/server/banner/service/BannerServiceTest.java new file mode 100644 index 0000000..d9fd731 --- /dev/null +++ b/src/test/java/org/runnect/server/banner/service/BannerServiceTest.java @@ -0,0 +1,58 @@ +package org.runnect.server.banner.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.Collections; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.runnect.server.banner.dto.response.GetBannerResponseDto; +import org.runnect.server.banner.entity.Banner; +import org.runnect.server.banner.repository.BannerRepository; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BannerServiceTest { + + @Mock + private BannerRepository bannerRepository; + + private BannerService bannerService; + + private Banner buildBanner(Long id, String imageUrl, String linkUrl, int sortOrder) { + Banner banner = Banner.builder().imageUrl(imageUrl).linkUrl(linkUrl).sortOrder(sortOrder).build(); + ReflectionTestUtils.setField(banner, "id", id); + return banner; + } + + @Test + void 활성_배너를_정렬된_순서_그대로_0부터_인덱싱해서_반환한다() { + bannerService = new BannerService(bannerRepository); + Banner first = buildBanner(1L, "image1.png", "https://a.com", 0); + Banner second = buildBanner(2L, "image2.png", "https://b.com", 1); + when(bannerRepository.findByIsActiveTrueOrderBySortOrderAscIdAsc()) + .thenReturn(Arrays.asList(first, second)); + + GetBannerResponseDto response = bannerService.getBanners(); + + assertThat(response.getBanners()).hasSize(2); + assertThat(response.getBanners().get(0).getIndex()).isEqualTo(0); + assertThat(response.getBanners().get(0).getImageUrl()).isEqualTo("image1.png"); + assertThat(response.getBanners().get(1).getIndex()).isEqualTo(1); + assertThat(response.getBanners().get(1).getLinkUrl()).isEqualTo("https://b.com"); + } + + @Test + void 활성_배너가_없으면_빈_목록을_반환한다() { + bannerService = new BannerService(bannerRepository); + when(bannerRepository.findByIsActiveTrueOrderBySortOrderAscIdAsc()) + .thenReturn(Collections.emptyList()); + + GetBannerResponseDto response = bannerService.getBanners(); + + assertThat(response.getBanners()).isEmpty(); + } +} diff --git a/src/test/java/org/runnect/server/external/aws/S3ServiceTest.java b/src/test/java/org/runnect/server/external/aws/S3ServiceTest.java new file mode 100644 index 0000000..dd8b9d4 --- /dev/null +++ b/src/test/java/org/runnect/server/external/aws/S3ServiceTest.java @@ -0,0 +1,120 @@ +package org.runnect.server.external.aws; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.PutObjectRequest; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.runnect.server.common.exception.BadRequestException; +import org.runnect.server.common.exception.NotFoundException; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +@ExtendWith(MockitoExtension.class) +class S3ServiceTest { + + @Mock + private AmazonS3 amazonS3; + + private S3Service s3Service; + + @BeforeEach + void setUp() { + s3Service = new S3Service(amazonS3); + ReflectionTestUtils.setField(s3Service, "bucket", "runnect-test-bucket"); + } + + private void stubUploadedUrl() throws Exception { + when(amazonS3.getUrl(any(), any())).thenReturn( + new URL("https://runnect-test-bucket.s3.ap-northeast-2.amazonaws.com/course/image/test.jpg")); + } + + @Nested + @DisplayName("uploadImage") + class UploadImage { + + @Test + @DisplayName("정상적인 이미지 파일이면 업로드하고 URL을 반환한다") + void 정상_업로드() throws Exception { + stubUploadedUrl(); + MultipartFile file = new MockMultipartFile("image", "photo.jpg", "image/jpeg", "content".getBytes()); + + String url = s3Service.uploadImage(file, "course"); + + assertThat(url).isEqualTo( + "https://runnect-test-bucket.s3.ap-northeast-2.amazonaws.com/course/image/test.jpg"); + } + + @Test + @DisplayName("대문자 확장자(.PNG)도 정상 업로드된다") + void 대문자_확장자() throws Exception { + stubUploadedUrl(); + MultipartFile file = new MockMultipartFile("image", "photo.PNG", "image/png", "content".getBytes()); + + assertThat(s3Service.uploadImage(file, "course")).isNotNull(); + } + + @Test + @DisplayName("지원하지 않는 확장자면 BadRequestException") + void 지원하지_않는_확장자() { + MultipartFile file = new MockMultipartFile("image", "photo.gif", "image/gif", "content".getBytes()); + + assertThatThrownBy(() -> s3Service.uploadImage(file, "course")) + .isInstanceOf(BadRequestException.class); + } + + @Test + @DisplayName("[버그 수정 검증] 파일명이 null이면 500(NPE) 대신 NotFoundException") + void 파일명이_null() { + MultipartFile file = new MockMultipartFile("image", null, "image/jpeg", "content".getBytes()); + + assertThatThrownBy(() -> s3Service.uploadImage(file, "course")) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("파일명이 빈 문자열이면 NotFoundException") + void 파일명이_빈문자열() { + MultipartFile file = new MockMultipartFile("image", "", "image/jpeg", "content".getBytes()); + + assertThatThrownBy(() -> s3Service.uploadImage(file, "course")) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("[버그 수정 검증] 확장자(.)가 없는 파일명이면 500(StringIndexOutOfBoundsException) 대신 BadRequestException") + void 확장자가_없는_파일명() { + MultipartFile file = new MockMultipartFile("image", "photo_without_extension", "image/jpeg", + "content".getBytes()); + + assertThatThrownBy(() -> s3Service.uploadImage(file, "course")) + .isInstanceOf(BadRequestException.class); + } + + @Test + @DisplayName("파일 스트림을 읽는 중 오류가 나면 NotFoundException") + void 스트림_읽기_실패() throws IOException { + MultipartFile file = mock(MultipartFile.class); + when(file.getOriginalFilename()).thenReturn("photo.jpg"); + when(file.getInputStream()).thenThrow(new IOException("disk error")); + + assertThatThrownBy(() -> s3Service.uploadImage(file, "course")) + .isInstanceOf(NotFoundException.class); + } + } +}