diff --git a/build.gradle b/build.gradle index 9e195eb..855c650 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'com.fasterxml.jackson.module:jackson-module-kotlin' implementation 'org.jetbrains.kotlin:kotlin-reflect' + implementation("com.amazonaws:aws-java-sdk-s3:1.12.773") compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/kotlin/com/get_offer/common/AuditingTimeEntity.kt b/src/main/kotlin/com/get_offer/common/AuditingTimeEntity.kt index d348be2..22a0247 100644 --- a/src/main/kotlin/com/get_offer/common/AuditingTimeEntity.kt +++ b/src/main/kotlin/com/get_offer/common/AuditingTimeEntity.kt @@ -11,8 +11,8 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener @MappedSuperclass abstract class AuditingTimeEntity { @CreatedDate - private var createdAt: LocalDateTime = LocalDateTime.now() + val createdAt: LocalDateTime = LocalDateTime.now() @LastModifiedDate - private var updatedAt: LocalDateTime = LocalDateTime.now() + var updatedAt: LocalDateTime = LocalDateTime.now() } \ No newline at end of file diff --git a/src/main/kotlin/com/get_offer/common/exception/CustomExceptions.kt b/src/main/kotlin/com/get_offer/common/exception/CustomExceptions.kt index 60533e0..544428e 100644 --- a/src/main/kotlin/com/get_offer/common/exception/CustomExceptions.kt +++ b/src/main/kotlin/com/get_offer/common/exception/CustomExceptions.kt @@ -4,4 +4,6 @@ package com.get_offer.common.exception * custom exception 처리를 위해 남겨 놓았습니다. */ class NotFoundException(override val message: String) : RuntimeException(message) -class UnAuthorizationException : RuntimeException() \ No newline at end of file +class UnAuthorizationException : RuntimeException() + +class UnsupportedFileExtensionException : RuntimeException() \ No newline at end of file diff --git a/src/main/kotlin/com/get_offer/common/exception/ExceptionControllerAdvice.kt b/src/main/kotlin/com/get_offer/common/exception/ExceptionControllerAdvice.kt index ef1e911..70bb316 100644 --- a/src/main/kotlin/com/get_offer/common/exception/ExceptionControllerAdvice.kt +++ b/src/main/kotlin/com/get_offer/common/exception/ExceptionControllerAdvice.kt @@ -22,4 +22,9 @@ class ExceptionControllerAdvice { fun handleUnAuthorizationException(ex: UnAuthorizationException): ResponseEntity> { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.error("인가되지 않은 사용자입니다")) } + + @ExceptionHandler + fun handleUnsupportedFileExtensionException(ex: UnsupportedFileExtensionException): ResponseEntity> { + return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(ApiResponse.error("파일의 확장자가 유효하지 않습니다.")) + } } \ No newline at end of file diff --git a/src/main/kotlin/com/get_offer/common/s3/S3Config.kt b/src/main/kotlin/com/get_offer/common/s3/S3Config.kt new file mode 100644 index 0000000..b28d6b0 --- /dev/null +++ b/src/main/kotlin/com/get_offer/common/s3/S3Config.kt @@ -0,0 +1,28 @@ +package com.get_offer.common.s3 + +import com.amazonaws.auth.AWSStaticCredentialsProvider +import com.amazonaws.auth.BasicAWSCredentials +import com.amazonaws.regions.Regions +import com.amazonaws.services.s3.AmazonS3 +import com.amazonaws.services.s3.AmazonS3ClientBuilder +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class S3Config( + @Value("\${aws.credentials.accessKey}") + private val accessKey: String, + @Value("\${aws.credentials.secretKey}") + private val secretKey: String, +) { + @Bean + fun amazonS3Client(): AmazonS3 { + val credentials = BasicAWSCredentials(accessKey, secretKey) + return AmazonS3ClientBuilder + .standard() + .withCredentials(AWSStaticCredentialsProvider(credentials)) + .withRegion(Regions.AP_NORTHEAST_2) + .build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/get_offer/common/s3/S3FileManagement.kt b/src/main/kotlin/com/get_offer/common/s3/S3FileManagement.kt new file mode 100644 index 0000000..19f5a60 --- /dev/null +++ b/src/main/kotlin/com/get_offer/common/s3/S3FileManagement.kt @@ -0,0 +1,62 @@ +package com.get_offer.common.s3 + +import com.amazonaws.services.s3.AmazonS3 +import com.amazonaws.services.s3.model.ObjectMetadata +import com.get_offer.multipart.FileValidate +import java.util.* +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.web.multipart.MultipartFile + +@Component +class S3FileManagement( + @Value("\${aws.s3.bucket}") + private val bucket: String, + private val amazonS3: AmazonS3, +) { + companion object { + const val TYPE_IMAGE = "image" + } + + fun uploadImages(multipartFiles: List): List { + return multipartFiles.map { uploadImage(it) } + } + + fun uploadImage(multipartFile: MultipartFile): String { + val originalFilename = multipartFile.originalFilename + ?: throw IllegalStateException() + FileValidate.checkImageFormat(originalFilename) + val fileName = "${UUID.randomUUID()}-${originalFilename}" + val objectMetadata = setFileDateOption( + type = TYPE_IMAGE, + file = getFileExtension(originalFilename), + multipartFile = multipartFile + ) + amazonS3.putObject(bucket, fileName, multipartFile.inputStream, objectMetadata) + return fileName + } + + fun getFile(fileName: String): String { + return amazonS3.getUrl(bucket, fileName).toString() + } + + fun delete(fileName: String) { + amazonS3.deleteObject(bucket, fileName) + } + + private fun getFileExtension(fileName: String): String { + val extensionIndex = fileName.lastIndexOf('.') + return fileName.substring(extensionIndex + 1) + } + + private fun setFileDateOption( + type: String, + file: String, + multipartFile: MultipartFile + ): ObjectMetadata { + val objectMetadata = ObjectMetadata() + objectMetadata.contentType = "/${type}/${getFileExtension(file)}" + objectMetadata.contentLength = multipartFile.inputStream.available().toLong() + return objectMetadata + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/get_offer/multipart/FileValidate.kt b/src/main/kotlin/com/get_offer/multipart/FileValidate.kt new file mode 100644 index 0000000..27d1af0 --- /dev/null +++ b/src/main/kotlin/com/get_offer/multipart/FileValidate.kt @@ -0,0 +1,20 @@ +package com.get_offer.multipart + +import com.get_offer.common.exception.UnsupportedFileExtensionException + +class FileValidate { + companion object { + private val IMAGE_EXTENSIONS: List = listOf("jpg", "png") + + fun checkImageFormat(fileName: String) { + val extensionIndex = fileName.lastIndexOf('.') + if (extensionIndex == -1) { + throw UnsupportedFileExtensionException() + } + val extension = fileName.substring(extensionIndex + 1) + require(IMAGE_EXTENSIONS.contains(extension)) { + throw UnsupportedFileExtensionException() + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/get_offer/multipart/ImageService.kt b/src/main/kotlin/com/get_offer/multipart/ImageService.kt new file mode 100644 index 0000000..4b8da74 --- /dev/null +++ b/src/main/kotlin/com/get_offer/multipart/ImageService.kt @@ -0,0 +1,18 @@ +package com.get_offer.multipart + +import com.get_offer.common.s3.S3FileManagement +import org.springframework.stereotype.Service +import org.springframework.web.multipart.MultipartFile + +@Service +class ImageService( + private val s3FileManagement: S3FileManagement, +) { + fun saveImages(images: List): List { + return s3FileManagement.uploadImages(images) + } + + fun deleteImage(imageUrl: String) { + val file = s3FileManagement.delete(imageUrl) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/get_offer/product/controller/ProductController.kt b/src/main/kotlin/com/get_offer/product/controller/ProductController.kt index 3e1da6b..c44f8db 100644 --- a/src/main/kotlin/com/get_offer/product/controller/ProductController.kt +++ b/src/main/kotlin/com/get_offer/product/controller/ProductController.kt @@ -3,18 +3,22 @@ package com.get_offer.product.controller import ApiResponse import com.get_offer.product.service.ProductDetailDto import com.get_offer.product.service.ProductListDto +import com.get_offer.product.service.ProductSaveDto import com.get_offer.product.service.ProductService import org.springframework.data.domain.PageRequest import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestPart import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile @RestController @RequestMapping("/products") class ProductController( - private val productService: ProductService + private val productService: ProductService, ) { @GetMapping fun getProductList( @@ -33,4 +37,13 @@ class ProductController( fun getProductDetail(@PathVariable id: String, @RequestParam userId: String): ApiResponse { return ApiResponse.success(productService.getProductDetail(id.toLong(), userId.toLong())) } + + @PostMapping + fun postProduct( + @RequestParam userId: String, + @RequestPart("images") images: List, + @RequestPart productReqDto: ProductPostReqDto + ): ApiResponse { + return ApiResponse.success(productService.postProduct(productReqDto, userId.toLong(), images)) + } } \ No newline at end of file diff --git a/src/main/kotlin/com/get_offer/product/controller/ProductPostReqDto.kt b/src/main/kotlin/com/get_offer/product/controller/ProductPostReqDto.kt new file mode 100644 index 0000000..58f5f6e --- /dev/null +++ b/src/main/kotlin/com/get_offer/product/controller/ProductPostReqDto.kt @@ -0,0 +1,13 @@ +package com.get_offer.product.controller + +import com.get_offer.product.domain.Category +import java.time.LocalDateTime + +class ProductPostReqDto( + val title: String, + val category: Category, + val description: String, + val startPrice: Int, + val startDate: LocalDateTime, + val endDate: LocalDateTime, +) \ No newline at end of file diff --git a/src/main/kotlin/com/get_offer/product/domain/Product.kt b/src/main/kotlin/com/get_offer/product/domain/Product.kt index 11cf6fb..cb3093a 100644 --- a/src/main/kotlin/com/get_offer/product/domain/Product.kt +++ b/src/main/kotlin/com/get_offer/product/domain/Product.kt @@ -19,11 +19,9 @@ class Product( val title: String, - @Enumerated(EnumType.STRING) - val category: Category, + @Enumerated(EnumType.STRING) val category: Category, - @Convert(converter = ProductImagesConverter::class) - @Column(name = "IMAGES") + @Convert(converter = ProductImagesConverter::class) @Column(name = "IMAGES") val images: ProductImagesVo, val description: String, @@ -39,7 +37,6 @@ class Product( var endDate: LocalDateTime, - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - val id: Long = 0L, + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0L, ) : AuditingTimeEntity() \ No newline at end of file diff --git a/src/main/kotlin/com/get_offer/product/service/ProductSaveDto.kt b/src/main/kotlin/com/get_offer/product/service/ProductSaveDto.kt new file mode 100644 index 0000000..2a5d412 --- /dev/null +++ b/src/main/kotlin/com/get_offer/product/service/ProductSaveDto.kt @@ -0,0 +1,22 @@ +package com.get_offer.product.service + +import com.get_offer.product.domain.Product +import java.time.LocalDateTime + +data class ProductSaveDto( + val title: String, + val id: Long, + val writerId: Long, + val createdTime: LocalDateTime +) { + companion object { + fun of(product: Product): ProductSaveDto { + return ProductSaveDto( + title = product.title, + id = product.id, + writerId = product.writerId, + createdTime = product.createdAt + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/get_offer/product/service/ProductService.kt b/src/main/kotlin/com/get_offer/product/service/ProductService.kt index 11b3ff5..b1cc038 100644 --- a/src/main/kotlin/com/get_offer/product/service/ProductService.kt +++ b/src/main/kotlin/com/get_offer/product/service/ProductService.kt @@ -1,20 +1,29 @@ package com.get_offer.product.service import com.get_offer.common.exception.NotFoundException +import com.get_offer.multipart.ImageService +import com.get_offer.product.controller.ProductPostReqDto import com.get_offer.product.domain.Product +import com.get_offer.product.domain.ProductImagesVo import com.get_offer.product.domain.ProductStatus import com.get_offer.product.repository.ProductRepository import com.get_offer.user.repository.UserRepository +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit +import org.apache.coyote.BadRequestException import org.springframework.data.domain.Page import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile @Service +@Transactional(readOnly = true) class ProductService( + private val imageService: ImageService, private val productRepository: ProductRepository, private val userRepository: UserRepository, - - ) { +) { fun getProductList(userId: Long, pageRequest: PageRequest): Page { val productList: Page = productRepository.findAllByStatusInOrderByEndDateDesc( @@ -30,4 +39,47 @@ class ProductService( return ProductDetailDto.of(product, writer, userId) } + + @Transactional + fun postProduct(req: ProductPostReqDto, userId: Long, images: List): ProductSaveDto { + + validateStartPrice(req.startPrice) + validateDateRange(req.startDate, req.endDate) + + val imageUrls = imageService.saveImages(images) + + val product = productRepository.save( + Product( + title = req.title, + category = req.category, + writerId = userId, + images = ProductImagesVo(imageUrls), + description = req.description, + startPrice = req.startPrice, + currentPrice = req.startPrice, + startDate = req.startDate, + endDate = req.endDate, + status = checkStatus(req.startDate) + ) + ) + return ProductSaveDto.of(product) + } + + private fun validateStartPrice(startPrice: Int) { + if (startPrice < 0) { + throw BadRequestException("startPrice가 0보다 작을 수 없습니다.") + } + } + + private fun validateDateRange(startDate: LocalDateTime, endDate: LocalDateTime) { + if (startDate.isAfter(endDate)) throw BadRequestException("시작 날짜가 유효하지 않습니다.") + if (ChronoUnit.DAYS.between(startDate, endDate) > 7) throw BadRequestException("경매 기간은 7일을 넘길 수 없습니다.") + } + + private fun checkStatus(startDate: LocalDateTime): ProductStatus { + if (startDate.isAfter(LocalDateTime.now())) { + return ProductStatus.IN_PROGRESS + } + return ProductStatus.WAIT + } } \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7c5dfc7..89e2946 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -22,4 +22,10 @@ spring: hibernate: dialect: org.hibernate.dialect.H2Dialect format_sql: true - show_sql: true \ No newline at end of file + show_sql: true +aws: + s3: + bucket: get-offer-bucket + stack: + auto: false + credentials: \ No newline at end of file diff --git a/src/test/kotlin/com/get_offer/product/controller/ProductIntegrationTest.kt b/src/test/kotlin/com/get_offer/product/controller/ProductIntegrationTest.kt index bf40769..c4b714a 100644 --- a/src/test/kotlin/com/get_offer/product/controller/ProductIntegrationTest.kt +++ b/src/test/kotlin/com/get_offer/product/controller/ProductIntegrationTest.kt @@ -1,11 +1,16 @@ package com.get_offer.product.controller +import java.nio.file.Files +import java.nio.file.Paths import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile import org.springframework.test.context.jdbc.Sql import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultHandlers import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath @@ -21,15 +26,10 @@ class ProductIntegrationTest( @Test fun productListIntegrationTest() { mockMvc.perform( - get("/products") - .param("userId", "1") - .param("page", "0") - .param("size", "30") + get("/products").param("userId", "1").param("page", "0").param("size", "30") ).andDo(MockMvcResultHandlers.print()).andExpect(status().isOk) - .andExpect(jsonPath("$.data.pageNumber").value(0)) - .andExpect(jsonPath("$.data.pageSize").value(30)) - .andExpect(jsonPath("$.data.totalElements").value(2)) - .andExpect(jsonPath("$.data.totalPages").value(1)) + .andExpect(jsonPath("$.data.pageNumber").value(0)).andExpect(jsonPath("$.data.pageSize").value(30)) + .andExpect(jsonPath("$.data.totalElements").value(2)).andExpect(jsonPath("$.data.totalPages").value(1)) .andExpect(jsonPath("$.data.content[0].id").value("1")) .andExpect(jsonPath("$.data.content[0].writerId").value("1")) .andExpect(jsonPath("$.data.content[0].name").value("nintendo")) @@ -46,14 +46,11 @@ class ProductIntegrationTest( fun productDetailIntegrationTest() { mockMvc.perform( get("/products/1/detail").param("userId", "1") - ).andDo(MockMvcResultHandlers.print()).andExpect(status().isOk) - .andExpect(jsonPath("$.data.id").value("1")) - .andExpect(jsonPath("$.data.name").value("nintendo")) - .andExpect(jsonPath("$.data.writer.id").value("1")) + ).andDo(MockMvcResultHandlers.print()).andExpect(status().isOk).andExpect(jsonPath("$.data.id").value("1")) + .andExpect(jsonPath("$.data.name").value("nintendo")).andExpect(jsonPath("$.data.writer.id").value("1")) .andExpect(jsonPath("$.data.writer.nickname").value("test")) .andExpect(jsonPath("$.data.writer.profileImg").value("https://drive.google.com/file/d/1R9EIOoEWWgPUhY6e-t4VFuqMgknl7rm8/view?usp=sharing")) - .andExpect(jsonPath("$.data.name").value("nintendo")) - .andExpect(jsonPath("$.data.category").value("GAMES")) + .andExpect(jsonPath("$.data.name").value("nintendo")).andExpect(jsonPath("$.data.category").value("GAMES")) .andExpect(jsonPath("$.data.images.size()").value(2)) .andExpect(jsonPath("$.data.images[0]").value("https://picsum.photos/200/300")) .andExpect(jsonPath("$.data.description").value("닌텐도 새 제품")) @@ -64,4 +61,39 @@ class ProductIntegrationTest( .andExpect(jsonPath("$.data.endDate").value("2024-01-04T00:00:00")) .andExpect(jsonPath("$.data.isMine").value("true")) } + + @Test + fun postProductIntegrationTest() { + // 이미지 파일 로드 + val imagePath = Paths.get("src/test/resources/img.png") + val imageFile = MockMultipartFile( + "images", "img.png", MediaType.IMAGE_PNG_VALUE, Files.readAllBytes(imagePath) + ) + + // JSON 데이터 생성 + val productReqDto = """ + { + "title": "솔 타이틀", + "description": "설명", + "startPrice": 1000, + "startDate": "2024-10-19T15:00:00", + "endDate": "2024-10-23T15:00:00", + "category": "BOOKS" + } + """.trimIndent() + val productReqDtoFile = MockMultipartFile( + "productReqDto", "productReqDto", MediaType.APPLICATION_JSON_VALUE, productReqDto.toByteArray() + ) + + // MockMvc 요청 작성 및 실행 + mockMvc.perform( + MockMvcRequestBuilders.multipart("/products") + .file(imageFile) + .file(productReqDtoFile) + .param("userId", "1") + .contentType(MediaType.MULTIPART_FORM_DATA) + ).andExpect(status().isOk) + .andExpect(jsonPath("$.data.title").value("솔 타이틀")) + .andExpect(jsonPath("$.data.writerId").value(1)) + } } \ No newline at end of file diff --git a/src/test/kotlin/com/get_offer/product/service/ProductServiceTest.kt b/src/test/kotlin/com/get_offer/product/service/ProductServiceTest.kt index bc7f9e2..109e76c 100644 --- a/src/test/kotlin/com/get_offer/product/service/ProductServiceTest.kt +++ b/src/test/kotlin/com/get_offer/product/service/ProductServiceTest.kt @@ -1,32 +1,43 @@ package com.get_offer.product.service import com.get_offer.TestFixtures +import com.get_offer.multipart.ImageService +import com.get_offer.product.controller.ProductPostReqDto +import com.get_offer.product.domain.Category +import com.get_offer.product.domain.Product +import com.get_offer.product.domain.ProductImagesVo import com.get_offer.product.domain.ProductStatus import com.get_offer.product.repository.ProductRepository import com.get_offer.user.domain.User import com.get_offer.user.repository.UserRepository +import java.time.LocalDateTime import java.util.* +import org.apache.coyote.BadRequestException import org.assertj.core.api.Assertions import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.anyList import org.mockito.Mockito.any import org.mockito.Mockito.mock import org.mockito.Mockito.`when` import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageRequest +import org.springframework.mock.web.MockMultipartFile class ProductServiceTest { private lateinit var productService: ProductService private lateinit var mockProductRepository: ProductRepository private lateinit var mockUserRepository: UserRepository + private lateinit var mockImageService: ImageService @BeforeEach fun setUp() { mockProductRepository = mock(ProductRepository::class.java) mockUserRepository = mock(UserRepository::class.java) - productService = ProductService(mockProductRepository, mockUserRepository) + mockImageService = mock(ImageService::class.java) + productService = ProductService(mockImageService, mockProductRepository, mockUserRepository) } @Test @@ -41,8 +52,7 @@ class ProductServiceTest { `when`( mockProductRepository.findAllByStatusInOrderByEndDateDesc( - listOf(ProductStatus.IN_PROGRESS, ProductStatus.WAIT), - pageable + listOf(ProductStatus.IN_PROGRESS, ProductStatus.WAIT), pageable ) ).thenReturn( PageImpl(items, pageable, items.size.toLong()) @@ -80,4 +90,92 @@ class ProductServiceTest { Assertions.assertThat(result.images[0]).isEqualTo("https://image1.png") Assertions.assertThat(result.images[1]).isEqualTo("https://image2.png") } + + @Test + fun postProductWithValidData() { + // given + val userId = 1L + val productReqDto = ProductPostReqDto( + title = "Test Product", + description = "Test Description", + startPrice = 1000, + startDate = LocalDateTime.now().plusDays(1), + endDate = LocalDateTime.now().plusDays(3), + category = Category.BOOKS + ) + + val mockImage = MockMultipartFile("images", "test.jpg", "image/jpeg", byteArrayOf(1, 2, 3)) + + val imageUrls = listOf("http://image-url.com/test.jpg") + `when`(mockImageService.saveImages(anyList())).thenReturn(imageUrls) + + val product = Product( + title = productReqDto.title, + category = productReqDto.category, + writerId = userId, + images = ProductImagesVo(imageUrls), + description = productReqDto.description, + startPrice = productReqDto.startPrice, + currentPrice = productReqDto.startPrice, + startDate = productReqDto.startDate, + endDate = productReqDto.endDate, + status = ProductStatus.IN_PROGRESS + ) + + `when`(mockProductRepository.save(any(Product::class.java))).thenReturn(product) + + // when + val result = productService.postProduct(productReqDto, userId, listOf(mockImage)) + + // then + assertNotNull(result) + assertEquals(productReqDto.title, result.title) + } + + @Test + fun postProductWithInvalidStartPrice() { + // given + val userId = 1L + val productReqDto = ProductPostReqDto( + title = "Test Product", + description = "Test Description", + startPrice = -1000, // Invalid start price + startDate = LocalDateTime.now().plusDays(1), + endDate = LocalDateTime.now().plusDays(3), + category = Category.BOOKS + ) + + val mockImage = MockMultipartFile("images", "test.jpg", "image/jpeg", byteArrayOf(1, 2, 3)) + + // when & then + val exception = assertThrows(BadRequestException::class.java) { + productService.postProduct(productReqDto, userId, listOf(mockImage)) + } + + assertEquals("startPrice가 0보다 작을 수 없습니다.", exception.message) + } + + @Test + fun `test postProduct with invalid date range`() { + // given + val userId = 1L + val productReqDto = ProductPostReqDto( + title = "Test Product", + description = "Test Description", + startPrice = 1000, + startDate = LocalDateTime.now().plusDays(10), // Invalid start date (after end date) + endDate = LocalDateTime.now().plusDays(3), + category = Category.BOOKS + ) + + val mockImage = MockMultipartFile("images", "test.jpg", "image/jpeg", byteArrayOf(1, 2, 3)) + + // when & then + val exception = assertThrows(BadRequestException::class.java) { + productService.postProduct(productReqDto, userId, listOf(mockImage)) + } + + assertEquals("시작 날짜가 유효하지 않습니다.", exception.message) + } + } \ No newline at end of file diff --git a/src/test/resources/img.png b/src/test/resources/img.png new file mode 100644 index 0000000..0a1f258 Binary files /dev/null and b/src/test/resources/img.png differ diff --git a/src/test/resources/test_data.sql b/src/test/resources/test_data.sql index dfb52f9..86f9671 100644 --- a/src/test/resources/test_data.sql +++ b/src/test/resources/test_data.sql @@ -26,6 +26,7 @@ INSERT INTO PRODUCTS (ID, WRITER_ID, TITLE, DESCRIPTION, CATEGORY, CURRENT_PRICE VALUES (3, 1, 'ikea chair', '저쩌', 'FURNITURE', 43000, 8000, 'COMPLETED', '2024-01-06 00:00:00', '2024-01-04 00:00:00', '2024-01-02 00:00:00', '2024-01-02 00:00:00', '{"images":["https://picsum.photos/200/300","https://picsum.photos/200/300"]}'); +ALTER TABLE PRODUCTS ALTER COLUMN ID RESTART WITH 4; INSERT INTO AUCTION_RESULTS (ID, PRODUCT_ID, BUYER_ID, FINAL_PRICE, AUCTION_STATUS, CREATED_AT, UPDATED_AT)