diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 1193c0528..f0730bc41 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -8,6 +8,7 @@ jobs: deploy: name: Build & Deploy to OCI (Dev) runs-on: ubuntu-latest + environment: Test-Server steps: # (1) 코드 체크아웃 diff --git a/build.gradle b/build.gradle index ea216e0b8..a006c5fdc 100644 --- a/build.gradle +++ b/build.gradle @@ -66,6 +66,7 @@ dependencies { // Feature Flag by AWS AppConfig implementation(platform("software.amazon.awssdk:bom:2.27.21")) + implementation 'software.amazon.awssdk:s3' implementation 'software.amazon.awssdk:appconfig' implementation 'software.amazon.awssdk:appconfigdata' @@ -118,10 +119,11 @@ dependencies { // Test Container testImplementation 'org.junit.jupiter:junit-jupiter:5.8.1' - testImplementation 'org.testcontainers:testcontainers:1.19.8' - testImplementation 'org.testcontainers:junit-jupiter:1.19.8' - testImplementation 'org.testcontainers:mariadb:1.19.8' - testImplementation 'org.testcontainers:chromadb:1.19.8' + testImplementation "org.testcontainers:testcontainers:2.0.2" + testImplementation "org.testcontainers:testcontainers-junit-jupiter:2.0.2" + testImplementation "org.testcontainers:testcontainers-mariadb:2.0.2" + testImplementation "org.testcontainers:testcontainers-chromadb:2.0.2" + testImplementation "org.apache.commons:commons-lang3:3.18.0" testImplementation 'io.projectreactor:reactor-test' } diff --git a/src/main/java/com/kustacks/kuring/common/exception/InfrastructureException.java b/src/main/java/com/kustacks/kuring/common/exception/InfrastructureException.java new file mode 100644 index 000000000..b018a2a89 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/common/exception/InfrastructureException.java @@ -0,0 +1,18 @@ +package com.kustacks.kuring.common.exception; + +import com.kustacks.kuring.common.exception.code.ErrorCode; +import lombok.Getter; + +@Getter +public class InfrastructureException extends RuntimeException { + private final ErrorCode errorCode; + + public InfrastructureException(ErrorCode errorCode) { + this.errorCode = errorCode; + } + + public InfrastructureException(ErrorCode errorCode, Exception e) { + super(e); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/kustacks/kuring/common/exception/code/ErrorCode.java b/src/main/java/com/kustacks/kuring/common/exception/code/ErrorCode.java index 9904b9ca2..18b774353 100644 --- a/src/main/java/com/kustacks/kuring/common/exception/code/ErrorCode.java +++ b/src/main/java/com/kustacks/kuring/common/exception/code/ErrorCode.java @@ -105,7 +105,11 @@ public enum ErrorCode { */ DOMAIN_CANNOT_CREATE("해당 도메인을 생성할 수 없습니다."), DEPARTMENT_NOT_FOUND("해당 학과를 찾을 수 없습니다."), - QUESTION_COUNT_NOT_ENOUGH(HttpStatus.TOO_MANY_REQUESTS, "남은 질문 횟수가 부족합니다."); + QUESTION_COUNT_NOT_ENOUGH(HttpStatus.TOO_MANY_REQUESTS, "남은 질문 횟수가 부족합니다."), + + STORAGE_S3_SDK_PROBLEM(HttpStatus.INTERNAL_SERVER_ERROR, "S3 클라이언트 통신 간 에러가 발생했습니다."), + FILE_IO_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "파일을 읽어들이는데 문제가 발생했습니다.") + ; private final HttpStatus httpStatus; private final String message; diff --git a/src/main/java/com/kustacks/kuring/common/exception/handler/CommonExceptionHandler.java b/src/main/java/com/kustacks/kuring/common/exception/handler/CommonExceptionHandler.java index 634ab6fab..9f778c662 100644 --- a/src/main/java/com/kustacks/kuring/common/exception/handler/CommonExceptionHandler.java +++ b/src/main/java/com/kustacks/kuring/common/exception/handler/CommonExceptionHandler.java @@ -3,6 +3,7 @@ import com.kustacks.kuring.common.dto.ErrorResponse; import com.kustacks.kuring.common.exception.AdminException; import com.kustacks.kuring.common.exception.BusinessException; +import com.kustacks.kuring.common.exception.InfrastructureException; import com.kustacks.kuring.common.exception.InternalLogicException; import com.kustacks.kuring.common.exception.InvalidStateException; import com.kustacks.kuring.common.exception.NoPermissionException; @@ -79,6 +80,13 @@ public ResponseEntity FirebaseSubscribeExceptionHandler(FirebaseS .body(new ErrorResponse(ErrorCode.API_FB_SERVER_ERROR)); } + @ExceptionHandler + public ResponseEntity InfrastructureExceptionHandler(InfrastructureException exception) { + log.warn("{} {}", exception.getClass().getName(), exception.getErrorCode().getMessage(), exception); + return ResponseEntity.status(exception.getErrorCode().getHttpStatus()) + .body(new ErrorResponse(exception.getErrorCode())); + } + @ExceptionHandler public ResponseEntity InvalidStateExceptionHandler(InvalidStateException exception) { log.info("[InvalidStateException] {}", exception.getMessage()); diff --git a/src/main/java/com/kustacks/kuring/common/properties/CloudStorageProperties.java b/src/main/java/com/kustacks/kuring/common/properties/CloudStorageProperties.java new file mode 100644 index 000000000..8c63e8200 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/common/properties/CloudStorageProperties.java @@ -0,0 +1,21 @@ +package com.kustacks.kuring.common.properties; + +import jakarta.validation.constraints.*; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "cloud.storage") +public record CloudStorageProperties( + @NotBlank String region, + String endpoint, // OCI인 경우에만 사용. + @NotBlank String bucket, + @NotNull Credentials credentials +) { + public record Credentials( + @NotBlank String accessKey, + @NotBlank String secretKey + ) { + + } +} \ No newline at end of file diff --git a/src/main/java/com/kustacks/kuring/config/CloudStorageConfig.java b/src/main/java/com/kustacks/kuring/config/CloudStorageConfig.java new file mode 100644 index 000000000..45556a74d --- /dev/null +++ b/src/main/java/com/kustacks/kuring/config/CloudStorageConfig.java @@ -0,0 +1,81 @@ +package com.kustacks.kuring.config; + +import com.kustacks.kuring.common.properties.CloudStorageProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3ClientBuilder; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import java.net.URI; + +/** + * Local, Test환경 에서는 사용하지 않는다. + * Prod, Dev환경에서 사용한다. + * Prod : AWS S3 서비스 이용 + * Dev : OCI Storage 서비스 이용. + * 서비스 사용은 S3기술로 호환가능. Presigned 기술도 V4지원. + */ +@Configuration +@RequiredArgsConstructor +public class CloudStorageConfig { + + private final CloudStorageProperties properties; + + @Bean + @Profile("prod") + public S3Client awsS3Client() { + return defaultS3ClientBuilder() + .build(); + } + + @Bean + @Profile("prod") + public S3Presigner awsS3Presigner() { + return defaultS3PresignerBuilder() + .build(); + } + + @Bean + @Profile("dev") + public S3Client oracleStorageClient() { + return defaultS3ClientBuilder() + .endpointOverride(URI.create(properties.endpoint())) //OCI는 엔드포인트를 강제 지정한다. + .forcePathStyle(true) // 강제로 PathStyle 지정. + .build(); + } + + @Bean + @Profile("dev") + public S3Presigner oracleS3Presigner() { + return defaultS3PresignerBuilder() + .endpointOverride(URI.create(properties.endpoint())) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(true) + .build()) + .build(); + } + + private S3ClientBuilder defaultS3ClientBuilder() { + return S3Client.builder() + .region(Region.of(properties.region())) + .credentialsProvider(staticCredentialsProvider()); + } + + private S3Presigner.Builder defaultS3PresignerBuilder() { + return S3Presigner.builder() + .region(Region.of(properties.region())) + .credentialsProvider(staticCredentialsProvider()); + } + + private StaticCredentialsProvider staticCredentialsProvider() { + return StaticCredentialsProvider + .create(AwsBasicCredentials.create(properties.credentials().accessKey(), properties.credentials().secretKey())); + } +} \ No newline at end of file diff --git a/src/main/java/com/kustacks/kuring/message/application/service/FirebaseNotificationService.java b/src/main/java/com/kustacks/kuring/message/application/service/FirebaseNotificationService.java index d736732e4..8d823d623 100644 --- a/src/main/java/com/kustacks/kuring/message/application/service/FirebaseNotificationService.java +++ b/src/main/java/com/kustacks/kuring/message/application/service/FirebaseNotificationService.java @@ -109,7 +109,8 @@ private void loggingNoticeSendInfo(List notificationDtoList) { log.info("전송된 공지 목록은 다음과 같습니다."); for (NoticeMessageDto noticeMessageDto : notificationDtoList) { - log.info("아이디 = {}, 날짜 = {}, 카테고리 = {}, 제목 = {}", + log.info("ID = {}, ArticleId = {}, 날짜 = {}, 카테고리 = {}, 제목 = {}", + noticeMessageDto.getId(), noticeMessageDto.getArticleId(), noticeMessageDto.getPostedDate(), noticeMessageDto.getCategory(), diff --git a/src/main/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticePersistenceAdapter.java b/src/main/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticePersistenceAdapter.java index 971acdf3f..2f1bf4c2d 100644 --- a/src/main/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticePersistenceAdapter.java +++ b/src/main/java/com/kustacks/kuring/notice/adapter/out/persistence/NoticePersistenceAdapter.java @@ -34,6 +34,16 @@ public void saveAllDepartmentNotices(List departmentNotices) { this.noticeJdbcRepository.saveAllDepartmentNotices(departmentNotices); } + @Override + public List saveAllCategoryNoticesAndReturn(List notices) { + return this.noticeRepository.saveAll(notices); + } + + @Override + public List saveAllDepartmentNoticesAndReturn(List notices) { + return this.noticeRepository.saveAll(notices); + } + @Override public void deleteAllByIdsAndCategory(CategoryName categoryName, List articleIds) { this.noticeRepository.deleteAllByIdsAndCategory(categoryName, articleIds); diff --git a/src/main/java/com/kustacks/kuring/notice/application/port/out/NoticeCommandPort.java b/src/main/java/com/kustacks/kuring/notice/application/port/out/NoticeCommandPort.java index 69fa62079..17885a361 100644 --- a/src/main/java/com/kustacks/kuring/notice/application/port/out/NoticeCommandPort.java +++ b/src/main/java/com/kustacks/kuring/notice/application/port/out/NoticeCommandPort.java @@ -11,6 +11,8 @@ public interface NoticeCommandPort { void saveAllCategoryNotices(List notices); void saveAllDepartmentNotices(List departmentNotices); + List saveAllCategoryNoticesAndReturn(List notices); + List saveAllDepartmentNoticesAndReturn(List notices); void deleteAllByIdsAndCategory(CategoryName categoryName, List articleIds); void deleteAllByIdsAndDepartment(DepartmentName departmentName, List articleIds); void changeNoticeImportantToFalseByArticleId(CategoryName categoryName, List articleIds); diff --git a/src/main/java/com/kustacks/kuring/notice/application/service/NoticeQueryService.java b/src/main/java/com/kustacks/kuring/notice/application/service/NoticeQueryService.java index d93d4ed3a..ddf3d21a0 100644 --- a/src/main/java/com/kustacks/kuring/notice/application/service/NoticeQueryService.java +++ b/src/main/java/com/kustacks/kuring/notice/application/service/NoticeQueryService.java @@ -201,7 +201,7 @@ private List convertDepartmentNameDtos(List NAME_MAP; @@ -103,8 +105,17 @@ public enum DepartmentName { NAME_MAP = Collections.unmodifiableMap(Arrays.stream(DepartmentName.values()) .collect(Collectors.toMap(DepartmentName::getName, DepartmentName::name))); - HOST_PREFIX_MAP = Collections.unmodifiableMap(Arrays.stream(DepartmentName.values()) - .collect(Collectors.toMap(DepartmentName::getHostPrefix, DepartmentName::name))); + HOST_PREFIX_MAP = Collections.unmodifiableMap( + Arrays.stream(DepartmentName.values()) + .flatMap(d -> Stream.of(d.hostPrefix, d.fallbackHostPrefix) + .filter(Objects::nonNull) + .map(prefix -> Map.entry(prefix, d.name())) + ) + .collect(Collectors.toMap( + Map.Entry::getKey, Map.Entry::getValue, + (a, b) -> a + )) + ); KOR_NAME_MAP = Collections.unmodifiableMap(Arrays.stream(DepartmentName.values()) .collect(Collectors.toMap(DepartmentName::getKorName, DepartmentName::name))); @@ -112,11 +123,13 @@ public enum DepartmentName { private final String name; private final String hostPrefix; + private final String fallbackHostPrefix; private final String korName; - DepartmentName(String name, String hostPrefix, String korName) { + DepartmentName(String name, String hostPrefix, String fallbackHostPrefix, String korName) { this.name = name; this.hostPrefix = hostPrefix; + this.fallbackHostPrefix = fallbackHostPrefix; this.korName = korName; } diff --git a/src/main/java/com/kustacks/kuring/storage/adapter/out/MockStorageAdapter.java b/src/main/java/com/kustacks/kuring/storage/adapter/out/MockStorageAdapter.java new file mode 100644 index 000000000..49beb6a99 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/storage/adapter/out/MockStorageAdapter.java @@ -0,0 +1,31 @@ +package com.kustacks.kuring.storage.adapter.out; + +import com.kustacks.kuring.storage.application.port.out.StoragePort; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import java.io.InputStream; + +@Profile("local | test") +@Slf4j +@Service +public class MockStorageAdapter implements StoragePort { + + private static final String MOCK_SERVER = "https://mock.ku-ring.com/"; + + @Override + public void upload(InputStream inputStream, String key, String contentType, long contentLength) { + log.info(String.format("%s 파일 저장 완료", key)); + } + + @Override + public String getPresignedUrl(String key) { + return MOCK_SERVER + key; + } + + @Override + public void delete(String key) { + log.info(String.format("%s 파일 제거 완료.", key)); + } +} diff --git a/src/main/java/com/kustacks/kuring/storage/adapter/out/S3CompatibleStorageAdapter.java b/src/main/java/com/kustacks/kuring/storage/adapter/out/S3CompatibleStorageAdapter.java new file mode 100644 index 000000000..da7c43b71 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/storage/adapter/out/S3CompatibleStorageAdapter.java @@ -0,0 +1,85 @@ +package com.kustacks.kuring.storage.adapter.out; + +import com.kustacks.kuring.common.properties.CloudStorageProperties; +import com.kustacks.kuring.storage.application.port.out.StoragePort; +import com.kustacks.kuring.storage.exception.CloudStorageException; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +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.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +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; + +import java.io.InputStream; +import java.time.Duration; + +import static com.kustacks.kuring.common.exception.code.ErrorCode.STORAGE_S3_SDK_PROBLEM; + +@Profile("dev | prod") +@Service +@RequiredArgsConstructor +public class S3CompatibleStorageAdapter implements StoragePort { + + private final S3Client s3Client; + private final S3Presigner s3Presigner; + private final CloudStorageProperties properties; + + @Override + public void upload(InputStream inputStream, String key, String contentType, long contentLength) { + try { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(properties.bucket()) + .key(key) + .contentType(contentType) + .contentLength(contentLength) + .build(); + + s3Client.putObject(putObjectRequest, + RequestBody.fromInputStream(inputStream, contentLength)); + } catch (S3Exception | SdkClientException e) { + throw new CloudStorageException(STORAGE_S3_SDK_PROBLEM); + } + } + + @Override + public String getPresignedUrl(String key) { + try { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(properties.bucket()) + .key(key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofHours(1)) // 1 hour expiry + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedGetObjectRequest = s3Presigner.presignGetObject(presignRequest); + + return presignedGetObjectRequest.url().toString(); + } catch (S3Exception | SdkClientException e) { + throw new CloudStorageException(STORAGE_S3_SDK_PROBLEM); + } + } + + @Override + public void delete(String key) { + try { + DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() + .bucket(properties.bucket()) + .key(key) + .build(); + + s3Client.deleteObject(deleteObjectRequest); + } catch (S3Exception | SdkClientException e) { + throw new CloudStorageException(STORAGE_S3_SDK_PROBLEM); + } + } +} diff --git a/src/main/java/com/kustacks/kuring/storage/application/port/out/StoragePort.java b/src/main/java/com/kustacks/kuring/storage/application/port/out/StoragePort.java new file mode 100644 index 000000000..cec75e2a6 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/storage/application/port/out/StoragePort.java @@ -0,0 +1,9 @@ +package com.kustacks.kuring.storage.application.port.out; + +import java.io.InputStream; + +public interface StoragePort { + void upload(InputStream inputStream, String key, String contentType, long contentLength); + String getPresignedUrl(String key); + void delete(String key); +} diff --git a/src/main/java/com/kustacks/kuring/storage/exception/CloudStorageException.java b/src/main/java/com/kustacks/kuring/storage/exception/CloudStorageException.java new file mode 100644 index 000000000..dbc662f99 --- /dev/null +++ b/src/main/java/com/kustacks/kuring/storage/exception/CloudStorageException.java @@ -0,0 +1,11 @@ +package com.kustacks.kuring.storage.exception; + +import com.kustacks.kuring.common.exception.InfrastructureException; +import com.kustacks.kuring.common.exception.code.ErrorCode; + +public class CloudStorageException extends InfrastructureException { + + public CloudStorageException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/kustacks/kuring/worker/update/notice/DepartmentGraduationNoticeUpdater.java b/src/main/java/com/kustacks/kuring/worker/update/notice/DepartmentGraduationNoticeUpdater.java index 5b732c840..270706788 100644 --- a/src/main/java/com/kustacks/kuring/worker/update/notice/DepartmentGraduationNoticeUpdater.java +++ b/src/main/java/com/kustacks/kuring/worker/update/notice/DepartmentGraduationNoticeUpdater.java @@ -110,8 +110,7 @@ private List compareLatestAndUpdateDB(List saveNewNotices(List scrapResults, List savedArticleIds, DepartmentName departmentNameEnum, boolean important, boolean graduated) { List newNotices = noticeUpdateSupport.filteringSoonSaveDepartmentNotices(scrapResults, savedArticleIds, departmentNameEnum, important, graduated); - noticeCommandPort.saveAllDepartmentNotices(newNotices); - return newNotices; + return noticeCommandPort.saveAllDepartmentNoticesAndReturn(newNotices); } private void compareAllAndUpdateDB(List scrapResults, String departmentName) { diff --git a/src/main/java/com/kustacks/kuring/worker/update/notice/DepartmentNoticeUpdater.java b/src/main/java/com/kustacks/kuring/worker/update/notice/DepartmentNoticeUpdater.java index 05633667a..823b9af66 100644 --- a/src/main/java/com/kustacks/kuring/worker/update/notice/DepartmentNoticeUpdater.java +++ b/src/main/java/com/kustacks/kuring/worker/update/notice/DepartmentNoticeUpdater.java @@ -101,8 +101,7 @@ private List compareLatestAndUpdateDB(List saveNewNotices(List scrapResults, List savedArticleIds, DepartmentName departmentNameEnum, boolean important, boolean graduated) { List newNotices = noticeUpdateSupport.filteringSoonSaveDepartmentNotices(scrapResults, savedArticleIds, departmentNameEnum, important, graduated); - noticeCommandPort.saveAllDepartmentNotices(newNotices); - return newNotices; + return noticeCommandPort.saveAllDepartmentNoticesAndReturn(newNotices); } private void compareAllAndUpdateDB(List scrapResults, String departmentName) { diff --git a/src/main/java/com/kustacks/kuring/worker/update/notice/KuisHomepageNoticeUpdater.java b/src/main/java/com/kustacks/kuring/worker/update/notice/KuisHomepageNoticeUpdater.java index cdad61a2f..5d1c212a0 100644 --- a/src/main/java/com/kustacks/kuring/worker/update/notice/KuisHomepageNoticeUpdater.java +++ b/src/main/java/com/kustacks/kuring/worker/update/notice/KuisHomepageNoticeUpdater.java @@ -106,13 +106,13 @@ private List synchronizationWithLibraryDb(List sc List deletedNoticesArticleIds = noticeUpdateSupport.filteringSoonDeleteNoticeIds(savedArticleIds, scrapNoticeIds); - noticeCommandPort.saveAllCategoryNotices(newNotices); + List savedNotices = noticeCommandPort.saveAllCategoryNoticesAndReturn(newNotices); if (!deletedNoticesArticleIds.isEmpty()) { noticeCommandPort.deleteAllByIdsAndCategory(categoryName, deletedNoticesArticleIds); } - return newNotices; + return savedNotices; } // 여기부터는 KUIS 공지 @@ -179,8 +179,7 @@ private List saveNewNotices( updateNoticesByImportantToNormal(savedArticleIds, newNotices, categoryName); } - noticeCommandPort.saveAllCategoryNotices(newNotices); - return newNotices; + return noticeCommandPort.saveAllCategoryNoticesAndReturn(newNotices); } private void updateNoticesByImportantToNormal( diff --git a/src/main/resources/config/environments/common.yml b/src/main/resources/config/environments/common.yml index 6cc06bbc7..a537adb79 100644 --- a/src/main/resources/config/environments/common.yml +++ b/src/main/resources/config/environments/common.yml @@ -66,6 +66,11 @@ spring: serialization: FAIL_ON_EMPTY_BEANS: false + servlet: + multipart: + max-file-size: 10MB # 파일 하나당 최대 용량 + max-request-size: 50MB # 한번 요청에 받을 수 있는 용량 + management: endpoint: health: @@ -89,6 +94,15 @@ aws: profile: ${AWS_APPCONFIG_PROFILE} region: ${AWS_APPCONFIG_REGION} +cloud: + storage: + region: ${CLOUD_STORAGE_REGION} + endpoint: ${CLOUD_STORAGE_ENDPOINT} + bucket: ${CLOUD_STORAGE_BUCKET} + credentials: + access-key: ${CLOUD_STORAGE_ACCESS_KEY} + secret-key: ${CLOUD_STORAGE_SECRET_KEY} + springdoc: swagger-ui: # 각 API의 그룹 표시 순서 diff --git a/src/test/java/com/kustacks/kuring/notice/domain/DepartmentNameTest.java b/src/test/java/com/kustacks/kuring/notice/domain/DepartmentNameTest.java index 607d66036..3d43ae9e8 100644 --- a/src/test/java/com/kustacks/kuring/notice/domain/DepartmentNameTest.java +++ b/src/test/java/com/kustacks/kuring/notice/domain/DepartmentNameTest.java @@ -24,7 +24,7 @@ void fromName(String name, DepartmentName departmentName) { } @DisplayName("hostPrefix를 String으로 받아 해당 DepartmentName enum으로 변환한다") - @CsvSource({"korea,KOREAN", "cee,CIVIL_ENV", "biz,BUIS_ADMIN"}) + @CsvSource({"korea,KOREAN", "cee,CIVIL_ENV", "biz,BUIS_ADMIN", "rest,REAL_ESTATE", "kure,REAL_ESTATE"}) @ParameterizedTest void fromHostPrefix(String name, DepartmentName departmentName) { // when @@ -59,14 +59,27 @@ void fromNameException() { .isInstanceOf(NotFoundException.class); } - @DisplayName("존재하지 않는 String fromHostPrefix로 DepartmentName을 찾으려 하는 경우 예외가 발생한다") - @Test - void fromHostPrefixException() { - // given - String name = "invalidName"; + @DisplayName("유효하지 않은 hostPrefix로 DepartmentName을 찾으려 하는 경우 예외가 발생한다") + @ParameterizedTest + @CsvSource({ + "invalidName", + "' '", + "''" + }) + void fromHostPrefix_invalid_exception(String hostPrefix) { + // when + ThrowingCallable actual = () -> DepartmentName.fromHostPrefix(hostPrefix); + // then + assertThatThrownBy(actual) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("hostPrefix가 null이면 예외가 발생한다") + void fromHostPrefix_null_exception() { // when - ThrowingCallable actual = () -> DepartmentName.fromHostPrefix(name); + ThrowingCallable actual = () -> DepartmentName.fromHostPrefix(null); // then assertThatThrownBy(actual) @@ -86,4 +99,28 @@ void fromKorException() { assertThatThrownBy(actual) .isInstanceOf(NotFoundException.class); } + + @Test + @DisplayName("HOST_PREFIX_MAP은 모든 hostPrefix와 fallbackHostPrefix를 정확히 매핑한다") + void hostPrefixMap_test() { + for (DepartmentName department : DepartmentName.values()) { + + //when + String hostPrefix = department.getHostPrefix(); + //then + assertThat( + DepartmentName.fromHostPrefix(hostPrefix) + ).isEqualTo(department); + + if (department.getFallbackHostPrefix() != null) { + //when + String fallbackHostPrefix = department.getFallbackHostPrefix(); + //then + assertThat( + DepartmentName.fromHostPrefix(fallbackHostPrefix) + ).isEqualTo(department); + } + } + } + } diff --git a/src/test/java/com/kustacks/kuring/storage/adapter/out/S3CompatibleStorageAdapterTest.java b/src/test/java/com/kustacks/kuring/storage/adapter/out/S3CompatibleStorageAdapterTest.java new file mode 100644 index 000000000..7cfb93454 --- /dev/null +++ b/src/test/java/com/kustacks/kuring/storage/adapter/out/S3CompatibleStorageAdapterTest.java @@ -0,0 +1,157 @@ +package com.kustacks.kuring.storage.adapter.out; + +import com.kustacks.kuring.common.properties.CloudStorageProperties; +import com.kustacks.kuring.storage.exception.CloudStorageException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +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; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; + +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.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class S3CompatibleStorageAdapterTest { + + @Mock + private S3Client mockS3Client; + + @Mock + private S3Presigner mockS3Presigner; + + @Mock + private CloudStorageProperties properties; + + @InjectMocks + private S3CompatibleStorageAdapter s3CompatibleStorageAdapter; + + private String bucketName = "test-bucket"; + private String fileKey = "test-file-key"; + private byte[] testFile = "test-file".getBytes(); + + @DisplayName("S3에 파일을 업로드한다") + @Test + void uploadFile() { + // given + when(properties.bucket()).thenReturn(bucketName); + InputStream inputStream = new ByteArrayInputStream(testFile); + + // when + s3CompatibleStorageAdapter.upload(inputStream, fileKey, "image/jpeg", testFile.length); + + // then + verify(mockS3Client, times(1)) + .putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @DisplayName("S3에 파일을 업로드 중 SdkClientException이 발생하면 예외를 던진다") + @Test + void uploadFileWithSdkClientException() { + // given + when(properties.bucket()).thenReturn(bucketName); + doThrow(SdkClientException.create("S3 error")) + .when(mockS3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + InputStream inputStream = new ByteArrayInputStream(testFile); + + // when, then + assertThatThrownBy(() -> s3CompatibleStorageAdapter.upload(inputStream, fileKey, "image/jpeg", testFile.length)) + .isInstanceOf(CloudStorageException.class); + } + + @DisplayName("S3에 파일을 업로드 중 S3Exception이 발생하면 예외를 던진다") + @Test + void uploadFileWithS3Exception() { + // given + when(properties.bucket()).thenReturn(bucketName); + doThrow(SdkClientException.create("S3 error")) + .when(mockS3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + + InputStream inputStream = new ByteArrayInputStream(testFile); + + // when, then + assertThatThrownBy(() -> s3CompatibleStorageAdapter.upload(inputStream, fileKey, "image/jpeg", testFile.length)) + .isInstanceOf(CloudStorageException.class); + } + + + @DisplayName("S3 presigned url을 생성한다") + @Test + void getPresignedUrl() throws MalformedURLException { + // given + URL expectedUrl = new URL("https://test.com"); + when(properties.bucket()).thenReturn(bucketName); + + PresignedGetObjectRequest presignedRequest = mock(PresignedGetObjectRequest.class); + when(presignedRequest.url()).thenReturn(expectedUrl); + + when(mockS3Presigner.presignGetObject(any(GetObjectPresignRequest.class))) + .thenReturn(presignedRequest); + + // when + String actualUrl = s3CompatibleStorageAdapter.getPresignedUrl(fileKey); + + // then + assertThat(actualUrl).isEqualTo(expectedUrl.toString()); + verify(mockS3Presigner, times(1)).presignGetObject(any(GetObjectPresignRequest.class)); + } + + @DisplayName("S3 presigned url 생성 중 S3Exception이 발생하면 예외를 던진다") + @Test + void getPresignedUrlWithS3Exception() { + // given + when(properties.bucket()).thenReturn(bucketName); + when(mockS3Presigner.presignGetObject(any(GetObjectPresignRequest.class))) + .thenThrow(SdkClientException.create("S3 error")); + + // when, then + assertThatThrownBy(() -> s3CompatibleStorageAdapter.getPresignedUrl(fileKey)) + .isInstanceOf(CloudStorageException.class); + } + + @DisplayName("S3 파일을 삭제한다") + @Test + void deleteFile() { + // given + when(properties.bucket()).thenReturn(bucketName); + + // when + s3CompatibleStorageAdapter.delete(fileKey); + + // then + verify(mockS3Client, times(1)).deleteObject(any(DeleteObjectRequest.class)); + } + + @DisplayName("S3 파일 삭제 중 S3Exception이 발생하면 예외를 던진다") + @Test + void deleteFileWithS3Exception() { + // given + when(properties.bucket()).thenReturn(bucketName); + doThrow(SdkClientException.create("S3 error")) + .when(mockS3Client).deleteObject(any(DeleteObjectRequest.class)); + + // when, then + assertThatThrownBy(() -> s3CompatibleStorageAdapter.delete(fileKey)) + .isInstanceOf(CloudStorageException.class); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 3d56a9c93..f3a21dbd8 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -52,6 +52,15 @@ spring: starttls: enable: true # TLS +cloud: + storage: + region: ap-northeast-2 + endpoint: https://test.com + bucket: test-bucket + credentials: + access-key: test-access-key + secret-key: test-secret-key + testcontainers: reuse: enable: true