diff --git a/BACK/spring-app/build.gradle b/BACK/spring-app/build.gradle index c9afb3a..0d8ec50 100644 --- a/BACK/spring-app/build.gradle +++ b/BACK/spring-app/build.gradle @@ -29,6 +29,7 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' compileOnly 'org.projectlombok:lombok' @@ -38,6 +39,8 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.findify:s3mock_2.13:0.2.6' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } @@ -45,3 +48,9 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +tasks.withType(Test) { + systemProperty "spring.profiles.active", "test" +} + + diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/category/controller/CategoryController.java b/BACK/spring-app/src/main/java/com/starchive/springapp/category/controller/CategoryController.java index a9e6cde..85e4938 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/category/controller/CategoryController.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/category/controller/CategoryController.java @@ -21,19 +21,19 @@ public class CategoryController { private final CategoryService categoryService; private final HashTagService hashTagService; - @GetMapping("/categorys") + @GetMapping("/categories") @Operation(summary = "카테고리 목록 전체 조회") public ResponseEntity>> showCategories() { - List categorys = categoryService.findAll(); - ResponseDto> listResponseDto = new ResponseDto<>(categorys); + List categories = categoryService.findAll(); + ResponseDto> listResponseDto = new ResponseDto<>(categories); return ResponseEntity.ok(listResponseDto); } - @GetMapping("/categorys/{categoryId}/hashtags") + @GetMapping("/categories/{categoryId}/hashtags") @Operation(summary = "특정 카테고리에 포함되는 해쉬태그 목록 조회") public ResponseEntity>> showHashTags(@PathVariable("categoryId") Long categoryId) { - List categorys = hashTagService.findManyByCategory(categoryId); - ResponseDto> listResponseDto = new ResponseDto<>(categorys); + List categories = hashTagService.findManyByCategory(categoryId); + ResponseDto> listResponseDto = new ResponseDto<>(categories); return ResponseEntity.ok(listResponseDto); } } diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/category/domain/Category.java b/BACK/spring-app/src/main/java/com/starchive/springapp/category/domain/Category.java index 77b9266..b4c63e8 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/category/domain/Category.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/category/domain/Category.java @@ -19,7 +19,7 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -@Table(name = "Categorys") +@Table(name = "Categories") public class Category { @Id @GeneratedValue diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/global/ErrorMessage.java b/BACK/spring-app/src/main/java/com/starchive/springapp/global/ErrorMessage.java new file mode 100644 index 0000000..89a7212 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/global/ErrorMessage.java @@ -0,0 +1,7 @@ +package com.starchive.springapp.global; + +public class ErrorMessage { + final public static String FAIL_UPLOAD = "이미지 업로드 실패"; + final public static String NOT_IMAGE_EXTENSION = "올바르지 않은 이미지 확장자(허용 확장자: \"jpg\", \"png\", \"gif\", \"jpeg\""; + final public static String INVALID_FILE_SIZE = "파일 크기가 너무 큽니다. 최대 이미지 크기: 5MB"; +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/global/config/SchedulerConfig.java b/BACK/spring-app/src/main/java/com/starchive/springapp/global/config/SchedulerConfig.java new file mode 100644 index 0000000..d90bf10 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/global/config/SchedulerConfig.java @@ -0,0 +1,9 @@ +package com.starchive.springapp.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulerConfig { +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/global/exception/GlobalExceptionHandler.java b/BACK/spring-app/src/main/java/com/starchive/springapp/global/exception/GlobalExceptionHandler.java index 28f3b81..0b6bce9 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/global/exception/GlobalExceptionHandler.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/global/exception/GlobalExceptionHandler.java @@ -1,11 +1,15 @@ package com.starchive.springapp.global.exception; +import static com.starchive.springapp.global.ErrorMessage.INVALID_FILE_SIZE; + import java.util.HashMap; import java.util.Map; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; @RestControllerAdvice public class GlobalExceptionHandler { @@ -21,4 +25,10 @@ public ResponseEntity> handleValidationExceptions(MethodArgu return ResponseEntity.badRequest().body(errors); } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity handleMaxSizeException(MaxUploadSizeExceededException ex) { + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE) + .body(INVALID_FILE_SIZE); + } } diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/image/controller/PostImageController.java b/BACK/spring-app/src/main/java/com/starchive/springapp/image/controller/PostImageController.java new file mode 100644 index 0000000..407eef8 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/image/controller/PostImageController.java @@ -0,0 +1,29 @@ +package com.starchive.springapp.image.controller; + +import com.starchive.springapp.global.dto.ResponseDto; +import com.starchive.springapp.image.dto.PostImageDto; +import com.starchive.springapp.image.service.PostImageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "게시글 이미지") +@RestController +@RequiredArgsConstructor +public class PostImageController { + private final PostImageService postImageService; + + @Operation(summary = "게시글에 포함될 이미지 업로드") + @PostMapping(value = "/postImage", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> imageUpload(@RequestParam("image") MultipartFile image) { + PostImageDto postImageDto = postImageService.uploadImage(image); + + return ResponseEntity.ok(new ResponseDto<>(postImageDto)); + } +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/image/domain/PostImage.java b/BACK/spring-app/src/main/java/com/starchive/springapp/image/domain/PostImage.java new file mode 100644 index 0000000..6e7bfd2 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/image/domain/PostImage.java @@ -0,0 +1,36 @@ +package com.starchive.springapp.image.domain; + +import static jakarta.persistence.FetchType.LAZY; + +import com.starchive.springapp.post.domain.Post; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Lob; +import jakarta.persistence.ManyToOne; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostImage { + @Id + @GeneratedValue + private Long id; + + @Lob + String imagePath; + + LocalDateTime uploadDate; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "postId") + Post post; +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/image/dto/PostImageDto.java b/BACK/spring-app/src/main/java/com/starchive/springapp/image/dto/PostImageDto.java new file mode 100644 index 0000000..91a47e2 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/image/dto/PostImageDto.java @@ -0,0 +1,11 @@ +package com.starchive.springapp.image.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class PostImageDto { + Long id; + String imagePath; +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/image/repository/PostImageRepository.java b/BACK/spring-app/src/main/java/com/starchive/springapp/image/repository/PostImageRepository.java new file mode 100644 index 0000000..8afd157 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/image/repository/PostImageRepository.java @@ -0,0 +1,18 @@ +package com.starchive.springapp.image.repository; + +import com.starchive.springapp.image.domain.PostImage; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PostImageRepository extends JpaRepository { + @Query("SELECT p FROM PostImage p WHERE p.post IS NULL AND p.uploadDate < :cutoffDate") + List findOldOrphanedPostImages(@Param("cutoffDate") LocalDateTime cutoffDate); + + @Modifying + @Query("DELETE FROM PostImage p WHERE p.id IN :ids") + void deleteByIds(@Param("ids") List ids); +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/image/service/PostImageService.java b/BACK/spring-app/src/main/java/com/starchive/springapp/image/service/PostImageService.java new file mode 100644 index 0000000..d96e3e3 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/image/service/PostImageService.java @@ -0,0 +1,54 @@ +package com.starchive.springapp.image.service; + +import com.starchive.springapp.image.domain.PostImage; +import com.starchive.springapp.image.dto.PostImageDto; +import com.starchive.springapp.image.repository.PostImageRepository; +import com.starchive.springapp.s3.S3Service; +import java.net.URI; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class PostImageService { + private final S3Service s3Service; + private final PostImageRepository postImageRepository; + + public PostImageDto uploadImage(MultipartFile image) { + String imagePath = s3Service.saveFile(image); + PostImage postImage = PostImage.builder() + .imagePath(imagePath) + .uploadDate(LocalDateTime.now()) + .build(); + + postImageRepository.save(postImage); + + return new PostImageDto(postImage.getId(), postImage.getImagePath()); + } + + @Scheduled(cron = "0 0 2 * * ?") + public void deleteOldOrphanedPostImages() { + LocalDateTime cutoffDate = LocalDateTime.now().minusDays(1); // 하루 전 + + List oldOrphanedPostImages = postImageRepository.findOldOrphanedPostImages(cutoffDate); + oldOrphanedPostImages.forEach(postImage -> { + s3Service.deleteObject(extractKeyFromUrl(postImage.getImagePath())); + postImageRepository.delete(postImage); + log.info("Deleted old orphaned PostImages: {}", postImage.getImagePath()); + }); + + } + + private String extractKeyFromUrl(String url) { + URI uri = URI.create(url); + return uri.getPath().substring(1); // 첫 번째 '/' 제거 + } +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/s3/S3Config.java b/BACK/spring-app/src/main/java/com/starchive/springapp/s3/S3Config.java new file mode 100644 index 0000000..51ecfec --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/s3/S3Config.java @@ -0,0 +1,37 @@ +package com.starchive.springapp.s3; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@Configuration +public class S3Config { + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + @Primary + public AmazonS3 amazonS3Client() { + AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + + return AmazonS3ClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withRegion(region) + .build(); + } + + +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/s3/S3Service.java b/BACK/spring-app/src/main/java/com/starchive/springapp/s3/S3Service.java new file mode 100644 index 0000000..15b560c --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/s3/S3Service.java @@ -0,0 +1,77 @@ +package com.starchive.springapp.s3; + +import com.amazonaws.SdkClientException; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.AmazonS3Exception; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.starchive.springapp.global.ErrorMessage; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Service +@RequiredArgsConstructor +public class S3Service { + private final AmazonS3 amazonS3; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + // 단일 파일 저장 + public String saveFile(MultipartFile file) { + String randomFilename = generateRandomFilename(file); + log.info("File upload started: {}, bucketName: {}", randomFilename, bucket); + + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + + try { + amazonS3.putObject(bucket, randomFilename, file.getInputStream(), metadata); + } catch (AmazonS3Exception e) { + log.error("Amazon S3 error while uploading file: " + e.getMessage()); + throw new RuntimeException(ErrorMessage.FAIL_UPLOAD); + } catch (SdkClientException e) { + log.error("AWS SDK client error while uploading file: " + e.getMessage()); + throw new RuntimeException(ErrorMessage.FAIL_UPLOAD); + } catch (IOException e) { + log.error("IO error while uploading file: " + e.getMessage()); + throw new RuntimeException(ErrorMessage.FAIL_UPLOAD); + } + + log.info("File upload completed: " + randomFilename); + + return amazonS3.getUrl(bucket, randomFilename).toString(); + } + + public void deleteObject(String key) { + amazonS3.deleteObject(bucket, key); + log.info("Deleted S3 Object: {}", key); + } + + // 랜덤파일명 생성 (파일명 중복 방지) + private String generateRandomFilename(MultipartFile multipartFile) { + String originalFilename = multipartFile.getOriginalFilename(); + String fileExtension = validateFileExtension(originalFilename); + String randomFilename = UUID.randomUUID() + "." + fileExtension; + return randomFilename; + } + + // 파일 확장자 체크 + private String validateFileExtension(String originalFilename) { + String fileExtension = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase(); + List allowedExtensions = Arrays.asList("jpg", "png", "gif", "jpeg"); + + if (!allowedExtensions.contains(fileExtension)) { + throw new RuntimeException(ErrorMessage.NOT_IMAGE_EXTENSION); + } + return fileExtension; + } +} diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/category/controller/CategoryControllerTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/category/controller/CategoryControllerTest.java index 6b50b7e..7646e34 100644 --- a/BACK/spring-app/src/test/java/com/starchive/springapp/category/controller/CategoryControllerTest.java +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/category/controller/CategoryControllerTest.java @@ -51,7 +51,7 @@ class CategoryControllerTest { categoryRepository.save(child3); // when - mockMvc.perform(get("/categorys") + mockMvc.perform(get("/categories") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").isArray()) @@ -98,7 +98,7 @@ class CategoryControllerTest { entityManager.persist(postHashTag2); // When & Then - mockMvc.perform(get("/categorys/{categoryId}/hashtags", category.getId()) + mockMvc.perform(get("/categories/{categoryId}/hashtags", category.getId()) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").isArray()) diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/image/controller/PostImageControllerTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/image/controller/PostImageControllerTest.java new file mode 100644 index 0000000..4dbfb6a --- /dev/null +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/image/controller/PostImageControllerTest.java @@ -0,0 +1,54 @@ +package com.starchive.springapp.image.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.starchive.springapp.image.dto.PostImageDto; +import com.starchive.springapp.image.service.PostImageService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(PostImageController.class) +@ExtendWith(MockitoExtension.class) +class PostImageControllerTest { + @Autowired + private MockMvc mockMvc; + + @MockBean + private PostImageService postImageService; + + @InjectMocks + private PostImageController postImageController; + + @Test + public void 이미지_업로드_컨트롤러_단위_테스트() throws Exception { + //given + String path = "test.png"; + String contentType = "image/png"; + String content = "테스트 내용"; + + MockMultipartFile file = new MockMultipartFile("image", path, contentType, content.getBytes()); + + Mockito.when(postImageService.uploadImage(Mockito.any(MockMultipartFile.class))) + .thenReturn(new PostImageDto(1L, "Https://" + path)); + //when + //then + mockMvc.perform(multipart("/postImage") + .file(file) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + ).andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(1L)) + .andExpect(jsonPath("$.data.imagePath").value("Https://" + path)); + } + +} \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/image/service/PostImageServiceTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/image/service/PostImageServiceTest.java new file mode 100644 index 0000000..d7fb81a --- /dev/null +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/image/service/PostImageServiceTest.java @@ -0,0 +1,59 @@ +package com.starchive.springapp.image.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.starchive.springapp.image.domain.PostImage; +import com.starchive.springapp.image.dto.PostImageDto; +import com.starchive.springapp.image.repository.PostImageRepository; +import com.starchive.springapp.s3.S3Service; +import java.lang.reflect.Field; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; + +@ExtendWith(MockitoExtension.class) +class PostImageServiceTest { + @Mock + private S3Service s3Service; + + @Mock + private PostImageRepository postImageRepository; + + @InjectMocks + private PostImageService postImageService; + + @Test + public void 이미지_업로드_서비스로직_단위_테스트() throws Exception { + //given + String path = "test.png"; + String contentType = "image/png"; + String content = "테스트 내용"; + + MockMultipartFile file = new MockMultipartFile("test", path, contentType, content.getBytes()); + + when(s3Service.saveFile(Mockito.any(MockMultipartFile.class))).thenReturn("https://test.png"); + when(postImageRepository.save(Mockito.any())).then(invocation -> { + PostImage postImage = invocation.getArgument(0); + Field field = postImage.getClass().getDeclaredField("id"); + field.setAccessible(true); + field.set(postImage, 1L); + return postImage; + }); + + //when + PostImageDto postImageDto = postImageService.uploadImage(file); + + //then + verify(s3Service).saveFile(Mockito.any(MockMultipartFile.class)); + verify(postImageRepository).save(Mockito.any()); + assertThat(postImageDto.getId()).isEqualTo(1L); + assertThat(postImageDto.getImagePath()).contains("test.png"); + } + +} \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/s3/S3MockConfig.java b/BACK/spring-app/src/test/java/com/starchive/springapp/s3/S3MockConfig.java new file mode 100644 index 0000000..f01c3fa --- /dev/null +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/s3/S3MockConfig.java @@ -0,0 +1,48 @@ +package com.starchive.springapp.s3; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.AnonymousAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import io.findify.s3mock.S3Mock; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +class S3MockConfig { + + private String testBucketName = "test-bucket-in-memory"; + + private String testRegion = "na-east-1"; + + @Bean + public S3Mock s3Mock() { + return new S3Mock.Builder() + .withPort(8001) + .withInMemoryBackend() + .build(); + } + + @Bean(name = "MockS3Client") + public AmazonS3 amazonS3(S3Mock s3Mock) { + s3Mock.start(); + AwsClientBuilder.EndpointConfiguration endpoint = new AwsClientBuilder.EndpointConfiguration( + "http://localhost:8001", testRegion); + + AmazonS3 client = AmazonS3ClientBuilder + .standard() + .withPathStyleAccessEnabled(true) + .withEndpointConfiguration(endpoint) + .withCredentials(new AWSStaticCredentialsProvider(new AnonymousAWSCredentials())) + .build(); + + client.createBucket(testBucketName); + + return client; + } + + public String getTestBucketName() { + return testBucketName; + } +} \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/s3/S3ServiceTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/s3/S3ServiceTest.java new file mode 100644 index 0000000..3d0e3cb --- /dev/null +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/s3/S3ServiceTest.java @@ -0,0 +1,64 @@ +package com.starchive.springapp.s3; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.amazonaws.services.s3.AmazonS3; +import java.lang.reflect.Field; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockMultipartFile; + + +@SpringBootTest +@Import(S3MockConfig.class) +class S3ServiceTest { + + @Autowired + @Qualifier("MockS3Client") + private AmazonS3 amazonS3; + + @Autowired + S3MockConfig s3MockConfig; + + @Autowired + private S3Service s3Service; + + @Test + public void 이미지_업로드_테스트() throws Exception { + //given + String path = "test.png"; + String contentType = "image/png"; + String bucket = s3MockConfig.getTestBucketName(); + + MockMultipartFile file = new MockMultipartFile("test", path, contentType, "test".getBytes()); + //Reflection s3Service + Field reflectionFieldFor_amazonS3 = s3Service.getClass().getDeclaredField("amazonS3"); + reflectionFieldFor_amazonS3.setAccessible(true); + reflectionFieldFor_amazonS3.set(s3Service, amazonS3); + + Field reflectionFieldFor_bucket = s3Service.getClass().getDeclaredField("bucket"); + reflectionFieldFor_bucket.setAccessible(true); + reflectionFieldFor_bucket.set(s3Service, bucket); + + //when + String urlPath = s3Service.saveFile(file); + + //then + assertThat(urlPath).contains(bucket); + + amazonS3.listBuckets().forEach(System.out::println); + amazonS3.listObjects(bucket).getObjectSummaries().forEach(System.out::println); + + assertThat(amazonS3.listObjects(bucket).getObjectSummaries().size()).isEqualTo(1); + + String key = urlPath.substring(urlPath.lastIndexOf("/") + 1); + + assertThat(amazonS3.listObjects(bucket).getObjectSummaries().get(0).getKey()) + .isEqualTo(key); + + } + +} \ No newline at end of file