diff --git a/backend-submodule b/backend-submodule index 226f2fcb..3ec7227e 160000 --- a/backend-submodule +++ b/backend-submodule @@ -1 +1 @@ -Subproject commit 226f2fcb34bca6a33f3bddef8362c185bc1e95c9 +Subproject commit 3ec7227e0f9c48751aa88d4e43ada7994a583c29 diff --git a/build.gradle.kts b/build.gradle.kts index 92663e97..e55107f5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -57,6 +57,9 @@ dependencies { implementation("ch.qos.logback:logback-access") implementation("org.codehaus.janino:janino:3.1.6") + // s3 + implementation("com.amazonaws:aws-java-sdk-s3:1.12.693") + runtimeOnly("com.mysql:mysql-connector-j") runtimeOnly("com.h2database:h2") diff --git a/src/main/kotlin/com/petqua/application/image/ImageStorageService.kt b/src/main/kotlin/com/petqua/application/image/ImageStorageService.kt new file mode 100644 index 00000000..9b6b6392 --- /dev/null +++ b/src/main/kotlin/com/petqua/application/image/ImageStorageService.kt @@ -0,0 +1,28 @@ +package com.petqua.application.image + +import com.amazonaws.services.s3.AmazonS3 +import com.amazonaws.services.s3.model.ObjectMetadata +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.web.multipart.MultipartFile + +@Service +class ImageStorageService( + private val amazonS3: AmazonS3, + private val urlConverter: UrlConverter, + + @Value("\${cloud.aws.s3.bucket}") + private val bucket: String, +) { + + fun upload(path: String, image: MultipartFile): String { + val metadata = ObjectMetadata() + metadata.contentType = image.contentType + metadata.contentLength = image.size + + amazonS3.putObject(bucket, path, image.inputStream, metadata) + + val storedUrl = amazonS3.getUrl(bucket, path).toString() + return urlConverter.convertToAccessibleUrl(path, storedUrl) + } +} diff --git a/src/main/kotlin/com/petqua/application/image/UrlConverter.kt b/src/main/kotlin/com/petqua/application/image/UrlConverter.kt new file mode 100644 index 00000000..c47e762b --- /dev/null +++ b/src/main/kotlin/com/petqua/application/image/UrlConverter.kt @@ -0,0 +1,26 @@ +package com.petqua.application.image + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component + +@Component +class UrlConverter( + @Value("\${image.common.domain}") + private val domain: String, +) { + + fun convertToAccessibleUrl(filePath: String, storedUrl: String): String { + /* + * example) + * filePath = "root/directory/image.jpeg" + * storedUrl = "https://storedUrl.com/root/directory/image.jpeg" + * + * pathIndex = 21 + * storedPath = "/root/directory/image.jpeg" + * return "https://domain.com/root/directory/image.jpeg" + * */ + val pathIndex = storedUrl.indexOf("/$filePath") + val storedPath = storedUrl.substring(pathIndex) + return "$domain$storedPath" + } +} diff --git a/src/main/kotlin/com/petqua/application/product/dto/ProductReviewDtos.kt b/src/main/kotlin/com/petqua/application/product/dto/ProductReviewDtos.kt index 75234c84..501de335 100644 --- a/src/main/kotlin/com/petqua/application/product/dto/ProductReviewDtos.kt +++ b/src/main/kotlin/com/petqua/application/product/dto/ProductReviewDtos.kt @@ -7,12 +7,33 @@ import com.petqua.common.domain.dto.PAGING_LIMIT_CEILING import com.petqua.domain.auth.LoginMemberOrGuest import com.petqua.domain.product.dto.ProductReviewReadCondition import com.petqua.domain.product.dto.ProductReviewWithMemberResponse +import com.petqua.domain.product.review.ProductReview import com.petqua.domain.product.review.ProductReviewSorter import com.petqua.domain.product.review.ProductReviewSorter.REVIEW_DATE_DESC import com.petqua.domain.product.review.ProductReviewStatistics import io.swagger.v3.oas.annotations.media.Schema +import org.springframework.web.multipart.MultipartFile import java.time.LocalDateTime +data class ProductReviewCreateCommand( + val memberId: Long, + val productId: Long, + val score: Int, + val content: String, + val images: List, +) { + + fun toProductReview(): ProductReview { + return ProductReview.of( + memberId = memberId, + productId = productId, + score = score, + content = content, + images = images, + ) + } +} + data class ProductReviewReadQuery( val productId: Long, val loginMemberOrGuest: LoginMemberOrGuest, diff --git a/src/main/kotlin/com/petqua/application/product/review/ProductReviewFacadeService.kt b/src/main/kotlin/com/petqua/application/product/review/ProductReviewFacadeService.kt new file mode 100644 index 00000000..d05613e8 --- /dev/null +++ b/src/main/kotlin/com/petqua/application/product/review/ProductReviewFacadeService.kt @@ -0,0 +1,33 @@ +package com.petqua.application.product.review + +import com.petqua.application.product.dto.ProductReviewCreateCommand +import com.petqua.application.product.dto.ProductReviewReadQuery +import com.petqua.application.product.dto.ProductReviewStatisticsResponse +import com.petqua.application.product.dto.ProductReviewsResponse +import com.petqua.application.product.dto.UpdateReviewRecommendationCommand +import org.springframework.stereotype.Service + +@Service +class ProductReviewFacadeService( + private val productReviewService: ProductReviewService, + private val productReviewImageUploader: ProductReviewImageUploader, +) { + + fun create(command: ProductReviewCreateCommand): Long { + val productReview = command.toProductReview() + val reviewImageUrls = productReviewImageUploader.uploadAll(command.images) + return productReviewService.create(productReview, reviewImageUrls) + } + + fun readAll(query: ProductReviewReadQuery): ProductReviewsResponse { + return productReviewService.readAll(query) + } + + fun readReviewCountStatistics(productId: Long): ProductReviewStatisticsResponse { + return productReviewService.readReviewCountStatistics(productId) + } + + fun updateReviewRecommendation(command: UpdateReviewRecommendationCommand) { + productReviewService.updateReviewRecommendation(command) + } +} diff --git a/src/main/kotlin/com/petqua/application/product/review/ProductReviewImageUploader.kt b/src/main/kotlin/com/petqua/application/product/review/ProductReviewImageUploader.kt new file mode 100644 index 00000000..aee41d3f --- /dev/null +++ b/src/main/kotlin/com/petqua/application/product/review/ProductReviewImageUploader.kt @@ -0,0 +1,53 @@ +package com.petqua.application.product.review + +import com.petqua.application.image.ImageStorageService +import com.petqua.domain.product.review.ProductReviewImageType +import com.petqua.exception.product.review.ProductReviewException +import com.petqua.exception.product.review.ProductReviewExceptionType.FAILED_REVIEW_IMAGE_UPLOAD +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.web.multipart.MultipartFile +import java.util.UUID + +@Component +class ProductReviewImageUploader( + private val imageStorageService: ImageStorageService, + + @Value("\${image.product-review.directory}") + private val directory: String, +) { + + fun uploadAll(images: List): List { + if (images.isEmpty()) { + return listOf() + } + return images.map { upload(it) } + } + + private fun upload(image: MultipartFile): String { + ProductReviewImageType.validateSupported(image.contentType) + val fileName = UUID.randomUUID() + val path = "$directory$fileName${parseFileExtension(image)}" + + return uploadOrThrow(path, image) + } + + private fun parseFileExtension(image: MultipartFile): String { + return image.originalFilename?.let { + ".${it.substringAfterLast(FILE_EXTENSION_DELIMITER)}" + } ?: EMPTY_EXTENSION + } + + private fun uploadOrThrow(path: String, image: MultipartFile): String { + try { + return imageStorageService.upload(path, image) + } catch (e: Exception) { + throw ProductReviewException(FAILED_REVIEW_IMAGE_UPLOAD) + } + } + + companion object { + private const val FILE_EXTENSION_DELIMITER = '.' + private const val EMPTY_EXTENSION = "" + } +} diff --git a/src/main/kotlin/com/petqua/application/product/review/ProductReviewService.kt b/src/main/kotlin/com/petqua/application/product/review/ProductReviewService.kt index d6ae51b0..d96b630a 100644 --- a/src/main/kotlin/com/petqua/application/product/review/ProductReviewService.kt +++ b/src/main/kotlin/com/petqua/application/product/review/ProductReviewService.kt @@ -7,6 +7,8 @@ import com.petqua.application.product.dto.ProductReviewsResponse import com.petqua.application.product.dto.UpdateReviewRecommendationCommand import com.petqua.common.domain.findByIdOrThrow import com.petqua.domain.product.dto.ProductReviewWithMemberResponse +import com.petqua.domain.product.review.ProductReview +import com.petqua.domain.product.review.ProductReviewImage import com.petqua.domain.product.review.ProductReviewImageRepository import com.petqua.domain.product.review.ProductReviewRecommendation import com.petqua.domain.product.review.ProductReviewRecommendationRepository @@ -25,6 +27,15 @@ class ProductReviewService( private val productReviewRecommendationRepository: ProductReviewRecommendationRepository, ) { + fun create(productReview: ProductReview, reviewImageUrls: List): Long { + val savedProductReview = productReviewRepository.save(productReview) + val images = reviewImageUrls.map { + ProductReviewImage(imageUrl = it, productReviewId = savedProductReview.id) + } + productReviewImageRepository.saveAll(images) + return savedProductReview.id + } + @Transactional(readOnly = true) fun readAll(query: ProductReviewReadQuery): ProductReviewsResponse { val reviewsByCondition = productReviewRepository.findAllByCondition( @@ -65,10 +76,10 @@ class ProductReviewService( productReviewRecommendationRepository.findByProductReviewIdAndMemberId( command.productReviewId, command.memberId, - )?.let { delete(it) } ?: save(command.toReviewRecommendation()) + )?.let { delete(it) } ?: saveReviewRecommendation(command.toReviewRecommendation()) } - private fun save(productReviewRecommendation: ProductReviewRecommendation) { + private fun saveReviewRecommendation(productReviewRecommendation: ProductReviewRecommendation) { productReviewRecommendationRepository.save(productReviewRecommendation) val productReview = productReviewRepository.findByIdOrThrow(productReviewRecommendation.productReviewId) { ProductReviewException(NOT_FOUND_PRODUCT_REVIEW) diff --git a/src/main/kotlin/com/petqua/common/config/DataInitializer.kt b/src/main/kotlin/com/petqua/common/config/DataInitializer.kt index 2553c17d..60f1da52 100644 --- a/src/main/kotlin/com/petqua/common/config/DataInitializer.kt +++ b/src/main/kotlin/com/petqua/common/config/DataInitializer.kt @@ -15,11 +15,11 @@ import com.petqua.domain.keyword.ProductKeywordRepository import com.petqua.domain.member.FishLifeYear import com.petqua.domain.member.Member import com.petqua.domain.member.MemberRepository -import com.petqua.domain.notification.Notification -import com.petqua.domain.notification.NotificationRepository import com.petqua.domain.member.nickname.Nickname import com.petqua.domain.member.nickname.NicknameWord import com.petqua.domain.member.nickname.NicknameWordRepository +import com.petqua.domain.notification.Notification +import com.petqua.domain.notification.NotificationRepository import com.petqua.domain.order.ShippingAddress import com.petqua.domain.order.ShippingAddressRepository import com.petqua.domain.policy.bannedword.BannedWord @@ -55,6 +55,7 @@ import com.petqua.domain.product.option.Sex.FEMALE import com.petqua.domain.product.option.Sex.HERMAPHRODITE import com.petqua.domain.product.option.Sex.MALE import com.petqua.domain.product.review.ProductReview +import com.petqua.domain.product.review.ProductReviewContent import com.petqua.domain.product.review.ProductReviewImage import com.petqua.domain.product.review.ProductReviewImageRepository import com.petqua.domain.product.review.ProductReviewRepository @@ -63,14 +64,13 @@ import com.petqua.domain.recommendation.ProductRecommendation import com.petqua.domain.recommendation.ProductRecommendationRepository import com.petqua.domain.store.Store import com.petqua.domain.store.StoreRepository -import java.math.BigDecimal -import java.time.LocalDateTime -import kotlin.random.Random import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.context.annotation.Profile import org.springframework.context.event.EventListener import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import kotlin.random.Random @Component @Profile("local", "prod") @@ -387,7 +387,7 @@ class DataInitializer( ProductReview( productId = product.id, memberId = memberId, - content = "좋아요 ${product.name}", + content = ProductReviewContent("진짜 좋아요 ${product.name} 꼭 다시 살 거예요"), score = ProductReviewScore(it % 5 + 1), hasPhotos = hasPhotos ) diff --git a/src/main/kotlin/com/petqua/common/config/S3Config.kt b/src/main/kotlin/com/petqua/common/config/S3Config.kt new file mode 100644 index 00000000..500c4f58 --- /dev/null +++ b/src/main/kotlin/com/petqua/common/config/S3Config.kt @@ -0,0 +1,22 @@ +package com.petqua.common.config + +import com.amazonaws.auth.EC2ContainerCredentialsProviderWrapper +import com.amazonaws.regions.Regions.AP_NORTHEAST_2 +import com.amazonaws.services.s3.AmazonS3 +import com.amazonaws.services.s3.AmazonS3ClientBuilder +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile + +@Configuration +@Profile("!test") +class S3Config { + + @Bean + fun amazonS3(): AmazonS3 { + return AmazonS3ClientBuilder.standard() + .withRegion(AP_NORTHEAST_2) + .withCredentials(EC2ContainerCredentialsProviderWrapper()) + .build() + } +} diff --git a/src/main/kotlin/com/petqua/common/converter/MultipartJackson2HttpMessageConverter.kt b/src/main/kotlin/com/petqua/common/converter/MultipartJackson2HttpMessageConverter.kt new file mode 100644 index 00000000..b0152a59 --- /dev/null +++ b/src/main/kotlin/com/petqua/common/converter/MultipartJackson2HttpMessageConverter.kt @@ -0,0 +1,25 @@ +package com.petqua.common.converter + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.http.MediaType +import org.springframework.http.MediaType.APPLICATION_OCTET_STREAM +import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter +import org.springframework.stereotype.Component +import java.lang.reflect.Type + +@Component +class MultipartJackson2HttpMessageConverter(objectMapper: ObjectMapper) : + AbstractJackson2HttpMessageConverter(objectMapper, APPLICATION_OCTET_STREAM) { + + override fun canWrite(clazz: Class<*>, mediaType: MediaType?): Boolean { + return false + } + + override fun canWrite(type: Type?, clazz: Class<*>, mediaType: MediaType?): Boolean { + return false + } + + override fun canWrite(mediaType: MediaType?): Boolean { + return false + } +} diff --git a/src/main/kotlin/com/petqua/domain/product/dto/ProductReviewDtos.kt b/src/main/kotlin/com/petqua/domain/product/dto/ProductReviewDtos.kt index 05902f67..6765e31c 100644 --- a/src/main/kotlin/com/petqua/domain/product/dto/ProductReviewDtos.kt +++ b/src/main/kotlin/com/petqua/domain/product/dto/ProductReviewDtos.kt @@ -32,7 +32,7 @@ data class ProductReviewWithMemberResponse( id = productReview.id, productId = productReview.productId, score = productReview.score.value, - content = productReview.content, + content = productReview.content.value, createdAt = productReview.createdAt, hasPhotos = productReview.hasPhotos, recommendCount = productReview.recommendCount, diff --git a/src/main/kotlin/com/petqua/domain/product/review/ProductReview.kt b/src/main/kotlin/com/petqua/domain/product/review/ProductReview.kt index f8d26926..7bb049f3 100644 --- a/src/main/kotlin/com/petqua/domain/product/review/ProductReview.kt +++ b/src/main/kotlin/com/petqua/domain/product/review/ProductReview.kt @@ -1,6 +1,9 @@ package com.petqua.domain.product.review import com.petqua.common.domain.BaseEntity +import com.petqua.common.util.throwExceptionWhen +import com.petqua.exception.product.review.ProductReviewException +import com.petqua.exception.product.review.ProductReviewExceptionType.EXCEEDED_REVIEW_IMAGES_COUNT_LIMIT import jakarta.persistence.AttributeOverride import jakarta.persistence.Column import jakarta.persistence.Embedded @@ -8,14 +11,16 @@ import jakarta.persistence.Entity import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id +import org.springframework.web.multipart.MultipartFile @Entity class ProductReview( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0L, - @Column(nullable = false) - val content: String, + @Embedded + @AttributeOverride(name = "value", column = Column(name = "content")) + val content: ProductReviewContent, @Column(nullable = false) val productId: Long, @@ -41,4 +46,27 @@ class ProductReview( fun decreaseRecommendCount() { recommendCount -= 1 } + + companion object { + private const val MAX_REVIEW_IMAGES_COUNT = 10 + + fun of( + memberId: Long, + productId: Long, + content: String, + score: Int, + images: List, + ): ProductReview { + throwExceptionWhen(images.size > MAX_REVIEW_IMAGES_COUNT) { + ProductReviewException(EXCEEDED_REVIEW_IMAGES_COUNT_LIMIT) + } + return ProductReview( + memberId = memberId, + productId = productId, + content = ProductReviewContent(content), + score = ProductReviewScore(score), + hasPhotos = images.isNotEmpty() + ) + } + } } diff --git a/src/main/kotlin/com/petqua/domain/product/review/ProductReviewContent.kt b/src/main/kotlin/com/petqua/domain/product/review/ProductReviewContent.kt new file mode 100644 index 00000000..29bcd62b --- /dev/null +++ b/src/main/kotlin/com/petqua/domain/product/review/ProductReviewContent.kt @@ -0,0 +1,23 @@ +package com.petqua.domain.product.review + +import com.petqua.common.util.throwExceptionWhen +import com.petqua.exception.product.review.ProductReviewException +import com.petqua.exception.product.review.ProductReviewExceptionType.REVIEW_CONTENT_LENGTH_OUT_OF_RANGE +import jakarta.persistence.Embeddable + +@Embeddable +class ProductReviewContent( + val value: String, +) { + + init { + throwExceptionWhen(value.length < MIN_LENGTH || value.length > MAX_LENGTH) { + ProductReviewException(REVIEW_CONTENT_LENGTH_OUT_OF_RANGE) + } + } + + companion object { + private const val MIN_LENGTH = 10 + private const val MAX_LENGTH = 300 + } +} diff --git a/src/main/kotlin/com/petqua/domain/product/review/ProductReviewImageType.kt b/src/main/kotlin/com/petqua/domain/product/review/ProductReviewImageType.kt new file mode 100644 index 00000000..f321da8f --- /dev/null +++ b/src/main/kotlin/com/petqua/domain/product/review/ProductReviewImageType.kt @@ -0,0 +1,26 @@ +package com.petqua.domain.product.review + +import com.petqua.exception.product.review.ProductReviewException +import com.petqua.exception.product.review.ProductReviewExceptionType.UNSUPPORTED_IMAGE_TYPE +import org.springframework.http.MediaType.IMAGE_JPEG_VALUE +import org.springframework.http.MediaType.IMAGE_PNG_VALUE +import java.util.Locale.ENGLISH + +enum class ProductReviewImageType( + val contentType: String, +) { + + IMAGE_JPEG(IMAGE_JPEG_VALUE), + IMAGE_PNG(IMAGE_PNG_VALUE), + ; + + companion object { + fun validateSupported(contentType: String?) { + contentType?.let { + enumValues().find { + it.contentType.uppercase() == contentType.uppercase(ENGLISH) + } + } ?: throw ProductReviewException(UNSUPPORTED_IMAGE_TYPE) + } + } +} diff --git a/src/main/kotlin/com/petqua/exception/product/review/ProductReviewExceptionType.kt b/src/main/kotlin/com/petqua/exception/product/review/ProductReviewExceptionType.kt index 35f5540b..733f506e 100644 --- a/src/main/kotlin/com/petqua/exception/product/review/ProductReviewExceptionType.kt +++ b/src/main/kotlin/com/petqua/exception/product/review/ProductReviewExceptionType.kt @@ -2,6 +2,8 @@ package com.petqua.exception.product.review import com.petqua.common.exception.BaseExceptionType import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatus.BAD_REQUEST +import org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR import org.springframework.http.HttpStatus.NOT_FOUND enum class ProductReviewExceptionType( @@ -11,7 +13,11 @@ enum class ProductReviewExceptionType( ) : BaseExceptionType { NOT_FOUND_PRODUCT_REVIEW(NOT_FOUND, "PR01", "존재하지 않는 리뷰입니다."), - REVIEW_SCORE_OUT_OF_RANGE(NOT_FOUND, "PR02", "리뷰 별점은 1점부터 5점까지만 가능합니다.") + REVIEW_SCORE_OUT_OF_RANGE(BAD_REQUEST, "PR02", "리뷰 별점은 1점부터 5점까지만 가능합니다."), + REVIEW_CONTENT_LENGTH_OUT_OF_RANGE(BAD_REQUEST, "PR03", "리뷰는 최소 10자 최대 300자 작성할 수 있습니다."), + EXCEEDED_REVIEW_IMAGES_COUNT_LIMIT(BAD_REQUEST, "PR04", "최대 리뷰 사진 업로드 개수를 초과했습니다"), + FAILED_REVIEW_IMAGE_UPLOAD(INTERNAL_SERVER_ERROR, "PR05", "리뷰 사진 업로드에 실패했습니다."), + UNSUPPORTED_IMAGE_TYPE(BAD_REQUEST, "PR06", "지원하지 않는 리뷰 이미지 형식입니다."), ; override fun httpStatus(): HttpStatus { diff --git a/src/main/kotlin/com/petqua/presentation/product/ProductReviewController.kt b/src/main/kotlin/com/petqua/presentation/product/ProductReviewController.kt index e87c4a4c..5a4826df 100644 --- a/src/main/kotlin/com/petqua/presentation/product/ProductReviewController.kt +++ b/src/main/kotlin/com/petqua/presentation/product/ProductReviewController.kt @@ -2,19 +2,24 @@ package com.petqua.presentation.product import com.petqua.application.product.dto.ProductReviewStatisticsResponse import com.petqua.application.product.dto.ProductReviewsResponse -import com.petqua.application.product.review.ProductReviewService +import com.petqua.application.product.review.ProductReviewFacadeService import com.petqua.common.config.ACCESS_TOKEN_SECURITY_SCHEME_KEY import com.petqua.domain.auth.Auth import com.petqua.domain.auth.LoginMember import com.petqua.domain.auth.LoginMemberOrGuest +import com.petqua.presentation.product.dto.CreateReviewRequest import com.petqua.presentation.product.dto.ReadAllProductReviewsRequest import com.petqua.presentation.product.dto.UpdateReviewRecommendationRequest import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.security.SecurityRequirement import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.HttpStatus.CREATED +import org.springframework.http.MediaType.APPLICATION_JSON_VALUE +import org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -23,9 +28,31 @@ import org.springframework.web.bind.annotation.RestController @Tag(name = "ProductReview", description = "상품 후기 관련 API 명세") @RestController class ProductReviewController( - private val productReviewService: ProductReviewService + private val productReviewFacadeService: ProductReviewFacadeService, ) { + @Operation(summary = "상품 후기 작성 API", description = "상품의 후기를 작성합니다") + @ApiResponse(responseCode = "201", description = "상품 후기 작성 성공") + @SecurityRequirement(name = ACCESS_TOKEN_SECURITY_SCHEME_KEY) + @PostMapping( + value = ["/products/{productId}/reviews"], + consumes = [MULTIPART_FORM_DATA_VALUE], + produces = [APPLICATION_JSON_VALUE] + ) + fun create( + @Auth loginMember: LoginMember, + @PathVariable productId: Long, + @ModelAttribute request: CreateReviewRequest, + ): ResponseEntity { + val command = request.toCommand( + memberId = loginMember.memberId, + productId = productId, + images = request.images + ) + productReviewFacadeService.create(command) + return ResponseEntity.status(CREATED).build() + } + @Operation(summary = "상품 후기 조건 조회 API", description = "상품의 후기를 조건에 따라 조회합니다") @ApiResponse(responseCode = "200", description = "상품 후기 조건 조회 성공") @SecurityRequirement(name = ACCESS_TOKEN_SECURITY_SCHEME_KEY) @@ -35,7 +62,7 @@ class ProductReviewController( request: ReadAllProductReviewsRequest, @PathVariable productId: Long, ): ResponseEntity { - val responses = productReviewService.readAll( + val responses = productReviewFacadeService.readAll( request.toCommand( productId = productId, loginMemberOrGuest = loginMemberOrGuest, @@ -48,9 +75,9 @@ class ProductReviewController( @ApiResponse(responseCode = "200", description = "상품 후기 통계 조회 성공") @GetMapping("/products/{productId}/review-statistics") fun readReviewCountStatistics( - @PathVariable productId: Long + @PathVariable productId: Long, ): ResponseEntity { - val response = productReviewService.readReviewCountStatistics(productId) + val response = productReviewFacadeService.readReviewCountStatistics(productId) return ResponseEntity.ok(response) } @@ -60,10 +87,10 @@ class ProductReviewController( @PostMapping("/product-reviews/recommendation") fun updateRecommendation( @Auth loginMember: LoginMember, - @RequestBody request: UpdateReviewRecommendationRequest + @RequestBody request: UpdateReviewRecommendationRequest, ): ResponseEntity { val command = request.toCommand(loginMember.memberId) - productReviewService.updateReviewRecommendation(command) + productReviewFacadeService.updateReviewRecommendation(command) return ResponseEntity .noContent() .build() diff --git a/src/main/kotlin/com/petqua/presentation/product/dto/ProductReviewDtos.kt b/src/main/kotlin/com/petqua/presentation/product/dto/ProductReviewDtos.kt index 4ba6edbe..f7ab137e 100644 --- a/src/main/kotlin/com/petqua/presentation/product/dto/ProductReviewDtos.kt +++ b/src/main/kotlin/com/petqua/presentation/product/dto/ProductReviewDtos.kt @@ -1,5 +1,6 @@ package com.petqua.presentation.product.dto +import com.petqua.application.product.dto.ProductReviewCreateCommand import com.petqua.application.product.dto.ProductReviewReadQuery import com.petqua.application.product.dto.UpdateReviewRecommendationCommand import com.petqua.common.domain.dto.PAGING_LIMIT_CEILING @@ -7,6 +8,36 @@ import com.petqua.domain.auth.LoginMemberOrGuest import com.petqua.domain.product.review.ProductReviewSorter import com.petqua.domain.product.review.ProductReviewSorter.REVIEW_DATE_DESC import io.swagger.v3.oas.annotations.media.Schema +import org.springframework.web.multipart.MultipartFile + +data class CreateReviewRequest( + @Schema( + description = "상품 후기 평점", + example = "5" + ) + val score: Int, + + @Schema( + description = "상품 후기 내용", + example = "아주 좋네요" + ) + val content: String, + + @Schema( + description = "상품 후기 이미지 목록", + ) + val images: List = listOf(), +) { + fun toCommand(memberId: Long, productId: Long, images: List): ProductReviewCreateCommand { + return ProductReviewCreateCommand( + memberId = memberId, + productId = productId, + score = score, + content = content, + images = images + ) + } +} data class ReadAllProductReviewsRequest( @Schema( diff --git a/src/test/kotlin/com/petqua/application/image/ImageStorageServiceTest.kt b/src/test/kotlin/com/petqua/application/image/ImageStorageServiceTest.kt new file mode 100644 index 00000000..9df31a26 --- /dev/null +++ b/src/test/kotlin/com/petqua/application/image/ImageStorageServiceTest.kt @@ -0,0 +1,50 @@ +package com.petqua.application.image + +import com.amazonaws.services.s3.AmazonS3 +import com.ninjasquad.springmockk.MockkBean +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.verify +import org.mockito.ArgumentMatchers.any +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.NONE +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import java.net.URL + +@SpringBootTest(webEnvironment = NONE) +class ImageStorageServiceTest( + private val imageStorageService: ImageStorageService, + + @MockkBean + private val amazonS3: AmazonS3, +) : BehaviorSpec({ + + Given("이미지 업로드를 요청할 때") { + val path = "root/directory/image.jpeg" + val mockImage = MockMultipartFile( + "image", + "image.jpeg", + MediaType.IMAGE_JPEG_VALUE, + "image".byteInputStream() + ) + + every { amazonS3.putObject(any(), any(), any(), any()) } returns any() + every { amazonS3.getUrl(any(), any()) } returns URL("https://storedUrl.com/root/directory/image.jpeg") + + When("파일 경로와 이미지를 입력하면") { + val imageUrl = imageStorageService.upload(path = path, image = mockImage) + + Then("업로드한다") { + verify(exactly = 1) { + amazonS3.putObject(any(), any(), any(), any()) + } + } + + Then("이미지 파일을 조회할 수 있는 URL을 반환한다") { + imageUrl shouldBe "https://domain.com/root/directory/image.jpeg" + } + } + } +}) diff --git a/src/test/kotlin/com/petqua/application/image/UrlConverterTest.kt b/src/test/kotlin/com/petqua/application/image/UrlConverterTest.kt new file mode 100644 index 00000000..b8f8d56a --- /dev/null +++ b/src/test/kotlin/com/petqua/application/image/UrlConverterTest.kt @@ -0,0 +1,18 @@ +package com.petqua.application.image + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class UrlConverterTest : StringSpec({ + + "파일 경로와 파일이 저장된 url 을 입력해 접근 가능한 url로 변환한다" { + val domainUrl = "https://domain.com" + val filePath = "root/directory/image.jpeg" + val storedUrl = "https://storedUrl.com/root/directory/image.jpeg" + val urlConverter = UrlConverter(domainUrl) + + val accessibleUrl = urlConverter.convertToAccessibleUrl(filePath, storedUrl) + + accessibleUrl shouldBe "https://domain.com/root/directory/image.jpeg" + } +}) diff --git a/src/test/kotlin/com/petqua/application/product/review/ProductReviewFacadeServiceTest.kt b/src/test/kotlin/com/petqua/application/product/review/ProductReviewFacadeServiceTest.kt new file mode 100644 index 00000000..04fc3a35 --- /dev/null +++ b/src/test/kotlin/com/petqua/application/product/review/ProductReviewFacadeServiceTest.kt @@ -0,0 +1,231 @@ +package com.petqua.application.product.review + +import com.amazonaws.services.s3.AmazonS3 +import com.ninjasquad.springmockk.SpykBean +import com.petqua.application.product.dto.ProductReviewCreateCommand +import com.petqua.common.domain.findByIdOrThrow +import com.petqua.domain.product.review.ProductReviewImageRepository +import com.petqua.domain.product.review.ProductReviewRepository +import com.petqua.exception.product.review.ProductReviewException +import com.petqua.exception.product.review.ProductReviewExceptionType +import com.petqua.test.DataCleaner +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.mockk.verify +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.NONE +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile + +@SpringBootTest(webEnvironment = NONE) +class ProductReviewFacadeServiceTest( + private val productReviewFacadeService: ProductReviewFacadeService, + private val productReviewRepository: ProductReviewRepository, + private val productReviewImageRepository: ProductReviewImageRepository, + private val dataCleaner: DataCleaner, + + @SpykBean + private val amazonS3: AmazonS3, +) : BehaviorSpec({ + + Given("상품 후기를 작성할 때") { + val image1 = MockMultipartFile( + "image1", + "image1.jpeg", + MediaType.IMAGE_JPEG_VALUE, + "image1".byteInputStream() + ) + val image2 = MockMultipartFile( + "image2", + "image2.jpeg", + MediaType.IMAGE_JPEG_VALUE, + "image2".byteInputStream() + ) + + When("후기와 이미지를 입력하면") { + val productReviewId = productReviewFacadeService.create( + ProductReviewCreateCommand( + memberId = 1L, + productId = 1L, + score = 5, + content = "10자가 넘는 정성스러운 후기", + images = listOf(image1) + ) + ) + + Then("이미지를 업로드한다") { + verify(exactly = 1) { + amazonS3.putObject(any(), any(), any(), any()) + } + } + + Then("후기가 저장된다") { + val productReview = productReviewRepository.findByIdOrThrow(productReviewId) + + productReview.memberId shouldBe 1L + productReview.productId shouldBe 1L + productReview.score.value shouldBe 5 + productReview.content.value shouldBe "10자가 넘는 정성스러운 후기" + productReview.hasPhotos shouldBe true + productReview.recommendCount shouldBe 0 + } + + Then("후기 이미지가 저장된다") { + val productReviewImages = productReviewImageRepository.findAll() + + productReviewImages.size shouldBe 1 + productReviewImages[0].productReviewId shouldBe productReviewId + productReviewImages[0].imageUrl shouldContain "https://domain.com/products/reviews/" + } + } + + When("이미지를 여러 개 입력하면") { + val productReviewId = productReviewFacadeService.create( + ProductReviewCreateCommand( + memberId = 1L, + productId = 1L, + score = 5, + content = "10자가 넘는 정성스러운 후기", + images = listOf(image1, image2) + ) + ) + + Then("이미지를 모두 업로드한다") { + verify(exactly = 2) { + amazonS3.putObject(any(), any(), any(), any()) + } + } + + Then("후기 이미지가 모두 저장된다") { + val productReviewImages = productReviewImageRepository.findAll() + + productReviewImages.size shouldBe 2 + productReviewImages[0].productReviewId shouldBe productReviewId + productReviewImages[0].imageUrl shouldContain "https://domain.com/products/reviews/" + productReviewImages[1].productReviewId shouldBe productReviewId + productReviewImages[1].imageUrl shouldContain "https://domain.com/products/reviews/" + } + } + + When("이미지를 입력하지 않으면") { + val productReviewId = productReviewFacadeService.create( + ProductReviewCreateCommand( + memberId = 1L, + productId = 1L, + score = 5, + content = "10자가 넘는 정성스러운 후기", + images = listOf() + ) + ) + + Then("이미지를 업로드하지 않는다") { + verify(exactly = 0) { + amazonS3.putObject(any(), any(), any(), any()) + } + } + + Then("후기에 이미지가 없다고 저장된다") { + val productReview = productReviewRepository.findByIdOrThrow(productReviewId) + + productReview.hasPhotos shouldBe false + } + + Then("후기 이미지가 저장되지 않는다") { + val productReviewImages = productReviewImageRepository.findAll() + + productReviewImages.size shouldBe 0 + } + } + + When("이미지를 10장 초과해 입력하면") { + val command = ProductReviewCreateCommand( + memberId = 1L, + productId = 1L, + score = 5, + content = "10자가 넘는 정성스러운 후기", + images = listOf( + image1, image1, image1, image1, image1, + image1, image1, image1, image1, image1, + image2, + ) + ) + + Then("예외가 발생한다") { + shouldThrow { + productReviewFacadeService.create(command) + }.exceptionType() shouldBe ProductReviewExceptionType.EXCEEDED_REVIEW_IMAGES_COUNT_LIMIT + } + + + Then("이미지를 업로드하지 않는다") { + shouldThrow { + productReviewFacadeService.create(command) + } + + verify(exactly = 0) { + amazonS3.putObject(any(), any(), any(), any()) + } + } + } + + When("별점을 1점 미만, 5점 초과로 입력하면") { + val command = ProductReviewCreateCommand( + memberId = 1L, + productId = 1L, + score = 6, + content = "10자가 넘는 정성스러운 후기", + images = listOf(image1, image2) + ) + + Then("예외가 발생한다") { + shouldThrow { + productReviewFacadeService.create(command) + }.exceptionType() shouldBe ProductReviewExceptionType.REVIEW_SCORE_OUT_OF_RANGE + } + + + Then("이미지를 업로드하지 않는다") { + shouldThrow { + productReviewFacadeService.create(command) + } + + verify(exactly = 0) { + amazonS3.putObject(any(), any(), any(), any()) + } + } + } + + When("후기를 10자 미만, 300자 초과해 입력하면") { + val command = ProductReviewCreateCommand( + memberId = 1L, + productId = 1L, + score = 5, + content = "9자로 적은 후기", + images = listOf(image1, image2) + ) + + Then("예외가 발생한다") { + shouldThrow { + productReviewFacadeService.create(command) + }.exceptionType() shouldBe ProductReviewExceptionType.REVIEW_CONTENT_LENGTH_OUT_OF_RANGE + } + + + Then("이미지를 업로드하지 않는다") { + shouldThrow { + productReviewFacadeService.create(command) + } + + verify(exactly = 0) { + amazonS3.putObject(any(), any(), any(), any()) + } + } + } + } + + afterContainer { + dataCleaner.clean() + } +}) diff --git a/src/test/kotlin/com/petqua/domain/product/review/ProductReviewContentTest.kt b/src/test/kotlin/com/petqua/domain/product/review/ProductReviewContentTest.kt new file mode 100644 index 00000000..3bb45bcc --- /dev/null +++ b/src/test/kotlin/com/petqua/domain/product/review/ProductReviewContentTest.kt @@ -0,0 +1,22 @@ +package com.petqua.domain.product.review + +import com.petqua.exception.product.review.ProductReviewException +import com.petqua.exception.product.review.ProductReviewExceptionType.REVIEW_CONTENT_LENGTH_OUT_OF_RANGE +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.inspectors.forAll +import io.kotest.matchers.shouldBe + +class ProductReviewContentTest : StringSpec({ + + "후기 작성 시 10자 미만, 300자 초과이면 예외를 던진다" { + val shortContent = "후기".repeat(4) + val longContent = "후기".repeat(151) + + listOf(shortContent, longContent).forAll { content -> + shouldThrow { + ProductReviewContent(content) + }.exceptionType() shouldBe REVIEW_CONTENT_LENGTH_OUT_OF_RANGE + } + } +}) diff --git a/src/test/kotlin/com/petqua/domain/product/review/ProductReviewImageTypeTest.kt b/src/test/kotlin/com/petqua/domain/product/review/ProductReviewImageTypeTest.kt new file mode 100644 index 00000000..779076b4 --- /dev/null +++ b/src/test/kotlin/com/petqua/domain/product/review/ProductReviewImageTypeTest.kt @@ -0,0 +1,23 @@ +package com.petqua.domain.product.review + +import com.petqua.exception.product.review.ProductReviewException +import com.petqua.exception.product.review.ProductReviewExceptionType.UNSUPPORTED_IMAGE_TYPE +import io.kotest.assertions.throwables.shouldNotThrow +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class ProductReviewImageTypeTest : StringSpec({ + + "리뷰 이미지로 업로드할 수 있는 타입인지 검증한다" { + shouldNotThrow { + ProductReviewImageType.validateSupported("image/jpeg") + } + } + + "리뷰 이미지로 업로드할 수 없는 타입이라면 예외를 던진다" { + shouldThrow { + ProductReviewImageType.validateSupported("image/gif") + }.exceptionType() shouldBe UNSUPPORTED_IMAGE_TYPE + } +}) diff --git a/src/test/kotlin/com/petqua/presentation/product/ProductReviewControllerSteps.kt b/src/test/kotlin/com/petqua/presentation/product/ProductReviewControllerSteps.kt index 7aae89f2..14b435bb 100644 --- a/src/test/kotlin/com/petqua/presentation/product/ProductReviewControllerSteps.kt +++ b/src/test/kotlin/com/petqua/presentation/product/ProductReviewControllerSteps.kt @@ -2,14 +2,42 @@ package com.petqua.presentation.product import com.petqua.domain.product.review.ProductReviewSorter import com.petqua.domain.product.review.ProductReviewSorter.REVIEW_DATE_DESC +import com.petqua.presentation.product.dto.CreateReviewRequest import com.petqua.presentation.product.dto.UpdateReviewRecommendationRequest import io.restassured.module.kotlin.extensions.Extract import io.restassured.module.kotlin.extensions.Given import io.restassured.module.kotlin.extensions.Then import io.restassured.module.kotlin.extensions.When import io.restassured.response.Response -import org.springframework.http.MediaType +import org.springframework.http.MediaType.APPLICATION_JSON_VALUE +import org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE +fun requestCreateProductReview( + accessToken: String, + productId: Long, + request: CreateReviewRequest, +): Response { + val requestGivenSpec = Given { + log().all() + contentType(MULTIPART_FORM_DATA_VALUE) + auth().preemptive().oauth2(accessToken) + pathParam("productId", productId) + multiPart("score", request.score) + multiPart("content", request.content) + } + + request.images.forEach { image -> + requestGivenSpec.multiPart("images", image.name, image.bytes, image.contentType) + } + + return requestGivenSpec When { + post("/products/{productId}/reviews") + } Then { + log().all() + } Extract { + response() + } +} fun requestReadAllReviewProducts( productId: Long, @@ -17,7 +45,7 @@ fun requestReadAllReviewProducts( lastViewedId: Long = -1, limit: Int = 20, score: Int? = null, - photoOnly: Boolean = false + photoOnly: Boolean = false, ): Response { return Given { log().all() @@ -52,13 +80,13 @@ fun requestReadProductReviewCount(productId: Long): Response { fun requestUpdateReviewRecommendation( request: UpdateReviewRecommendationRequest, - accessToken: String + accessToken: String, ): Response { return Given { log().all() .body(request) .auth().preemptive().oauth2(accessToken) - .contentType(MediaType.APPLICATION_JSON_VALUE) + .contentType(APPLICATION_JSON_VALUE) } When { post("/product-reviews/recommendation") } Then { diff --git a/src/test/kotlin/com/petqua/presentation/product/ProductReviewControllerTest.kt b/src/test/kotlin/com/petqua/presentation/product/ProductReviewControllerTest.kt index 28ac745b..5351bdbc 100644 --- a/src/test/kotlin/com/petqua/presentation/product/ProductReviewControllerTest.kt +++ b/src/test/kotlin/com/petqua/presentation/product/ProductReviewControllerTest.kt @@ -1,13 +1,20 @@ package com.petqua.presentation.product +import com.amazonaws.services.s3.AmazonS3 +import com.ninjasquad.springmockk.SpykBean import com.petqua.application.product.dto.ProductReviewStatisticsResponse import com.petqua.application.product.dto.ProductReviewsResponse import com.petqua.common.domain.findByIdOrThrow +import com.petqua.common.exception.ExceptionResponse import com.petqua.domain.member.MemberRepository import com.petqua.domain.product.ProductRepository import com.petqua.domain.product.review.ProductReviewImageRepository import com.petqua.domain.product.review.ProductReviewRepository import com.petqua.domain.store.StoreRepository +import com.petqua.exception.product.review.ProductReviewExceptionType.EXCEEDED_REVIEW_IMAGES_COUNT_LIMIT +import com.petqua.exception.product.review.ProductReviewExceptionType.REVIEW_CONTENT_LENGTH_OUT_OF_RANGE +import com.petqua.exception.product.review.ProductReviewExceptionType.REVIEW_SCORE_OUT_OF_RANGE +import com.petqua.presentation.product.dto.CreateReviewRequest import com.petqua.presentation.product.dto.UpdateReviewRecommendationRequest import com.petqua.test.ApiTestConfig import com.petqua.test.fixture.member @@ -20,7 +27,13 @@ import io.kotest.inspectors.forAll import io.kotest.matchers.collections.shouldBeSortedWith import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.mockk.verify +import org.springframework.http.HttpStatus.BAD_REQUEST +import org.springframework.http.HttpStatus.CREATED import org.springframework.http.HttpStatus.NO_CONTENT +import org.springframework.http.MediaType.IMAGE_JPEG_VALUE +import org.springframework.mock.web.MockMultipartFile import java.math.BigDecimal class ProductReviewControllerTest( @@ -29,9 +42,187 @@ class ProductReviewControllerTest( private val storeRepository: StoreRepository, private val productReviewRepository: ProductReviewRepository, private val productReviewImageRepository: ProductReviewImageRepository, + + @SpykBean + private val amazonS3: AmazonS3, ) : ApiTestConfig() { init { + Given("상품 후기를 작성할 때") { + val accessToken = signInAsMember().accessToken + val product = productRepository.save(product()) + + val image1 = MockMultipartFile( + "image1", + "image1.jpeg", + IMAGE_JPEG_VALUE, + "image1".byteInputStream() + ) + val image2 = MockMultipartFile( + "image2", + "image2.jpeg", + IMAGE_JPEG_VALUE, + "image2".byteInputStream() + ) + + When("후기와 이미지를 입력하면") { + val response = requestCreateProductReview( + accessToken = accessToken, + productId = product.id, + request = CreateReviewRequest( + score = 5, + content = "this product is good", + images = listOf(image1, image2), + ), + ) + + Then("201 Created 를 응답한다") { + response.statusCode shouldBe CREATED.value() + } + + Then("이미지를 외부 스토리지에 업로드한다") { + verify(exactly = 2) { + amazonS3.putObject(any(), any(), any(), any()) + } + } + + Then("후기가 저장된다") { + val productReview = productReviewRepository.findAll()[0] + + productReview.memberId shouldBe 1L + productReview.productId shouldBe 1L + productReview.score.value shouldBe 5 + productReview.content.value shouldBe "this product is good" + productReview.hasPhotos shouldBe true + productReview.recommendCount shouldBe 0 + } + + Then("후기 이미지가 저장된다") { + val productReview = productReviewRepository.findAll()[0] + val productReviewImages = productReviewImageRepository.findAll() + + productReviewImages.size shouldBe 2 + productReviewImages[0].productReviewId shouldBe productReview.id + productReviewImages[0].imageUrl shouldContain "https://domain.com/products/reviews/" + productReviewImages[1].productReviewId shouldBe productReview.id + productReviewImages[1].imageUrl shouldContain "https://domain.com/products/reviews/" + } + } + + When("이미지없이 후기만 입력하면") { + val response = requestCreateProductReview( + accessToken = accessToken, + productId = product.id, + request = CreateReviewRequest( + score = 5, + content = "this product is good", + images = listOf(), + ), + ) + + Then("201 Created 를 응답한다") { + response.statusCode shouldBe CREATED.value() + } + + Then("이미지를 외부 스토리지에 업로드하지 않는다") { + verify(exactly = 0) { + amazonS3.putObject(any(), any(), any(), any()) + } + } + + Then("후기에 이미지가 없다고 저장된다") { + val productReview = productReviewRepository.findAll()[0] + + productReview.hasPhotos shouldBe false + } + + Then("후기 이미지가 저장되지 않는다") { + val productReviewImages = productReviewImageRepository.findAll() + + productReviewImages.size shouldBe 0 + } + } + + When("이미지를 10장 초과해 입력하면") { + val response = requestCreateProductReview( + accessToken = accessToken, + productId = product.id, + request = CreateReviewRequest( + score = 5, + content = "this product is good", + images = listOf( + image1, image1, image1, image1, image1, + image1, image1, image1, image1, image1, + image2 + ), + ), + ) + + Then("예외를 응답한다") { + val errorResponse = response.`as`(ExceptionResponse::class.java) + + response.statusCode shouldBe BAD_REQUEST.value() + errorResponse.message shouldBe EXCEEDED_REVIEW_IMAGES_COUNT_LIMIT.errorMessage() + } + + Then("이미지를 외부 스토리지에 업로드하지 않는다") { + verify(exactly = 0) { + amazonS3.putObject(any(), any(), any(), any()) + } + } + } + + When("별점을 1점 미만, 5점 초과로 입력하면") { + val response = requestCreateProductReview( + accessToken = accessToken, + productId = product.id, + request = CreateReviewRequest( + score = 0, + content = "this product is good", + images = listOf(image1), + ), + ) + + Then("예외를 응답한다") { + val errorResponse = response.`as`(ExceptionResponse::class.java) + + response.statusCode shouldBe BAD_REQUEST.value() + errorResponse.message shouldBe REVIEW_SCORE_OUT_OF_RANGE.errorMessage() + } + + Then("이미지를 외부 스토리지에 업로드하지 않는다") { + verify(exactly = 0) { + amazonS3.putObject(any(), any(), any(), any()) + } + } + } + + When("후기를 10자 미만, 300자 초과로 입력하면") { + val response = requestCreateProductReview( + accessToken = accessToken, + productId = product.id, + request = CreateReviewRequest( + score = 5, + content = "this product is good".repeat(30), + images = listOf(image1), + ), + ) + + Then("예외를 응답한다") { + val errorResponse = response.`as`(ExceptionResponse::class.java) + + response.statusCode shouldBe BAD_REQUEST.value() + errorResponse.message shouldBe REVIEW_CONTENT_LENGTH_OUT_OF_RANGE.errorMessage() + } + + Then("이미지를 외부 스토리지에 업로드하지 않는다") { + verify(exactly = 0) { + amazonS3.putObject(any(), any(), any(), any()) + } + } + } + } + Given("조건에 따라 상품 후기를 조회 하면") { val store = storeRepository.save(store(name = "펫쿠아")) val member = memberRepository.save(member(nickname = "쿠아")) @@ -98,7 +289,6 @@ class ProductReviewControllerTest( ) When("전체 별점, 최신순으로 조회 하면") { - val response = requestReadAllReviewProducts(productId = product.id, limit = 3) Then("조회된 상품 후기 목록을 반환한다") { diff --git a/src/test/kotlin/com/petqua/test/config/ApiClientTestConfig.kt b/src/test/kotlin/com/petqua/test/config/ApiClientTestConfig.kt index 94f53bed..d23b8c81 100644 --- a/src/test/kotlin/com/petqua/test/config/ApiClientTestConfig.kt +++ b/src/test/kotlin/com/petqua/test/config/ApiClientTestConfig.kt @@ -1,7 +1,9 @@ package com.petqua.test.config +import com.amazonaws.services.s3.AmazonS3 import com.petqua.application.payment.infra.TossPaymentsApiClient import com.petqua.domain.auth.oauth.kakao.KakaoOauthApiClient +import com.petqua.test.fake.FakeAmazonS3 import com.petqua.test.fake.FakeKakaoOauthApiClient import com.petqua.test.fake.FakeTossPaymentsApiClient import org.springframework.context.annotation.Bean @@ -21,4 +23,9 @@ class ApiClientTestConfig { fun tossPaymentsApiClient(): TossPaymentsApiClient { return FakeTossPaymentsApiClient() } + + @Bean + fun amazonS3(): AmazonS3 { + return FakeAmazonS3() + } } diff --git a/src/test/kotlin/com/petqua/test/fake/FakeAmazonS3.kt b/src/test/kotlin/com/petqua/test/fake/FakeAmazonS3.kt new file mode 100644 index 00000000..b0f1eb70 --- /dev/null +++ b/src/test/kotlin/com/petqua/test/fake/FakeAmazonS3.kt @@ -0,0 +1,860 @@ +package com.petqua.test.fake + +import com.amazonaws.AmazonWebServiceRequest +import com.amazonaws.HttpMethod +import com.amazonaws.regions.Region +import com.amazonaws.services.s3.AmazonS3 +import com.amazonaws.services.s3.S3ClientOptions +import com.amazonaws.services.s3.S3ResponseMetadata +import com.amazonaws.services.s3.model.* +import com.amazonaws.services.s3.model.analytics.AnalyticsConfiguration +import com.amazonaws.services.s3.model.intelligenttiering.IntelligentTieringConfiguration +import com.amazonaws.services.s3.model.inventory.InventoryConfiguration +import com.amazonaws.services.s3.model.metrics.MetricsConfiguration +import com.amazonaws.services.s3.model.ownership.OwnershipControls +import com.amazonaws.services.s3.waiters.AmazonS3Waiters +import java.io.File +import java.io.InputStream +import java.net.URL +import java.util.Date + +class FakeAmazonS3 : AmazonS3 { + override fun putObject(putObjectRequest: PutObjectRequest?): PutObjectResult { + TODO("Not yet implemented") + } + + override fun putObject(bucketName: String?, key: String?, file: File?): PutObjectResult { + TODO("Not yet implemented") + } + + override fun putObject( + bucketName: String?, + key: String?, + input: InputStream?, + metadata: ObjectMetadata?, + ): PutObjectResult { + return PutObjectResult() + } + + override fun putObject(bucketName: String?, key: String?, content: String?): PutObjectResult { + TODO("Not yet implemented") + } + + override fun getObject(bucketName: String?, key: String?): S3Object { + TODO("Not yet implemented") + } + + override fun getObject(getObjectRequest: GetObjectRequest?): S3Object { + TODO("Not yet implemented") + } + + override fun getObject(getObjectRequest: GetObjectRequest?, destinationFile: File?): ObjectMetadata { + TODO("Not yet implemented") + } + + override fun completeMultipartUpload(request: CompleteMultipartUploadRequest?): CompleteMultipartUploadResult { + TODO("Not yet implemented") + } + + override fun initiateMultipartUpload(request: InitiateMultipartUploadRequest?): InitiateMultipartUploadResult { + TODO("Not yet implemented") + } + + override fun uploadPart(request: UploadPartRequest?): UploadPartResult { + TODO("Not yet implemented") + } + + override fun copyPart(copyPartRequest: CopyPartRequest?): CopyPartResult { + TODO("Not yet implemented") + } + + override fun abortMultipartUpload(request: AbortMultipartUploadRequest?) { + TODO("Not yet implemented") + } + + override fun setEndpoint(endpoint: String?) { + TODO("Not yet implemented") + } + + override fun setRegion(region: Region?) { + TODO("Not yet implemented") + } + + override fun setS3ClientOptions(clientOptions: S3ClientOptions?) { + TODO("Not yet implemented") + } + + override fun changeObjectStorageClass(bucketName: String?, key: String?, newStorageClass: StorageClass?) { + TODO("Not yet implemented") + } + + override fun setObjectRedirectLocation(bucketName: String?, key: String?, newRedirectLocation: String?) { + TODO("Not yet implemented") + } + + override fun listObjects(bucketName: String?): ObjectListing { + TODO("Not yet implemented") + } + + override fun listObjects(bucketName: String?, prefix: String?): ObjectListing { + TODO("Not yet implemented") + } + + override fun listObjects(listObjectsRequest: ListObjectsRequest?): ObjectListing { + TODO("Not yet implemented") + } + + override fun listObjectsV2(bucketName: String?): ListObjectsV2Result { + TODO("Not yet implemented") + } + + override fun listObjectsV2(bucketName: String?, prefix: String?): ListObjectsV2Result { + TODO("Not yet implemented") + } + + override fun listObjectsV2(listObjectsV2Request: ListObjectsV2Request?): ListObjectsV2Result { + TODO("Not yet implemented") + } + + override fun listNextBatchOfObjects(previousObjectListing: ObjectListing?): ObjectListing { + TODO("Not yet implemented") + } + + override fun listNextBatchOfObjects(listNextBatchOfObjectsRequest: ListNextBatchOfObjectsRequest?): ObjectListing { + TODO("Not yet implemented") + } + + override fun listVersions(bucketName: String?, prefix: String?): VersionListing { + TODO("Not yet implemented") + } + + override fun listVersions( + bucketName: String?, + prefix: String?, + keyMarker: String?, + versionIdMarker: String?, + delimiter: String?, + maxResults: Int?, + ): VersionListing { + TODO("Not yet implemented") + } + + override fun listVersions(listVersionsRequest: ListVersionsRequest?): VersionListing { + TODO("Not yet implemented") + } + + override fun listNextBatchOfVersions(previousVersionListing: VersionListing?): VersionListing { + TODO("Not yet implemented") + } + + override fun listNextBatchOfVersions(listNextBatchOfVersionsRequest: ListNextBatchOfVersionsRequest?): VersionListing { + TODO("Not yet implemented") + } + + override fun getS3AccountOwner(): Owner { + TODO("Not yet implemented") + } + + override fun getS3AccountOwner(getS3AccountOwnerRequest: GetS3AccountOwnerRequest?): Owner { + TODO("Not yet implemented") + } + + override fun doesBucketExist(bucketName: String?): Boolean { + TODO("Not yet implemented") + } + + override fun doesBucketExistV2(bucketName: String?): Boolean { + TODO("Not yet implemented") + } + + override fun headBucket(headBucketRequest: HeadBucketRequest?): HeadBucketResult { + TODO("Not yet implemented") + } + + override fun listBuckets(): MutableList { + TODO("Not yet implemented") + } + + override fun listBuckets(listBucketsRequest: ListBucketsRequest?): MutableList { + TODO("Not yet implemented") + } + + override fun getBucketLocation(bucketName: String?): String { + TODO("Not yet implemented") + } + + override fun getBucketLocation(getBucketLocationRequest: GetBucketLocationRequest?): String { + TODO("Not yet implemented") + } + + override fun createBucket(createBucketRequest: CreateBucketRequest?): Bucket { + TODO("Not yet implemented") + } + + override fun createBucket(bucketName: String?): Bucket { + TODO("Not yet implemented") + } + + override fun createBucket(bucketName: String?, region: com.amazonaws.services.s3.model.Region?): Bucket { + TODO("Not yet implemented") + } + + override fun createBucket(bucketName: String?, region: String?): Bucket { + TODO("Not yet implemented") + } + + override fun getObjectAcl(bucketName: String?, key: String?): AccessControlList { + TODO("Not yet implemented") + } + + override fun getObjectAcl(bucketName: String?, key: String?, versionId: String?): AccessControlList { + TODO("Not yet implemented") + } + + override fun getObjectAcl(getObjectAclRequest: GetObjectAclRequest?): AccessControlList { + TODO("Not yet implemented") + } + + override fun setObjectAcl(bucketName: String?, key: String?, acl: AccessControlList?) { + TODO("Not yet implemented") + } + + override fun setObjectAcl(bucketName: String?, key: String?, acl: CannedAccessControlList?) { + TODO("Not yet implemented") + } + + override fun setObjectAcl(bucketName: String?, key: String?, versionId: String?, acl: AccessControlList?) { + TODO("Not yet implemented") + } + + override fun setObjectAcl(bucketName: String?, key: String?, versionId: String?, acl: CannedAccessControlList?) { + TODO("Not yet implemented") + } + + override fun setObjectAcl(setObjectAclRequest: SetObjectAclRequest?) { + TODO("Not yet implemented") + } + + override fun getBucketAcl(bucketName: String?): AccessControlList { + TODO("Not yet implemented") + } + + override fun getBucketAcl(getBucketAclRequest: GetBucketAclRequest?): AccessControlList { + TODO("Not yet implemented") + } + + override fun setBucketAcl(setBucketAclRequest: SetBucketAclRequest?) { + TODO("Not yet implemented") + } + + override fun setBucketAcl(bucketName: String?, acl: AccessControlList?) { + TODO("Not yet implemented") + } + + override fun setBucketAcl(bucketName: String?, acl: CannedAccessControlList?) { + TODO("Not yet implemented") + } + + override fun getObjectMetadata(bucketName: String?, key: String?): ObjectMetadata { + TODO("Not yet implemented") + } + + override fun getObjectMetadata(getObjectMetadataRequest: GetObjectMetadataRequest?): ObjectMetadata { + TODO("Not yet implemented") + } + + override fun getObjectAsString(bucketName: String?, key: String?): String { + TODO("Not yet implemented") + } + + override fun getObjectTagging(getObjectTaggingRequest: GetObjectTaggingRequest?): GetObjectTaggingResult { + TODO("Not yet implemented") + } + + override fun setObjectTagging(setObjectTaggingRequest: SetObjectTaggingRequest?): SetObjectTaggingResult { + TODO("Not yet implemented") + } + + override fun deleteObjectTagging(deleteObjectTaggingRequest: DeleteObjectTaggingRequest?): DeleteObjectTaggingResult { + TODO("Not yet implemented") + } + + override fun deleteBucket(deleteBucketRequest: DeleteBucketRequest?) { + TODO("Not yet implemented") + } + + override fun deleteBucket(bucketName: String?) { + TODO("Not yet implemented") + } + + override fun copyObject( + sourceBucketName: String?, + sourceKey: String?, + destinationBucketName: String?, + destinationKey: String?, + ): CopyObjectResult { + TODO("Not yet implemented") + } + + override fun copyObject(copyObjectRequest: CopyObjectRequest?): CopyObjectResult { + TODO("Not yet implemented") + } + + override fun deleteObject(bucketName: String?, key: String?) { + TODO("Not yet implemented") + } + + override fun deleteObject(deleteObjectRequest: DeleteObjectRequest?) { + TODO("Not yet implemented") + } + + override fun deleteObjects(deleteObjectsRequest: DeleteObjectsRequest?): DeleteObjectsResult { + TODO("Not yet implemented") + } + + override fun deleteVersion(bucketName: String?, key: String?, versionId: String?) { + TODO("Not yet implemented") + } + + override fun deleteVersion(deleteVersionRequest: DeleteVersionRequest?) { + TODO("Not yet implemented") + } + + override fun getBucketLoggingConfiguration(bucketName: String?): BucketLoggingConfiguration { + TODO("Not yet implemented") + } + + override fun getBucketLoggingConfiguration(getBucketLoggingConfigurationRequest: GetBucketLoggingConfigurationRequest?): BucketLoggingConfiguration { + TODO("Not yet implemented") + } + + override fun setBucketLoggingConfiguration(setBucketLoggingConfigurationRequest: SetBucketLoggingConfigurationRequest?) { + TODO("Not yet implemented") + } + + override fun getBucketVersioningConfiguration(bucketName: String?): BucketVersioningConfiguration { + TODO("Not yet implemented") + } + + override fun getBucketVersioningConfiguration(getBucketVersioningConfigurationRequest: GetBucketVersioningConfigurationRequest?): BucketVersioningConfiguration { + TODO("Not yet implemented") + } + + override fun setBucketVersioningConfiguration(setBucketVersioningConfigurationRequest: SetBucketVersioningConfigurationRequest?) { + TODO("Not yet implemented") + } + + override fun getBucketLifecycleConfiguration(bucketName: String?): BucketLifecycleConfiguration { + TODO("Not yet implemented") + } + + override fun getBucketLifecycleConfiguration(getBucketLifecycleConfigurationRequest: GetBucketLifecycleConfigurationRequest?): BucketLifecycleConfiguration { + TODO("Not yet implemented") + } + + override fun setBucketLifecycleConfiguration( + bucketName: String?, + bucketLifecycleConfiguration: BucketLifecycleConfiguration?, + ) { + TODO("Not yet implemented") + } + + override fun setBucketLifecycleConfiguration(setBucketLifecycleConfigurationRequest: SetBucketLifecycleConfigurationRequest?) { + TODO("Not yet implemented") + } + + override fun deleteBucketLifecycleConfiguration(bucketName: String?) { + TODO("Not yet implemented") + } + + override fun deleteBucketLifecycleConfiguration(deleteBucketLifecycleConfigurationRequest: DeleteBucketLifecycleConfigurationRequest?) { + TODO("Not yet implemented") + } + + override fun getBucketCrossOriginConfiguration(bucketName: String?): BucketCrossOriginConfiguration { + TODO("Not yet implemented") + } + + override fun getBucketCrossOriginConfiguration(getBucketCrossOriginConfigurationRequest: GetBucketCrossOriginConfigurationRequest?): BucketCrossOriginConfiguration { + TODO("Not yet implemented") + } + + override fun setBucketCrossOriginConfiguration( + bucketName: String?, + bucketCrossOriginConfiguration: BucketCrossOriginConfiguration?, + ) { + TODO("Not yet implemented") + } + + override fun setBucketCrossOriginConfiguration(setBucketCrossOriginConfigurationRequest: SetBucketCrossOriginConfigurationRequest?) { + TODO("Not yet implemented") + } + + override fun deleteBucketCrossOriginConfiguration(bucketName: String?) { + TODO("Not yet implemented") + } + + override fun deleteBucketCrossOriginConfiguration(deleteBucketCrossOriginConfigurationRequest: DeleteBucketCrossOriginConfigurationRequest?) { + TODO("Not yet implemented") + } + + override fun getBucketTaggingConfiguration(bucketName: String?): BucketTaggingConfiguration { + TODO("Not yet implemented") + } + + override fun getBucketTaggingConfiguration(getBucketTaggingConfigurationRequest: GetBucketTaggingConfigurationRequest?): BucketTaggingConfiguration { + TODO("Not yet implemented") + } + + override fun setBucketTaggingConfiguration( + bucketName: String?, + bucketTaggingConfiguration: BucketTaggingConfiguration?, + ) { + TODO("Not yet implemented") + } + + override fun setBucketTaggingConfiguration(setBucketTaggingConfigurationRequest: SetBucketTaggingConfigurationRequest?) { + TODO("Not yet implemented") + } + + override fun deleteBucketTaggingConfiguration(bucketName: String?) { + TODO("Not yet implemented") + } + + override fun deleteBucketTaggingConfiguration(deleteBucketTaggingConfigurationRequest: DeleteBucketTaggingConfigurationRequest?) { + TODO("Not yet implemented") + } + + override fun getBucketNotificationConfiguration(bucketName: String?): BucketNotificationConfiguration { + TODO("Not yet implemented") + } + + override fun getBucketNotificationConfiguration(getBucketNotificationConfigurationRequest: GetBucketNotificationConfigurationRequest?): BucketNotificationConfiguration { + TODO("Not yet implemented") + } + + override fun setBucketNotificationConfiguration(setBucketNotificationConfigurationRequest: SetBucketNotificationConfigurationRequest?) { + TODO("Not yet implemented") + } + + override fun setBucketNotificationConfiguration( + bucketName: String?, + bucketNotificationConfiguration: BucketNotificationConfiguration?, + ) { + TODO("Not yet implemented") + } + + override fun getBucketWebsiteConfiguration(bucketName: String?): BucketWebsiteConfiguration { + TODO("Not yet implemented") + } + + override fun getBucketWebsiteConfiguration(getBucketWebsiteConfigurationRequest: GetBucketWebsiteConfigurationRequest?): BucketWebsiteConfiguration { + TODO("Not yet implemented") + } + + override fun setBucketWebsiteConfiguration(bucketName: String?, configuration: BucketWebsiteConfiguration?) { + TODO("Not yet implemented") + } + + override fun setBucketWebsiteConfiguration(setBucketWebsiteConfigurationRequest: SetBucketWebsiteConfigurationRequest?) { + TODO("Not yet implemented") + } + + override fun deleteBucketWebsiteConfiguration(bucketName: String?) { + TODO("Not yet implemented") + } + + override fun deleteBucketWebsiteConfiguration(deleteBucketWebsiteConfigurationRequest: DeleteBucketWebsiteConfigurationRequest?) { + TODO("Not yet implemented") + } + + override fun getBucketPolicy(bucketName: String?): BucketPolicy { + TODO("Not yet implemented") + } + + override fun getBucketPolicy(getBucketPolicyRequest: GetBucketPolicyRequest?): BucketPolicy { + TODO("Not yet implemented") + } + + override fun setBucketPolicy(bucketName: String?, policyText: String?) { + TODO("Not yet implemented") + } + + override fun setBucketPolicy(setBucketPolicyRequest: SetBucketPolicyRequest?) { + TODO("Not yet implemented") + } + + override fun deleteBucketPolicy(bucketName: String?) { + TODO("Not yet implemented") + } + + override fun deleteBucketPolicy(deleteBucketPolicyRequest: DeleteBucketPolicyRequest?) { + TODO("Not yet implemented") + } + + override fun generatePresignedUrl(bucketName: String?, key: String?, expiration: Date?): URL { + TODO("Not yet implemented") + } + + override fun generatePresignedUrl(bucketName: String?, key: String?, expiration: Date?, method: HttpMethod?): URL { + TODO("Not yet implemented") + } + + override fun generatePresignedUrl(generatePresignedUrlRequest: GeneratePresignedUrlRequest?): URL { + TODO("Not yet implemented") + } + + override fun listParts(request: ListPartsRequest?): PartListing { + TODO("Not yet implemented") + } + + override fun listMultipartUploads(request: ListMultipartUploadsRequest?): MultipartUploadListing { + TODO("Not yet implemented") + } + + override fun getCachedResponseMetadata(request: AmazonWebServiceRequest?): S3ResponseMetadata { + TODO("Not yet implemented") + } + + override fun restoreObject(request: RestoreObjectRequest?) { + TODO("Not yet implemented") + } + + override fun restoreObject(bucketName: String?, key: String?, expirationInDays: Int) { + TODO("Not yet implemented") + } + + override fun restoreObjectV2(request: RestoreObjectRequest?): RestoreObjectResult { + TODO("Not yet implemented") + } + + override fun enableRequesterPays(bucketName: String?) { + TODO("Not yet implemented") + } + + override fun disableRequesterPays(bucketName: String?) { + TODO("Not yet implemented") + } + + override fun isRequesterPaysEnabled(bucketName: String?): Boolean { + TODO("Not yet implemented") + } + + override fun setRequestPaymentConfiguration(setRequestPaymentConfigurationRequest: SetRequestPaymentConfigurationRequest?) { + TODO("Not yet implemented") + } + + override fun setBucketReplicationConfiguration( + bucketName: String?, + configuration: BucketReplicationConfiguration?, + ) { + TODO("Not yet implemented") + } + + override fun setBucketReplicationConfiguration(setBucketReplicationConfigurationRequest: SetBucketReplicationConfigurationRequest?) { + TODO("Not yet implemented") + } + + override fun getBucketReplicationConfiguration(bucketName: String?): BucketReplicationConfiguration { + TODO("Not yet implemented") + } + + override fun getBucketReplicationConfiguration(getBucketReplicationConfigurationRequest: GetBucketReplicationConfigurationRequest?): BucketReplicationConfiguration { + TODO("Not yet implemented") + } + + override fun deleteBucketReplicationConfiguration(bucketName: String?) { + TODO("Not yet implemented") + } + + override fun deleteBucketReplicationConfiguration(request: DeleteBucketReplicationConfigurationRequest?) { + TODO("Not yet implemented") + } + + override fun doesObjectExist(bucketName: String?, objectName: String?): Boolean { + TODO("Not yet implemented") + } + + override fun getBucketAccelerateConfiguration(bucketName: String?): BucketAccelerateConfiguration { + TODO("Not yet implemented") + } + + override fun getBucketAccelerateConfiguration(getBucketAccelerateConfigurationRequest: GetBucketAccelerateConfigurationRequest?): BucketAccelerateConfiguration { + TODO("Not yet implemented") + } + + override fun setBucketAccelerateConfiguration( + bucketName: String?, + accelerateConfiguration: BucketAccelerateConfiguration?, + ) { + TODO("Not yet implemented") + } + + override fun setBucketAccelerateConfiguration(setBucketAccelerateConfigurationRequest: SetBucketAccelerateConfigurationRequest?) { + TODO("Not yet implemented") + } + + override fun deleteBucketMetricsConfiguration( + bucketName: String?, + id: String?, + ): DeleteBucketMetricsConfigurationResult { + TODO("Not yet implemented") + } + + override fun deleteBucketMetricsConfiguration(deleteBucketMetricsConfigurationRequest: DeleteBucketMetricsConfigurationRequest?): DeleteBucketMetricsConfigurationResult { + TODO("Not yet implemented") + } + + override fun getBucketMetricsConfiguration(bucketName: String?, id: String?): GetBucketMetricsConfigurationResult { + TODO("Not yet implemented") + } + + override fun getBucketMetricsConfiguration(getBucketMetricsConfigurationRequest: GetBucketMetricsConfigurationRequest?): GetBucketMetricsConfigurationResult { + TODO("Not yet implemented") + } + + override fun setBucketMetricsConfiguration( + bucketName: String?, + metricsConfiguration: MetricsConfiguration?, + ): SetBucketMetricsConfigurationResult { + TODO("Not yet implemented") + } + + override fun setBucketMetricsConfiguration(setBucketMetricsConfigurationRequest: SetBucketMetricsConfigurationRequest?): SetBucketMetricsConfigurationResult { + TODO("Not yet implemented") + } + + override fun listBucketMetricsConfigurations(listBucketMetricsConfigurationsRequest: ListBucketMetricsConfigurationsRequest?): ListBucketMetricsConfigurationsResult { + TODO("Not yet implemented") + } + + override fun deleteBucketOwnershipControls(deleteBucketOwnershipControlsRequest: DeleteBucketOwnershipControlsRequest?): DeleteBucketOwnershipControlsResult { + TODO("Not yet implemented") + } + + override fun getBucketOwnershipControls(getBucketOwnershipControlsRequest: GetBucketOwnershipControlsRequest?): GetBucketOwnershipControlsResult { + TODO("Not yet implemented") + } + + override fun setBucketOwnershipControls( + bucketName: String?, + ownershipControls: OwnershipControls?, + ): SetBucketOwnershipControlsResult { + TODO("Not yet implemented") + } + + override fun setBucketOwnershipControls(setBucketOwnershipControlsRequest: SetBucketOwnershipControlsRequest?): SetBucketOwnershipControlsResult { + TODO("Not yet implemented") + } + + override fun deleteBucketAnalyticsConfiguration( + bucketName: String?, + id: String?, + ): DeleteBucketAnalyticsConfigurationResult { + TODO("Not yet implemented") + } + + override fun deleteBucketAnalyticsConfiguration(deleteBucketAnalyticsConfigurationRequest: DeleteBucketAnalyticsConfigurationRequest?): DeleteBucketAnalyticsConfigurationResult { + TODO("Not yet implemented") + } + + override fun getBucketAnalyticsConfiguration( + bucketName: String?, + id: String?, + ): GetBucketAnalyticsConfigurationResult { + TODO("Not yet implemented") + } + + override fun getBucketAnalyticsConfiguration(getBucketAnalyticsConfigurationRequest: GetBucketAnalyticsConfigurationRequest?): GetBucketAnalyticsConfigurationResult { + TODO("Not yet implemented") + } + + override fun setBucketAnalyticsConfiguration( + bucketName: String?, + analyticsConfiguration: AnalyticsConfiguration?, + ): SetBucketAnalyticsConfigurationResult { + TODO("Not yet implemented") + } + + override fun setBucketAnalyticsConfiguration(setBucketAnalyticsConfigurationRequest: SetBucketAnalyticsConfigurationRequest?): SetBucketAnalyticsConfigurationResult { + TODO("Not yet implemented") + } + + override fun listBucketAnalyticsConfigurations(listBucketAnalyticsConfigurationsRequest: ListBucketAnalyticsConfigurationsRequest?): ListBucketAnalyticsConfigurationsResult { + TODO("Not yet implemented") + } + + override fun deleteBucketIntelligentTieringConfiguration( + bucketName: String?, + id: String?, + ): DeleteBucketIntelligentTieringConfigurationResult { + TODO("Not yet implemented") + } + + override fun deleteBucketIntelligentTieringConfiguration(deleteBucketIntelligentTieringConfigurationRequest: DeleteBucketIntelligentTieringConfigurationRequest?): DeleteBucketIntelligentTieringConfigurationResult { + TODO("Not yet implemented") + } + + override fun getBucketIntelligentTieringConfiguration( + bucketName: String?, + id: String?, + ): GetBucketIntelligentTieringConfigurationResult { + TODO("Not yet implemented") + } + + override fun getBucketIntelligentTieringConfiguration(getBucketIntelligentTieringConfigurationRequest: GetBucketIntelligentTieringConfigurationRequest?): GetBucketIntelligentTieringConfigurationResult { + TODO("Not yet implemented") + } + + override fun setBucketIntelligentTieringConfiguration( + bucketName: String?, + intelligentTieringConfiguration: IntelligentTieringConfiguration?, + ): SetBucketIntelligentTieringConfigurationResult { + TODO("Not yet implemented") + } + + override fun setBucketIntelligentTieringConfiguration(setBucketIntelligentTieringConfigurationRequest: SetBucketIntelligentTieringConfigurationRequest?): SetBucketIntelligentTieringConfigurationResult { + TODO("Not yet implemented") + } + + override fun listBucketIntelligentTieringConfigurations(listBucketIntelligentTieringConfigurationsRequest: ListBucketIntelligentTieringConfigurationsRequest?): ListBucketIntelligentTieringConfigurationsResult { + TODO("Not yet implemented") + } + + override fun deleteBucketInventoryConfiguration( + bucketName: String?, + id: String?, + ): DeleteBucketInventoryConfigurationResult { + TODO("Not yet implemented") + } + + override fun deleteBucketInventoryConfiguration(deleteBucketInventoryConfigurationRequest: DeleteBucketInventoryConfigurationRequest?): DeleteBucketInventoryConfigurationResult { + TODO("Not yet implemented") + } + + override fun getBucketInventoryConfiguration( + bucketName: String?, + id: String?, + ): GetBucketInventoryConfigurationResult { + TODO("Not yet implemented") + } + + override fun getBucketInventoryConfiguration(getBucketInventoryConfigurationRequest: GetBucketInventoryConfigurationRequest?): GetBucketInventoryConfigurationResult { + TODO("Not yet implemented") + } + + override fun setBucketInventoryConfiguration( + bucketName: String?, + inventoryConfiguration: InventoryConfiguration?, + ): SetBucketInventoryConfigurationResult { + TODO("Not yet implemented") + } + + override fun setBucketInventoryConfiguration(setBucketInventoryConfigurationRequest: SetBucketInventoryConfigurationRequest?): SetBucketInventoryConfigurationResult { + TODO("Not yet implemented") + } + + override fun listBucketInventoryConfigurations(listBucketInventoryConfigurationsRequest: ListBucketInventoryConfigurationsRequest?): ListBucketInventoryConfigurationsResult { + TODO("Not yet implemented") + } + + override fun deleteBucketEncryption(bucketName: String?): DeleteBucketEncryptionResult { + TODO("Not yet implemented") + } + + override fun deleteBucketEncryption(request: DeleteBucketEncryptionRequest?): DeleteBucketEncryptionResult { + TODO("Not yet implemented") + } + + override fun getBucketEncryption(bucketName: String?): GetBucketEncryptionResult { + TODO("Not yet implemented") + } + + override fun getBucketEncryption(request: GetBucketEncryptionRequest?): GetBucketEncryptionResult { + TODO("Not yet implemented") + } + + override fun setBucketEncryption(setBucketEncryptionRequest: SetBucketEncryptionRequest?): SetBucketEncryptionResult { + TODO("Not yet implemented") + } + + override fun setPublicAccessBlock(request: SetPublicAccessBlockRequest?): SetPublicAccessBlockResult { + TODO("Not yet implemented") + } + + override fun getPublicAccessBlock(request: GetPublicAccessBlockRequest?): GetPublicAccessBlockResult { + TODO("Not yet implemented") + } + + override fun deletePublicAccessBlock(request: DeletePublicAccessBlockRequest?): DeletePublicAccessBlockResult { + TODO("Not yet implemented") + } + + override fun getBucketPolicyStatus(request: GetBucketPolicyStatusRequest?): GetBucketPolicyStatusResult { + TODO("Not yet implemented") + } + + override fun selectObjectContent(selectRequest: SelectObjectContentRequest?): SelectObjectContentResult { + TODO("Not yet implemented") + } + + override fun setObjectLegalHold(setObjectLegalHoldRequest: SetObjectLegalHoldRequest?): SetObjectLegalHoldResult { + TODO("Not yet implemented") + } + + override fun getObjectLegalHold(getObjectLegalHoldRequest: GetObjectLegalHoldRequest?): GetObjectLegalHoldResult { + TODO("Not yet implemented") + } + + override fun setObjectLockConfiguration(setObjectLockConfigurationRequest: SetObjectLockConfigurationRequest?): SetObjectLockConfigurationResult { + TODO("Not yet implemented") + } + + override fun getObjectLockConfiguration(getObjectLockConfigurationRequest: GetObjectLockConfigurationRequest?): GetObjectLockConfigurationResult { + TODO("Not yet implemented") + } + + override fun setObjectRetention(setObjectRetentionRequest: SetObjectRetentionRequest?): SetObjectRetentionResult { + TODO("Not yet implemented") + } + + override fun getObjectRetention(getObjectRetentionRequest: GetObjectRetentionRequest?): GetObjectRetentionResult { + TODO("Not yet implemented") + } + + override fun writeGetObjectResponse(writeGetObjectResponseRequest: WriteGetObjectResponseRequest?): WriteGetObjectResponseResult { + TODO("Not yet implemented") + } + + override fun download(presignedUrlDownloadRequest: PresignedUrlDownloadRequest?): PresignedUrlDownloadResult { + TODO("Not yet implemented") + } + + override fun download(presignedUrlDownloadRequest: PresignedUrlDownloadRequest?, destinationFile: File?) { + TODO("Not yet implemented") + } + + override fun upload(presignedUrlUploadRequest: PresignedUrlUploadRequest?): PresignedUrlUploadResult { + TODO("Not yet implemented") + } + + override fun shutdown() { + TODO("Not yet implemented") + } + + override fun getRegion(): com.amazonaws.services.s3.model.Region { + TODO("Not yet implemented") + } + + override fun getRegionName(): String { + TODO("Not yet implemented") + } + + override fun getUrl(bucketName: String?, key: String?): URL { + return URL("https://storedUrl.com/$key") + } + + override fun waiters(): AmazonS3Waiters { + TODO("Not yet implemented") + } +} diff --git a/src/test/kotlin/com/petqua/test/fixture/ProductReviewFixtures.kt b/src/test/kotlin/com/petqua/test/fixture/ProductReviewFixtures.kt index f9c2f00b..6745b015 100644 --- a/src/test/kotlin/com/petqua/test/fixture/ProductReviewFixtures.kt +++ b/src/test/kotlin/com/petqua/test/fixture/ProductReviewFixtures.kt @@ -1,12 +1,15 @@ package com.petqua.test.fixture +import com.petqua.application.product.dto.ProductReviewCreateCommand import com.petqua.domain.product.review.ProductReview +import com.petqua.domain.product.review.ProductReviewContent import com.petqua.domain.product.review.ProductReviewImage import com.petqua.domain.product.review.ProductReviewScore +import org.springframework.web.multipart.MultipartFile fun productReview( id: Long = 0L, - content: String = "content", + content: String = "This is a product review content", productId: Long, reviewerId: Long, score: Int = 5, @@ -15,7 +18,7 @@ fun productReview( ): ProductReview { return ProductReview( id = id, - content = content, + content = ProductReviewContent(content), productId = productId, memberId = reviewerId, score = ProductReviewScore(score), @@ -35,3 +38,19 @@ fun productReviewImage( productReviewId = productReviewId, ) } + +fun productReviewCreateCommand( + productId: Long = 0L, + memberId: Long = 0L, + score: Int, + content: String, + images: List, +): ProductReviewCreateCommand { + return ProductReviewCreateCommand( + productId = productId, + memberId = memberId, + score = score, + content = content, + images = images, + ) +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 351df777..2e561037 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -40,3 +40,18 @@ payment: secret-key: test-secret-key # for test success-url: ${base-url}/orders/payment/success fail-url: ${base-url}/orders/payment/fail + +cloud: + aws: + s3: + bucket: bucket + stack: + auto: false + credentials: + instanceProfile: true + +image: + common: + domain: https://domain.com + product-review: + directory: products/reviews/