diff --git a/build.gradle.kts b/build.gradle.kts index 8268c85..0b2e440 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,11 +34,19 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-oauth2-client") implementation("org.springframework.boot:spring-boot-starter-security") - // DB runtimeOnly("com.h2database:h2") runtimeOnly("com.mysql:mysql-connector-j") + // mockk + testImplementation("com.ninja-squad:springmockk:4.0.2") + testImplementation("io.mockk:mockk:1.13.8") + + // kotest + val kotestVersion = "5.5.4" + testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") + testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") + // Test testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") @@ -46,6 +54,7 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1") testImplementation("org.springframework.security:spring-security-test") + testImplementation("io.rest-assured:rest-assured:5.4.0") // Jasypt implementation("com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.5") @@ -67,6 +76,9 @@ dependencies { compileOnly("io.jsonwebtoken:jjwt-api:0.11.2") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.2") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.2") + + // Swagger + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13") } kotlin { @@ -95,16 +107,12 @@ sourceSets { } } -tasks.test { - useJUnitPlatform() -} - tasks.named("clean") { doLast { file(querydslDir).deleteRecursively() } } -tasks.withType { +tasks.withType().configureEach { useJUnitPlatform() } diff --git a/src/main/kotlin/com/study/core/DokiApplication.kt b/src/main/kotlin/com/study/core/DokiApplication.kt index 037b0be..21760fe 100644 --- a/src/main/kotlin/com/study/core/DokiApplication.kt +++ b/src/main/kotlin/com/study/core/DokiApplication.kt @@ -2,7 +2,6 @@ package com.study.core import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication -import org.springframework.core.env.get @SpringBootApplication class DokiApplication diff --git a/src/main/kotlin/com/study/core/global/config/AsyncConfig.kt b/src/main/kotlin/com/study/core/global/config/AsyncConfig.kt new file mode 100644 index 0000000..2117792 --- /dev/null +++ b/src/main/kotlin/com/study/core/global/config/AsyncConfig.kt @@ -0,0 +1,23 @@ +package com.study.core.global.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.EnableAsync +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor +import java.util.concurrent.Executor + +@Configuration +@EnableAsync +class AsyncConfig { + + @Bean(name = ["imageStorageExecutor"]) + fun imageStorageExecutor(): Executor { + val executor = ThreadPoolTaskExecutor() + executor.corePoolSize = 2 + executor.maxPoolSize = 4 + executor.queueCapacity = 50 + executor.setThreadNamePrefix("image-storage-") + executor.initialize() + return executor + } +} diff --git a/src/main/kotlin/com/study/core/global/config/SecurityConfig.kt b/src/main/kotlin/com/study/core/global/config/SecurityConfig.kt index 6273303..7406014 100644 --- a/src/main/kotlin/com/study/core/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/study/core/global/config/SecurityConfig.kt @@ -1,16 +1,16 @@ package com.study.core.global.config; import com.study.core.auth.oauth.CustomOAuth2UserService +import com.study.core.auth.oauth.OAuth2SuccessHandler import com.study.core.global.security.JwtFilter import com.study.core.global.security.JwtProvider -import com.study.core.auth.oauth.OAuth2SuccessHandler import org.springframework.boot.autoconfigure.security.servlet.PathRequest import org.springframework.context.annotation.Bean -import org.springframework.security.config.http.SessionCreationPolicy -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.context.annotation.Configuration import org.springframework.core.annotation.Order +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter @@ -47,6 +47,7 @@ class SecurityConfig( } .authorizeHttpRequests { it.requestMatchers("/health/check").permitAll() + it.requestMatchers(*SWAGGER_WHITE_LIST).permitAll() // **/swagger-ui/index.html it.requestMatchers( "/auth/login", // 로그인 시작점 "/auth/reissue", @@ -54,10 +55,10 @@ class SecurityConfig( "/oauth2/**", "/auth/callback" // 콜백 ).permitAll() - .anyRequest().authenticated() + .anyRequest().authenticated() } .oauth2Login { oauth -> - oauth.userInfoEndpoint{ endpoint -> + oauth.userInfoEndpoint { endpoint -> endpoint.userService(customOAuth2UserService) } oauth.successHandler(oAuth2SuccessHandler) @@ -69,4 +70,13 @@ class SecurityConfig( return http.build() } + + companion object { + private val SWAGGER_WHITE_LIST = arrayOf( + "/swagger-ui/**", + "/v3/api-docs/**", + "/v3/api-docs.yaml", + "/swagger-ui.html" + ) + } } diff --git a/src/main/kotlin/com/study/core/image/application/ImageEventListener.kt b/src/main/kotlin/com/study/core/image/application/ImageEventListener.kt new file mode 100644 index 0000000..c11e0ec --- /dev/null +++ b/src/main/kotlin/com/study/core/image/application/ImageEventListener.kt @@ -0,0 +1,42 @@ +package com.study.core.image.application + +import com.study.core.image.application.event.ImageCreateEvent +import com.study.core.image.application.event.ImageDeleteEvent +import com.study.core.image.domain.domainservice.ImageStorage +import com.study.core.image.domain.Image +import com.study.core.image.domain.ImageRepository +import org.springframework.context.event.EventListener +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component + +@Component +class ImageEventListener( + private val imageRepository: ImageRepository, + private val imageStorage: ImageStorage +) { + + @Async("imageStorageExecutor") + @EventListener + fun handleImageCreate(event: ImageCreateEvent) { + require(event.content.isNotEmpty()) { "content must not be empty." } + + val image = Image.createAndStore( + aggregateType = event.aggregateType, + aggregateId = event.aggregateId, + originalFilename = event.originalFilename, + content = event.content, + sortOrder = event.sortOrder, + imageStorage = imageStorage, + ) + imageRepository.save(image) + } + + @Async("imageStorageExecutor") + @EventListener + fun handleImageDelete(event: ImageDeleteEvent) { + imageRepository.deleteAllByAggregate( + aggregateType = event.aggregateType, + aggregateId = event.aggregateId, + ) + } +} diff --git a/src/main/kotlin/com/study/core/image/application/ImageEventPublisher.kt b/src/main/kotlin/com/study/core/image/application/ImageEventPublisher.kt new file mode 100644 index 0000000..7e7ba1a --- /dev/null +++ b/src/main/kotlin/com/study/core/image/application/ImageEventPublisher.kt @@ -0,0 +1,51 @@ +package com.study.core.image.application + +import com.study.core.image.application.event.ImageCreateEvent +import com.study.core.image.application.event.ImageDeleteEvent +import com.study.core.image.domain.vo.ImageAggregateType +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component + +@Component +class ImageEventPublisher( + private val applicationEventPublisher: ApplicationEventPublisher +) { + + fun publishCreate( + aggregateType: ImageAggregateType, + aggregateId: Long, + originalFilename: String, + content: ByteArray, + sortOrder: Int = 0 + ) { + publishCreate( + ImageCreateEvent( + aggregateType = aggregateType, + aggregateId = aggregateId, + originalFilename = originalFilename, + content = content, + sortOrder = sortOrder + ) + ) + } + + private fun publishCreate(event: ImageCreateEvent) { + applicationEventPublisher.publishEvent(event) + } + + fun publishDelete( + aggregateType: ImageAggregateType, + aggregateId: Long, + ) { + publishDelete( + ImageDeleteEvent( + aggregateType = aggregateType, + aggregateId = aggregateId, + ) + ) + } + + private fun publishDelete(event: ImageDeleteEvent) { + applicationEventPublisher.publishEvent(event) + } +} diff --git a/src/main/kotlin/com/study/core/image/application/event/ImageCreateEvent.kt b/src/main/kotlin/com/study/core/image/application/event/ImageCreateEvent.kt new file mode 100644 index 0000000..ae6b5f6 --- /dev/null +++ b/src/main/kotlin/com/study/core/image/application/event/ImageCreateEvent.kt @@ -0,0 +1,11 @@ +package com.study.core.image.application.event + +import com.study.core.image.domain.vo.ImageAggregateType + +data class ImageCreateEvent( + val aggregateType: ImageAggregateType, + val aggregateId: Long, + val originalFilename: String, + val content: ByteArray, + val sortOrder: Int = 0, +) diff --git a/src/main/kotlin/com/study/core/image/application/event/ImageDeleteEvent.kt b/src/main/kotlin/com/study/core/image/application/event/ImageDeleteEvent.kt new file mode 100644 index 0000000..e3da6a6 --- /dev/null +++ b/src/main/kotlin/com/study/core/image/application/event/ImageDeleteEvent.kt @@ -0,0 +1,8 @@ +package com.study.core.image.application.event + +import com.study.core.image.domain.vo.ImageAggregateType + +data class ImageDeleteEvent( + val aggregateType: ImageAggregateType, + val aggregateId: Long, +) diff --git a/src/main/kotlin/com/study/core/image/domain/Image.kt b/src/main/kotlin/com/study/core/image/domain/Image.kt new file mode 100644 index 0000000..6b9060c --- /dev/null +++ b/src/main/kotlin/com/study/core/image/domain/Image.kt @@ -0,0 +1,110 @@ +package com.study.core.image.domain + +import com.study.core.image.domain.domainservice.ImageStorage +import com.study.core.global.BaseEntity +import com.study.core.image.domain.vo.ImageAggregateType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.util.UUID + +@Entity +@Table(name = "images") +class Image( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L, + + @Enumerated(EnumType.STRING) + @Column(name = "aggregate_type", nullable = false, length = 30) + val aggregateType: ImageAggregateType, + + @Column(name = "aggregate_id", nullable = false) + val aggregateId: Long, + + @Column(name = "original_filename", nullable = false, length = 255) + val originalFilename: String, + + @Column(name = "unique_filename", nullable = false, length = 255) + val uniqueFilename: String, + + @Column(name = "extension", nullable = false, length = 20) + val extension: String, + + @Column(name = "file_size", nullable = false) + val fileSize: Long, + + @Column(name = "url", nullable = false, length = 500) + val url: String, + + @Column(name = "sort_order", nullable = false) + var sortOrder: Int = 0, +) : BaseEntity() { + + fun changeSortOrder(targetSortOrder: Int): Image { + require(targetSortOrder >= 0) { "sortOrder must be zero or positive." } + sortOrder = targetSortOrder + return this + } + + companion object { + fun createAndStore( + aggregateType: ImageAggregateType, + aggregateId: Long, + originalFilename: String, + content: ByteArray, + sortOrder: Int = 0, + imageStorage: ImageStorage, + ): Image { + require(aggregateId > 0) { "aggregateId must be positive." } + require(content.isNotEmpty()) { "content must not be empty." } + require(sortOrder >= 0) { "sortOrder must be zero or positive." } + + val normalizedOriginal = originalFilename.trim() + require(normalizedOriginal.isNotEmpty()) { "originalFilename must not be blank." } + + val generatedFilename = generateUniqueFilename(normalizedOriginal) + val storedImage = imageStorage.store(generatedFilename.uniqueFilename, content) + + require(storedImage.url.isNotBlank()) { "url must not be blank." } + require(storedImage.fileSize > 0) { "fileSize must be greater than zero." } + + return Image( + aggregateType = aggregateType, + aggregateId = aggregateId, + originalFilename = normalizedOriginal, + uniqueFilename = generatedFilename.uniqueFilename, + extension = generatedFilename.extension, + fileSize = storedImage.fileSize, + url = storedImage.url, + sortOrder = sortOrder, + ) + } + + fun generateUniqueFilename(originalFilename: String): GeneratedFilename { + val normalizedOriginal = originalFilename.trim() + require(normalizedOriginal.isNotEmpty()) { "originalFilename must not be blank." } + val extension = extractExtension(normalizedOriginal) + return GeneratedFilename( + uniqueFilename = "${UUID.randomUUID()}.$extension", + extension = extension + ) + } + + private fun extractExtension(filename: String): String { + val extension = filename.substringAfterLast('.', "").lowercase() + require(extension.isNotEmpty()) { "filename must contain an extension." } + return extension + } + } +} + +data class GeneratedFilename( + val uniqueFilename: String, + val extension: String +) diff --git a/src/main/kotlin/com/study/core/image/domain/ImageRepository.kt b/src/main/kotlin/com/study/core/image/domain/ImageRepository.kt new file mode 100644 index 0000000..2fc747c --- /dev/null +++ b/src/main/kotlin/com/study/core/image/domain/ImageRepository.kt @@ -0,0 +1,16 @@ +package com.study.core.image.domain + +import com.study.core.image.domain.vo.ImageAggregateType + +interface ImageRepository { + + fun save(image: Image): Image + + fun saveAll(images: Collection): List + + fun findByAggregate(aggregateType: ImageAggregateType, aggregateId: Long): List + + fun findByAggregates(aggregateType: ImageAggregateType, aggregateIds: Collection): List + + fun deleteAllByAggregate(aggregateType: ImageAggregateType, aggregateId: Long) +} diff --git a/src/main/kotlin/com/study/core/image/domain/domainservice/ImageStorage.kt b/src/main/kotlin/com/study/core/image/domain/domainservice/ImageStorage.kt new file mode 100644 index 0000000..5e17309 --- /dev/null +++ b/src/main/kotlin/com/study/core/image/domain/domainservice/ImageStorage.kt @@ -0,0 +1,10 @@ +package com.study.core.image.domain.domainservice + +interface ImageStorage { + fun store(uniqueFilename: String, content: ByteArray): StoredImage +} + +data class StoredImage( + val url: String, + val fileSize: Long +) diff --git a/src/main/kotlin/com/study/core/image/domain/vo/ImageAggregateType.kt b/src/main/kotlin/com/study/core/image/domain/vo/ImageAggregateType.kt new file mode 100644 index 0000000..4dfdf77 --- /dev/null +++ b/src/main/kotlin/com/study/core/image/domain/vo/ImageAggregateType.kt @@ -0,0 +1,8 @@ +package com.study.core.image.domain.vo + +enum class ImageAggregateType { + PACKAGE, + PRODUCT, + USER, + PACKAGE_REVIEW +} diff --git a/src/main/kotlin/com/study/core/image/infrastructure/persistence/ImageJpaRepository.kt b/src/main/kotlin/com/study/core/image/infrastructure/persistence/ImageJpaRepository.kt new file mode 100644 index 0000000..12f5732 --- /dev/null +++ b/src/main/kotlin/com/study/core/image/infrastructure/persistence/ImageJpaRepository.kt @@ -0,0 +1,20 @@ +package com.study.core.image.infrastructure.persistence + +import com.study.core.image.domain.Image +import com.study.core.image.domain.vo.ImageAggregateType +import org.springframework.data.jpa.repository.JpaRepository + +interface ImageJpaRepository : JpaRepository { + + fun findByAggregateTypeAndAggregateIdOrderBySortOrderAsc( + aggregateType: ImageAggregateType, + aggregateId: Long + ): List + + fun findByAggregateTypeAndAggregateIdInOrderBySortOrderAsc( + aggregateType: ImageAggregateType, + aggregateIds: Collection + ): List + + fun deleteByAggregateTypeAndAggregateId(aggregateType: ImageAggregateType, aggregateId: Long) +} diff --git a/src/main/kotlin/com/study/core/image/infrastructure/persistence/ImageRepositoryImpl.kt b/src/main/kotlin/com/study/core/image/infrastructure/persistence/ImageRepositoryImpl.kt new file mode 100644 index 0000000..1791b47 --- /dev/null +++ b/src/main/kotlin/com/study/core/image/infrastructure/persistence/ImageRepositoryImpl.kt @@ -0,0 +1,29 @@ +package com.study.core.image.infrastructure.persistence + +import com.study.core.image.domain.Image +import com.study.core.image.domain.vo.ImageAggregateType +import com.study.core.image.domain.ImageRepository +import org.springframework.stereotype.Repository + +@Repository +class ImageRepositoryImpl( + private val imageJpaRepository: ImageJpaRepository +) : ImageRepository { + + override fun save(image: Image): Image = + imageJpaRepository.save(image) + + override fun saveAll(images: Collection): List = + imageJpaRepository.saveAll(images) + + override fun findByAggregate(aggregateType: ImageAggregateType, aggregateId: Long): List = + imageJpaRepository.findByAggregateTypeAndAggregateIdOrderBySortOrderAsc(aggregateType, aggregateId) + + override fun findByAggregates(aggregateType: ImageAggregateType, aggregateIds: Collection): List = + if (aggregateIds.isEmpty()) emptyList() + else imageJpaRepository.findByAggregateTypeAndAggregateIdInOrderBySortOrderAsc(aggregateType, aggregateIds) + + override fun deleteAllByAggregate(aggregateType: ImageAggregateType, aggregateId: Long) { + imageJpaRepository.deleteByAggregateTypeAndAggregateId(aggregateType, aggregateId) + } +} diff --git a/src/main/kotlin/com/study/core/image/infrastructure/storage/LocalImageStorage.kt b/src/main/kotlin/com/study/core/image/infrastructure/storage/LocalImageStorage.kt new file mode 100644 index 0000000..c2262b5 --- /dev/null +++ b/src/main/kotlin/com/study/core/image/infrastructure/storage/LocalImageStorage.kt @@ -0,0 +1,45 @@ +package com.study.core.image.infrastructure.storage + +import com.study.core.image.domain.domainservice.ImageStorage +import com.study.core.image.domain.domainservice.StoredImage +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Primary +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Component +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardOpenOption + +@Primary +@Profile("local", "test") +@Component +class LocalImageStorage( + @Value("\${app.storage.local.base-path:}") basePath: String? = null +) : ImageStorage { + + // 자신 로컬 환경에 맞게 이미지 저장 경로 설정 + private val rootPath: Path = basePath + ?.takeIf { it.isNotBlank() } + ?.let { Paths.get(it) } + ?: Paths.get(System.getProperty("user.home"), "Desktop", "image") + + override fun store(uniqueFilename: String, content: ByteArray): StoredImage { + require(uniqueFilename.isNotBlank()) { "uniqueFilename must not be blank." } + require(content.isNotEmpty()) { "content must not be empty." } + + Files.createDirectories(rootPath) + val targetPath = rootPath.resolve(uniqueFilename) + Files.write( + targetPath, + content, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE + ) + return StoredImage( + url = targetPath.toUri().toString(), + fileSize = content.size.toLong() + ) + } +} diff --git a/src/main/kotlin/com/study/core/image/infrastructure/storage/MockCloudImageStorage.kt b/src/main/kotlin/com/study/core/image/infrastructure/storage/MockCloudImageStorage.kt new file mode 100644 index 0000000..687202b --- /dev/null +++ b/src/main/kotlin/com/study/core/image/infrastructure/storage/MockCloudImageStorage.kt @@ -0,0 +1,27 @@ +package com.study.core.image.infrastructure.storage + +import com.study.core.image.domain.domainservice.ImageStorage +import com.study.core.image.domain.domainservice.StoredImage +import org.springframework.context.annotation.Primary +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Component + +@Primary +@Profile("dev", "prod") +@Component +class MockCloudImageStorage : ImageStorage { + + /** + * TODO : 추후 클라우드 결정 후 개발 예정 + */ + + override fun store(uniqueFilename: String, content: ByteArray): StoredImage { + require(uniqueFilename.isNotBlank()) { "uniqueFilename must not be blank." } + require(content.isNotEmpty()) { "content must not be empty." } + + return StoredImage( + url = "https://mock-cloud.example/images/$uniqueFilename", + fileSize = content.size.toLong() + ) + } +} diff --git a/src/main/kotlin/com/study/core/productpackage/domain/PackageRepository.kt b/src/main/kotlin/com/study/core/productpackage/domain/PackageRepository.kt index 30fde80..8d516ee 100644 --- a/src/main/kotlin/com/study/core/productpackage/domain/PackageRepository.kt +++ b/src/main/kotlin/com/study/core/productpackage/domain/PackageRepository.kt @@ -8,4 +8,10 @@ interface PackageRepository { fun findPackagesByPaging(): List fun findPackageSpecificById(packageId: Long): PackageSpecificResponse? + + fun findById(packageId: Long): Package? + + fun existsById(packageId: Long): Boolean + + fun getReferenceById(packageId: Long): Package } diff --git a/src/main/kotlin/com/study/core/productpackage/infrastructure/PackageCommandRepository.kt b/src/main/kotlin/com/study/core/productpackage/infrastructure/PackageCommandRepository.kt index c132395..575b5e8 100644 --- a/src/main/kotlin/com/study/core/productpackage/infrastructure/PackageCommandRepository.kt +++ b/src/main/kotlin/com/study/core/productpackage/infrastructure/PackageCommandRepository.kt @@ -1,6 +1,7 @@ package com.study.core.productpackage.infrastructure import com.study.core.productpackage.domain.Package +import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Repository @Repository @@ -8,5 +9,11 @@ class PackageCommandRepository( private val packageJpaRepository: PackageJpaRepository ) { + fun findById(packageId: Long): Package? = packageJpaRepository.findByIdOrNull(packageId) + fun save(pkg: Package): Package = packageJpaRepository.save(pkg) + + fun existsById(packageId: Long): Boolean = packageJpaRepository.existsById(packageId) + + fun getReferenceById(packageId: Long): Package = packageJpaRepository.getReferenceById(packageId) } diff --git a/src/main/kotlin/com/study/core/productpackage/infrastructure/PackageJpaRepository.kt b/src/main/kotlin/com/study/core/productpackage/infrastructure/PackageJpaRepository.kt index c427d9a..8d68bd4 100644 --- a/src/main/kotlin/com/study/core/productpackage/infrastructure/PackageJpaRepository.kt +++ b/src/main/kotlin/com/study/core/productpackage/infrastructure/PackageJpaRepository.kt @@ -1,7 +1,6 @@ package com.study.core.productpackage.infrastructure import com.study.core.productpackage.domain.Package -import org.springframework.data.repository.CrudRepository +import org.springframework.data.jpa.repository.JpaRepository -interface PackageJpaRepository : CrudRepository { -} +interface PackageJpaRepository : JpaRepository diff --git a/src/main/kotlin/com/study/core/productpackage/infrastructure/PackageRepositoryImpl.kt b/src/main/kotlin/com/study/core/productpackage/infrastructure/PackageRepositoryImpl.kt index 3f2b90b..b0a6ca1 100644 --- a/src/main/kotlin/com/study/core/productpackage/infrastructure/PackageRepositoryImpl.kt +++ b/src/main/kotlin/com/study/core/productpackage/infrastructure/PackageRepositoryImpl.kt @@ -1,5 +1,6 @@ package com.study.core.productpackage.infrastructure +import com.study.core.productpackage.domain.Package import com.study.core.productpackage.domain.PackageRepository import com.study.core.productpackage.domain.dto.PackagePagingQueryResponse import com.study.core.productpackage.domain.dto.PackageSpecificResponse @@ -16,4 +17,14 @@ class PackageRepositoryImpl( override fun findPackageSpecificById(packageId: Long): PackageSpecificResponse? = packageQueryRepository.findPackageSpecificById(packageId) + + override fun findById(packageId: Long): Package? { + return packageCommandRepository.findById(packageId) + } + + override fun existsById(packageId: Long): Boolean = + packageCommandRepository.existsById(packageId) + + override fun getReferenceById(packageId: Long): Package = + packageCommandRepository.getReferenceById(packageId) } diff --git a/src/main/kotlin/com/study/core/review/application/PackageReviewCommandService.kt b/src/main/kotlin/com/study/core/review/application/PackageReviewCommandService.kt new file mode 100644 index 0000000..8290cee --- /dev/null +++ b/src/main/kotlin/com/study/core/review/application/PackageReviewCommandService.kt @@ -0,0 +1,101 @@ +package com.study.core.review.application + +import com.study.core.image.application.ImageEventPublisher +import com.study.core.image.domain.vo.ImageAggregateType +import com.study.core.productpackage.domain.Package +import com.study.core.productpackage.domain.PackageRepository +import com.study.core.review.application.dto.CreatePackageReviewCommand +import com.study.core.review.application.dto.ReviewImageFile +import com.study.core.review.application.dto.UpdatePackageReviewCommand +import com.study.core.review.domain.PackageReview +import com.study.core.review.domain.PackageReviewRepository +import com.study.core.review.domain.ReviewRating +import jakarta.persistence.EntityNotFoundException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Transactional +@Service +class PackageReviewCommandService( + private val packageRepository: PackageRepository, + private val packageReviewRepository: PackageReviewRepository, + private val imageEventPublisher: ImageEventPublisher, +) { + + fun createReview( + command: CreatePackageReviewCommand, + images: List = emptyList() + ): PackageReview { + val pkg = loadPackage(command.packageId) + ensureNotReviewed(pkg.id, command.userId) + + val review = PackageReview( + pkg = pkg, + userId = command.userId, + rating = ReviewRating.of(command.rating), + content = command.content + ) + + val packageReview = packageReviewRepository.save(review) + storeImages(packageReview.id, images) + return packageReview + } + + private fun loadPackage(packageId: Long): Package { + return packageRepository.findById(packageId) + ?: throw EntityNotFoundException("Package $packageId does not exist.") + } + + private fun ensureNotReviewed(packageId: Long, userId: Long) { + val existing = packageReviewRepository.findByPackageIdAndUserId(packageId, userId) + require(existing == null) { "You already wrote a review for this package." } + } + + fun updateReview( + command: UpdatePackageReviewCommand, + images: List? = null + ): PackageReview { + val review = findPackageByReviewId(command.reviewId) + review.validateOwner(userId = command.userId, pkgId = command.packageId) + review.changeReview( + rating = ReviewRating.of(command.rating), + content = command.content + ) + + images?.let { + deleteImages(review.id) + storeImages(review.id, it) + } + return review + } + + private fun findPackageByReviewId(reviewId: Long) = + packageReviewRepository.findById(reviewId) + ?: throw IllegalArgumentException("Review $reviewId not found.") + + fun deleteReview(packageId: Long, reviewId: Long, userId: Long) { + val review = findPackageByReviewId(reviewId) + review.validateOwner(userId = userId, pkgId = packageId) + deleteImages(reviewId) + packageReviewRepository.delete(review) + } + + private fun storeImages(reviewId: Long, images: List) { + images.forEach { image -> + imageEventPublisher.publishCreate( + aggregateType = ImageAggregateType.PACKAGE_REVIEW, + aggregateId = reviewId, + originalFilename = image.originalFilename, + content = image.content, + sortOrder = image.sortOrder + ) + } + } + + private fun deleteImages(reviewId: Long) { + imageEventPublisher.publishDelete( + aggregateType = ImageAggregateType.PACKAGE_REVIEW, + aggregateId = reviewId, + ) + } +} diff --git a/src/main/kotlin/com/study/core/review/application/PackageReviewQueryService.kt b/src/main/kotlin/com/study/core/review/application/PackageReviewQueryService.kt new file mode 100644 index 0000000..f1f0d91 --- /dev/null +++ b/src/main/kotlin/com/study/core/review/application/PackageReviewQueryService.kt @@ -0,0 +1,34 @@ +package com.study.core.review.application + +import com.study.core.image.domain.ImageRepository +import com.study.core.image.domain.vo.ImageAggregateType +import com.study.core.review.application.dto.PackageReviewResponse +import com.study.core.review.domain.PackageReview +import com.study.core.review.domain.PackageReviewRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Transactional(readOnly = true) +@Service +class PackageReviewQueryService( + private val packageReviewRepository: PackageReviewRepository, + private val imageRepository: ImageRepository +) { + + fun findReviewsByPackage(packageId: Long): List { + val reviews = packageReviewRepository.findByPackageId(packageId) + .sortedByDescending { it.createdAt } + val reviewIds = reviews.map { it.id } + val images = imageRepository.findByAggregates(ImageAggregateType.PACKAGE_REVIEW, reviewIds) + val imageMap = images.groupBy { it.aggregateId } + + return reviews.map { review -> + PackageReviewResponse.from(review, imageMap[review.id].orEmpty()) + } + } + + fun toResponse(review: PackageReview): PackageReviewResponse { + val images = imageRepository.findByAggregate(ImageAggregateType.PACKAGE_REVIEW, review.id) + return PackageReviewResponse.from(review, images) + } +} diff --git a/src/main/kotlin/com/study/core/review/application/dto/CreatePackageReviewCommand.kt b/src/main/kotlin/com/study/core/review/application/dto/CreatePackageReviewCommand.kt new file mode 100644 index 0000000..ee05bd0 --- /dev/null +++ b/src/main/kotlin/com/study/core/review/application/dto/CreatePackageReviewCommand.kt @@ -0,0 +1,8 @@ +package com.study.core.review.application.dto + +data class CreatePackageReviewCommand( + val packageId: Long, + val userId: Long, + val rating: Int, + val content: String +) diff --git a/src/main/kotlin/com/study/core/review/application/dto/PackageReviewImageResponse.kt b/src/main/kotlin/com/study/core/review/application/dto/PackageReviewImageResponse.kt new file mode 100644 index 0000000..0846825 --- /dev/null +++ b/src/main/kotlin/com/study/core/review/application/dto/PackageReviewImageResponse.kt @@ -0,0 +1,26 @@ +package com.study.core.review.application.dto + +import com.study.core.image.domain.Image +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "패키지 리뷰 이미지 응답 DTO") +data class PackageReviewImageResponse( + @Schema(description = "이미지 ID", example = "100") + val id: Long, + @Schema(description = "이미지 원본 파일명", example = "review.png") + val originalFilename: String, + @Schema(description = "이미지 URL", example = "https://cdn.example.com/reviews/uuid.png") + val url: String, + @Schema(description = "정렬 순서", example = "0") + val sortOrder: Int +) { + companion object { + fun from(image: Image): PackageReviewImageResponse = + PackageReviewImageResponse( + id = image.id, + originalFilename = image.originalFilename, + url = image.url, + sortOrder = image.sortOrder + ) + } +} diff --git a/src/main/kotlin/com/study/core/review/application/dto/PackageReviewResponse.kt b/src/main/kotlin/com/study/core/review/application/dto/PackageReviewResponse.kt new file mode 100644 index 0000000..a3e333c --- /dev/null +++ b/src/main/kotlin/com/study/core/review/application/dto/PackageReviewResponse.kt @@ -0,0 +1,39 @@ +package com.study.core.review.application.dto + +import com.study.core.review.domain.PackageReview +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +@Schema(description = "패키지 리뷰 응답 DTO") +data class PackageReviewResponse( + @Schema(description = "리뷰 ID", example = "21") + val reviewId: Long, + @Schema(description = "패키지 ID", example = "3") + val packageId: Long, + @Schema(description = "리뷰 작성자 ID", example = "7") + val userId: Long, + @Schema(description = "패키지 평점(1~5)", example = "4") + val rating: Int, + @Schema(description = "리뷰 내용", example = "What a nice package!") + val content: String, + @Schema(description = "리뷰 작성 일시", example = "2025-12-07T10:15:30") + val createdAt: LocalDateTime, + @Schema(description = "리뷰 이미지 목록") + val images: List = emptyList() +) { + + companion object { + fun from(packageReview: PackageReview, images: List = emptyList()): PackageReviewResponse { + val createdAt = packageReview.createdAt ?: LocalDateTime.now() + return PackageReviewResponse( + reviewId = packageReview.id, + packageId = requireNotNull(packageReview.pkg.id) { "packageId is required." }, + userId = packageReview.userId, + rating = packageReview.rating.value, + content = packageReview.content, + createdAt = createdAt, + images = images.map { PackageReviewImageResponse.from(it) } + ) + } + } +} diff --git a/src/main/kotlin/com/study/core/review/application/dto/ReviewImageFile.kt b/src/main/kotlin/com/study/core/review/application/dto/ReviewImageFile.kt new file mode 100644 index 0000000..e60bef5 --- /dev/null +++ b/src/main/kotlin/com/study/core/review/application/dto/ReviewImageFile.kt @@ -0,0 +1,7 @@ +package com.study.core.review.application.dto + +data class ReviewImageFile( + val originalFilename: String, + val content: ByteArray, + val sortOrder: Int +) diff --git a/src/main/kotlin/com/study/core/review/application/dto/UpdatePackageReviewCommand.kt b/src/main/kotlin/com/study/core/review/application/dto/UpdatePackageReviewCommand.kt new file mode 100644 index 0000000..bf4400e --- /dev/null +++ b/src/main/kotlin/com/study/core/review/application/dto/UpdatePackageReviewCommand.kt @@ -0,0 +1,9 @@ +package com.study.core.review.application.dto + +data class UpdatePackageReviewCommand( + val packageId: Long, + val reviewId: Long, + val userId: Long, + val rating: Int, + val content: String +) diff --git a/src/main/kotlin/com/study/core/review/domain/PackageReview.kt b/src/main/kotlin/com/study/core/review/domain/PackageReview.kt new file mode 100644 index 0000000..1931d5b --- /dev/null +++ b/src/main/kotlin/com/study/core/review/domain/PackageReview.kt @@ -0,0 +1,78 @@ +package com.study.core.review.domain + +import com.study.core.global.BaseEntity +import com.study.core.productpackage.domain.Package +import jakarta.persistence.Column +import jakarta.persistence.Embedded +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +@Entity +@Table( + name = "package_reviews", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_package_review_user", + columnNames = ["package_id", "user_id"] + ) + ] +) // 유저 당 패키지에 한 번의 리뷰 작성하도록 유니크 제약 설정 (추후 기획에 따라 수정) +class PackageReview( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "package_id", nullable = false) + val pkg: Package, + + @Column(name = "user_id", nullable = false) + val userId: Long, + + @Embedded + var rating: ReviewRating, + + @Column(nullable = false, length = 2000) + var content: String, +) : BaseEntity() { + + fun validateOwner(userId: Long, pkgId: Long) { + require(this.userId == userId) { "You can handle only your own review." } + require(this.pkg.id == pkgId) { "Review does not belong to package ${pkgId}." } + } + + fun changeReview(rating: ReviewRating, content: String): PackageReview { + validateContent(content) + this.rating = rating + this.content = content + return this + } + + companion object { + fun of( + pkg: Package, + userId: Long, + ratingScore: + Int, content: String + ): PackageReview { + validateContent(content) + return PackageReview( + pkg = pkg, + userId = userId, + rating = ReviewRating.of(ratingScore), + content = content + ) + } + + private fun validateContent(content: String) { + require(content.isNotBlank()) { "Review content must not be blank." } + } + } +} diff --git a/src/main/kotlin/com/study/core/review/domain/PackageReviewRepository.kt b/src/main/kotlin/com/study/core/review/domain/PackageReviewRepository.kt new file mode 100644 index 0000000..638e383 --- /dev/null +++ b/src/main/kotlin/com/study/core/review/domain/PackageReviewRepository.kt @@ -0,0 +1,14 @@ +package com.study.core.review.domain + +interface PackageReviewRepository { + + fun save(review: PackageReview): PackageReview + + fun findById(reviewId: Long): PackageReview? + + fun findByPackageId(packageId: Long): List + + fun findByPackageIdAndUserId(packageId: Long, userId: Long): PackageReview? + + fun delete(review: PackageReview) +} diff --git a/src/main/kotlin/com/study/core/review/domain/ReviewRating.kt b/src/main/kotlin/com/study/core/review/domain/ReviewRating.kt new file mode 100644 index 0000000..ff8fce6 --- /dev/null +++ b/src/main/kotlin/com/study/core/review/domain/ReviewRating.kt @@ -0,0 +1,21 @@ +package com.study.core.review.domain + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable + +@Embeddable +data class ReviewRating( + @Column(name = "rating", nullable = false) + val value: Int +) { + + companion object { + private const val MIN = 1 + private const val MAX = 5 + + fun of(value: Int): ReviewRating { + require(value in MIN..MAX) { "Rating must be between $MIN and $MAX." } + return ReviewRating(value) + } + } +} diff --git a/src/main/kotlin/com/study/core/review/infrastructure/PackageReviewJpaRepository.kt b/src/main/kotlin/com/study/core/review/infrastructure/PackageReviewJpaRepository.kt new file mode 100644 index 0000000..c748d4b --- /dev/null +++ b/src/main/kotlin/com/study/core/review/infrastructure/PackageReviewJpaRepository.kt @@ -0,0 +1,11 @@ +package com.study.core.review.infrastructure + +import com.study.core.review.domain.PackageReview +import org.springframework.data.jpa.repository.JpaRepository + +interface PackageReviewJpaRepository : JpaRepository { + + fun findAllByPkgId(packageId: Long): List + + fun findByPkgIdAndUserId(packageId: Long, userId: Long): com.study.core.review.domain.PackageReview? +} diff --git a/src/main/kotlin/com/study/core/review/infrastructure/PackageReviewRepositoryImpl.kt b/src/main/kotlin/com/study/core/review/infrastructure/PackageReviewRepositoryImpl.kt new file mode 100644 index 0000000..fef04a7 --- /dev/null +++ b/src/main/kotlin/com/study/core/review/infrastructure/PackageReviewRepositoryImpl.kt @@ -0,0 +1,25 @@ +package com.study.core.review.infrastructure + +import com.study.core.review.domain.PackageReviewRepository +import org.springframework.stereotype.Repository + +@Repository +class PackageReviewRepositoryImpl( + private val packageReviewJpaRepository: PackageReviewJpaRepository +) : PackageReviewRepository { + + override fun save(review: com.study.core.review.domain.PackageReview): com.study.core.review.domain.PackageReview = + packageReviewJpaRepository.save(review) + + override fun findById(reviewId: Long): com.study.core.review.domain.PackageReview? = + packageReviewJpaRepository.findById(reviewId).orElse(null) + + override fun findByPackageId(packageId: Long): List = + packageReviewJpaRepository.findAllByPkgId(packageId) + + override fun findByPackageIdAndUserId(packageId: Long, userId: Long): com.study.core.review.domain.PackageReview? = + packageReviewJpaRepository.findByPkgIdAndUserId(packageId, userId) + + override fun delete(review: com.study.core.review.domain.PackageReview) = + packageReviewJpaRepository.delete(review) +} diff --git a/src/main/kotlin/com/study/core/review/ui/PackageReviewApi.kt b/src/main/kotlin/com/study/core/review/ui/PackageReviewApi.kt new file mode 100644 index 0000000..b852412 --- /dev/null +++ b/src/main/kotlin/com/study/core/review/ui/PackageReviewApi.kt @@ -0,0 +1,105 @@ +package com.study.core.review.ui + +import com.study.core.review.application.dto.PackageReviewResponse +import com.study.core.review.ui.request.CreatePackageReviewRequest +import com.study.core.review.ui.request.UpdatePackageReviewRequest +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.MediaType +import org.springframework.web.multipart.MultipartFile + +@Tag(name = "Package Review", description = "패키지 리뷰 관련 API") +interface PackageReviewApi { + + @Operation( + summary = "패키지 리뷰 목록 조회", + description = "패키지에 대한 모든 리뷰를 조회합니다." + ) + @ApiResponse(responseCode = "200", description = "조회 성공") + fun findReviews( + @Parameter(description = "패키지 ID", example = "1") + packageId: Long + ): List + + @Operation( + summary = "패키지 리뷰 생성", + description = "패키지에 대한 새로운 리뷰를 생성합니다." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "201", description = "생성 성공"), + ApiResponse(responseCode = "400", description = "잘못된 요청") + ] + ) + fun createReview( + @Parameter(description = "패키지 ID", example = "1") + packageId: Long, + @Parameter(description = "리뷰 생성 요청 본문") + request: CreatePackageReviewRequest, + @Parameter( + description = "리뷰 이미지 파일 목록", + content = [ + Content( + mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + array = ArraySchema(schema = Schema(type = "string", format = "binary")) + ) + ] + ) + images: List? + ): PackageReviewResponse + + @Operation( + summary = "패키지 리뷰 수정", + description = "기존에 작성한 패키지 리뷰를 수정합니다." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "수정 성공"), + ApiResponse(responseCode = "400", description = "잘못된 요청"), + ApiResponse(responseCode = "404", description = "리뷰를 찾을 수 없음") + ] + ) + fun updateReview( + @Parameter(description = "패키지 ID", example = "1") + packageId: Long, + @Parameter(description = "리뷰 ID", example = "10") + reviewId: Long, + @Parameter(description = "리뷰 수정 요청 본문") + request: UpdatePackageReviewRequest, + @Parameter( + description = "리뷰 이미지 파일 목록(지정 시 기존 이미지를 대체)", + content = [ + Content( + mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + array = ArraySchema(schema = Schema(type = "string", format = "binary")) + ) + ] + ) + images: List? + ): PackageReviewResponse + + @Operation( + summary = "패키지 리뷰 삭제", + description = "특정 패키지 리뷰를 삭제합니다." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "204", description = "삭제 성공"), + ApiResponse(responseCode = "404", description = "리뷰를 찾을 수 없음") + ] + ) + fun deleteReview( + @Parameter(description = "패키지 ID", example = "1") + packageId: Long, + @Parameter(description = "리뷰 ID", example = "10") + reviewId: Long, + @Parameter(description = "요청 사용자 ID", example = "5") + userId: Long + ) +} diff --git a/src/main/kotlin/com/study/core/review/ui/PackageReviewController.kt b/src/main/kotlin/com/study/core/review/ui/PackageReviewController.kt new file mode 100644 index 0000000..93b976c --- /dev/null +++ b/src/main/kotlin/com/study/core/review/ui/PackageReviewController.kt @@ -0,0 +1,94 @@ +package com.study.core.review.ui + +import com.study.core.review.application.PackageReviewCommandService +import com.study.core.review.application.PackageReviewQueryService +import com.study.core.review.application.dto.CreatePackageReviewCommand +import com.study.core.review.application.dto.PackageReviewResponse +import com.study.core.review.application.dto.ReviewImageFile +import com.study.core.review.application.dto.UpdatePackageReviewCommand +import com.study.core.review.ui.request.CreatePackageReviewRequest +import com.study.core.review.ui.request.UpdatePackageReviewRequest +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.DeleteMapping +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.PutMapping +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.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +@RestController +@RequestMapping("/packages/{packageId}/reviews") +class PackageReviewController( + private val packageReviewCommandService: PackageReviewCommandService, + private val packageReviewQueryService: PackageReviewQueryService +) : PackageReviewApi { + + @GetMapping + override fun findReviews(@PathVariable packageId: Long): List = + packageReviewQueryService.findReviewsByPackage(packageId) + + @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @ResponseStatus(HttpStatus.CREATED) + override fun createReview( + @PathVariable packageId: Long, + @Valid @RequestPart("request") request: CreatePackageReviewRequest, + @RequestPart("images", required = false) images: List? + ): PackageReviewResponse { + val command = CreatePackageReviewCommand( + packageId = packageId, + userId = request.userId, + rating = request.rating, + content = request.content + ) + val imageFiles = toReviewImageFiles(images) + val review = packageReviewCommandService.createReview(command, imageFiles) + return packageReviewQueryService.toResponse(review) + } + + @PutMapping("/{reviewId}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + override fun updateReview( + @PathVariable packageId: Long, + @PathVariable reviewId: Long, + @Valid @RequestPart("request") request: UpdatePackageReviewRequest, + @RequestPart("images", required = false) images: List? + ): PackageReviewResponse { + val command = UpdatePackageReviewCommand( + packageId = packageId, + reviewId = reviewId, + userId = request.userId, + rating = request.rating, + content = request.content + ) + val imageFiles = images?.let { toReviewImageFiles(it) } + val review = packageReviewCommandService.updateReview(command, imageFiles) + return packageReviewQueryService.toResponse(review) + } + + @DeleteMapping("/{reviewId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + override fun deleteReview( + @PathVariable packageId: Long, + @PathVariable reviewId: Long, + @RequestParam userId: Long + ) { + packageReviewCommandService.deleteReview(packageId, reviewId, userId) + } + + private fun toReviewImageFiles(images: List?): List = + images.orEmpty() + .filterNot { it.isEmpty } + .mapIndexed { index, file -> + ReviewImageFile( + originalFilename = file.originalFilename ?: "review-image-$index", + content = file.bytes, + sortOrder = index + ) + } +} diff --git a/src/main/kotlin/com/study/core/review/ui/request/CreatePackageReviewRequest.kt b/src/main/kotlin/com/study/core/review/ui/request/CreatePackageReviewRequest.kt new file mode 100644 index 0000000..2f71a49 --- /dev/null +++ b/src/main/kotlin/com/study/core/review/ui/request/CreatePackageReviewRequest.kt @@ -0,0 +1,23 @@ +package com.study.core.review.ui.request + +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "패키지 리뷰 생성 요청 DTO") +data class CreatePackageReviewRequest( + @field:Schema(description = "리뷰 작성자 ID", example = "7") + val userId: Long, + + @field:Min(1) + @field:Max(5) + @field:Schema(description = "패키지 평점(1~5)", example = "4") + val rating: Int, + + @field:NotBlank + @field:Size(max = 2000) + @field:Schema(description = "리뷰 내용", example = "패키지 구성이 좋았습니다.") + val content: String +) diff --git a/src/main/kotlin/com/study/core/review/ui/request/UpdatePackageReviewRequest.kt b/src/main/kotlin/com/study/core/review/ui/request/UpdatePackageReviewRequest.kt new file mode 100644 index 0000000..720c068 --- /dev/null +++ b/src/main/kotlin/com/study/core/review/ui/request/UpdatePackageReviewRequest.kt @@ -0,0 +1,23 @@ +package com.study.core.review.ui.request + +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "패키지 리뷰 수정 요청 DTO") +data class UpdatePackageReviewRequest( + @field:Schema(description = "리뷰 작성자 ID", example = "7") + val userId: Long, + + @field:Min(1) + @field:Max(5) + @field:Schema(description = "패키지 평점(1~5)", example = "5") + val rating: Int, + + @field:NotBlank + @field:Size(max = 2000) + @field:Schema(description = "수정된 리뷰 내용", example = "패키지 이용해서 멋있어졌습니다.") + val content: String +) diff --git a/src/test/kotlin/com/study/core/image/application/ImageEventListenerTest.kt b/src/test/kotlin/com/study/core/image/application/ImageEventListenerTest.kt new file mode 100644 index 0000000..2a36dcc --- /dev/null +++ b/src/test/kotlin/com/study/core/image/application/ImageEventListenerTest.kt @@ -0,0 +1,61 @@ +package com.study.core.image.application + +import com.study.core.image.application.event.ImageCreateEvent +import com.study.core.image.domain.domainservice.StoredImage +import com.study.core.image.domain.domainservice.ImageStorage +import com.study.core.image.domain.Image +import com.study.core.image.domain.vo.ImageAggregateType +import com.study.core.image.domain.ImageRepository +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldEndWith +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Test + +class ImageEventListenerTest { + + private val imageRepository: ImageRepository = mockk(relaxed = true) + private val imageStorage: ImageStorage = mockk() + private val listener = ImageEventListener(imageRepository, imageStorage) + + @Test + fun `이미지 생성 이벤트를 처리하면 파일을 저장하고 이미지를 저장한다`() { + // given + val content = "hello".toByteArray() + val event = ImageCreateEvent( + aggregateType = ImageAggregateType.PACKAGE, + aggregateId = 1L, + originalFilename = "photo.jpg", + content = content, + sortOrder = 3 + ) + + every { imageStorage.store(any(), content) } returns StoredImage( + url = "file:///tmp/photo.jpg", + fileSize = content.size.toLong() + ) + + val savedSlot = slot() + every { imageRepository.save(capture(savedSlot)) } answers { savedSlot.captured } + + // when + listener.handleImageCreate(event) + + // then + val saved = savedSlot.captured + saved.aggregateType shouldBe ImageAggregateType.PACKAGE + saved.aggregateId shouldBe 1L + saved.originalFilename shouldBe "photo.jpg" + saved.uniqueFilename shouldEndWith ".jpg" + saved.fileSize shouldBe content.size.toLong() + saved.sortOrder shouldBe 3 + saved.url shouldBe "file:///tmp/photo.jpg" + + verify(exactly = 1) { + imageStorage.store(match { it.endsWith(".jpg") }, content) + } + verify(exactly = 1) { imageRepository.save(any()) } + } +} diff --git a/src/test/kotlin/com/study/core/image/domain/ImageTest.kt b/src/test/kotlin/com/study/core/image/domain/ImageTest.kt new file mode 100644 index 0000000..6f00b46 --- /dev/null +++ b/src/test/kotlin/com/study/core/image/domain/ImageTest.kt @@ -0,0 +1,72 @@ +package com.study.core.image.domain + +import com.study.core.image.domain.domainservice.ImageStorage +import com.study.core.image.domain.domainservice.StoredImage +import com.study.core.image.domain.vo.ImageAggregateType +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldEndWith +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test + +class ImageTest { + + @Test + fun `고유 파일명 생성시 확장자를 소문자로 유지하고 uuid를 붙인다`() { + // given + val originalFilename = " Photo.PNG " + + // when + val generated = Image.generateUniqueFilename(originalFilename) + + // then + generated.extension shouldBe "png" + generated.uniqueFilename shouldEndWith ".png" + (generated.uniqueFilename.length > generated.extension.length) shouldBe true + } + + @Test + fun `파일을 저장하면서 이미지를 생성하면 검증된 값으로 생성된다`() { + // given + val content = ByteArray(10) { 1 } + val imageStorage = mockk() + every { imageStorage.store(any(), content) } answers { + val filename = firstArg() + StoredImage( + url = "file:///tmp/$filename", + fileSize = 512 + ) + } + + // when + val image = Image.createAndStore( + aggregateType = ImageAggregateType.PACKAGE, + aggregateId = 10L, + originalFilename = "photo.png", + content = content, + sortOrder = 2, + imageStorage = imageStorage + ) + + // then + image.originalFilename shouldBe "photo.png" + image.extension shouldBe "png" + image.fileSize shouldBe 512 + image.sortOrder shouldBe 2 + } + + @Test + fun `잘못된 입력으로 이미지를 생성하면 예외가 발생한다`() { + // given, when, then + shouldThrow { + Image.createAndStore( + aggregateType = ImageAggregateType.PACKAGE, + aggregateId = 0L, + originalFilename = "photo.png", + content = byteArrayOf(1, 2, 3), + imageStorage = mockk() + ) + } + } +} diff --git a/src/test/kotlin/com/study/core/image/infrastructure/LocalImageStorageTest.kt b/src/test/kotlin/com/study/core/image/infrastructure/LocalImageStorageTest.kt new file mode 100644 index 0000000..ec18932 --- /dev/null +++ b/src/test/kotlin/com/study/core/image/infrastructure/LocalImageStorageTest.kt @@ -0,0 +1,28 @@ +package com.study.core.image.infrastructure + +import com.study.core.image.infrastructure.storage.LocalImageStorage +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import java.nio.file.Files + +class LocalImageStorageTest { + + @Test + fun `파일을 저장하면 설정된 기본 경로에 작성된다`() { + // given + val tempDir = Files.createTempDirectory("local-image-storage-test") + val storage = LocalImageStorage(basePath = tempDir.toString()) + val content = "sample-data".toByteArray() + + // when + val stored = storage.store("test.txt", content) + + // then + val targetPath = tempDir.resolve("test.txt") + Files.exists(targetPath).shouldBeTrue() + Files.readString(targetPath) shouldBe "sample-data" + stored.url shouldBe targetPath.toUri().toString() + stored.fileSize shouldBe content.size.toLong() + } +} diff --git a/src/test/kotlin/com/study/core/review/application/PackageReviewCommandServiceTest.kt b/src/test/kotlin/com/study/core/review/application/PackageReviewCommandServiceTest.kt new file mode 100644 index 0000000..85324e9 --- /dev/null +++ b/src/test/kotlin/com/study/core/review/application/PackageReviewCommandServiceTest.kt @@ -0,0 +1,233 @@ +package com.study.core.review.application + +import com.study.core.image.application.ImageEventPublisher +import com.study.core.image.domain.vo.ImageAggregateType +import com.study.core.productpackage.domain.Package +import com.study.core.productpackage.domain.PackageRepository +import com.study.core.review.application.dto.CreatePackageReviewCommand +import com.study.core.review.application.dto.ReviewImageFile +import com.study.core.review.application.dto.UpdatePackageReviewCommand +import com.study.core.review.domain.PackageReview +import com.study.core.review.domain.PackageReviewRepository +import com.study.core.review.domain.ReviewRating +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test + +class PackageReviewCommandServiceTest { + + private val packageRepository: PackageRepository = mockk() + private val packageReviewRepository: PackageReviewRepository = mockk() + private val imageEventPublisher: ImageEventPublisher = mockk(relaxed = true) + private val service = PackageReviewCommandService( + packageRepository = packageRepository, + packageReviewRepository = packageReviewRepository, + imageEventPublisher = imageEventPublisher + ) + + private val pkg = Package( + id = 1L, + name = "패키지", + description = "설명" + ) + + @Test + fun `리뷰를 생성하면 저장하고 이미지 이벤트를 발행한다`() { + // given + val command = CreatePackageReviewCommand( + packageId = pkg.id, + userId = 10L, + rating = 5, + content = "최고에요" + ) + val images = listOf( + ReviewImageFile("first.jpg", "a".toByteArray(), sortOrder = 0), + ReviewImageFile("second.jpg", "b".toByteArray(), sortOrder = 1) + ) + + every { packageRepository.existsById(pkg.id) } returns true + every { packageRepository.findById(pkg.id) } returns pkg + every { packageReviewRepository.findByPackageIdAndUserId(pkg.id, command.userId) } returns null + every { packageReviewRepository.save(any()) } answers { review -> + val captured = review.invocation.args.first() as PackageReview + PackageReview( + id = 99L, + pkg = captured.pkg, + userId = captured.userId, + rating = captured.rating, + content = captured.content + ) + } + + // when + val saved = service.createReview(command, images) + + // then + saved.id shouldBe 99L + saved.content shouldBe "최고에요" + verify(exactly = 1) { + packageReviewRepository.save(match { it.userId == command.userId && it.content == command.content }) + } + verify(exactly = images.size) { + imageEventPublisher.publishCreate( + aggregateType = ImageAggregateType.PACKAGE_REVIEW, + aggregateId = 99L, + originalFilename = any(), + content = any(), + sortOrder = any() + ) + } + } + + @Test + fun `이미 리뷰를 작성했다면 생성시 예외가 발생한다`() { + // given + val command = CreatePackageReviewCommand( + packageId = pkg.id, + userId = 10L, + rating = 4, + content = "이미 작성함" + ) + every { packageRepository.existsById(pkg.id) } returns true + every { packageRepository.findById(pkg.id) } returns pkg + every { packageReviewRepository.findByPackageIdAndUserId(pkg.id, command.userId) } returns PackageReview.of( + pkg = pkg, + userId = command.userId, + ratingScore = 4, + content = "중복" + ) + + // when, then + shouldThrow { + service.createReview(command) + } + } + + @Test + fun `리뷰를 수정하면 내용과 평점이 변경되고 이미지가 재등록된다`() { + // given + val existing = PackageReview( + id = 5L, + pkg = pkg, + userId = 20L, + rating = ReviewRating.of(3), + content = "처음 리뷰" + ) + val command = UpdatePackageReviewCommand( + packageId = pkg.id, + reviewId = existing.id, + userId = existing.userId, + rating = 1, + content = "수정된 리뷰" + ) + val images = listOf(ReviewImageFile("new.jpg", "c".toByteArray(), sortOrder = 0)) + + every { packageReviewRepository.findById(existing.id) } returns existing + justRun { packageReviewRepository.delete(any()) } + + // when + val updated = service.updateReview(command, images) + + // then + updated.rating.value shouldBe 1 + updated.content shouldBe "수정된 리뷰" + verify(exactly = 1) { + imageEventPublisher.publishDelete( + aggregateType = ImageAggregateType.PACKAGE_REVIEW, + aggregateId = existing.id + ) + } + verify(exactly = images.size) { + imageEventPublisher.publishCreate( + aggregateType = ImageAggregateType.PACKAGE_REVIEW, + aggregateId = existing.id, + originalFilename = any(), + content = any(), + sortOrder = any() + ) + } + } + + @Test + fun `다른 사용자가 리뷰를 수정하면 예외가 발생한다`() { + // given + val existing = PackageReview( + id = 5L, + pkg = pkg, + userId = 1L, + rating = ReviewRating.of(4), + content = "본인 리뷰" + ) + every { packageReviewRepository.findById(existing.id) } returns existing + + val command = UpdatePackageReviewCommand( + packageId = pkg.id, + reviewId = existing.id, + userId = 2L, + rating = 5, + content = "남의 리뷰 수정" + ) + + // when, then + shouldThrow { + service.updateReview(command) + } + } + + @Test + fun `리뷰를 삭제하면 소유자 검증후 이미지를 삭제한다`() { + // given + val existing = PackageReview( + id = 30L, + pkg = pkg, + userId = 99L, + rating = ReviewRating.of(5), + content = "삭제 대상" + ) + every { packageReviewRepository.findById(existing.id) } returns existing + justRun { packageReviewRepository.delete(existing) } + + // when + service.deleteReview( + packageId = pkg.id, + reviewId = existing.id, + userId = existing.userId + ) + + // then + verify(exactly = 1) { + imageEventPublisher.publishDelete( + aggregateType = ImageAggregateType.PACKAGE_REVIEW, + aggregateId = existing.id + ) + } + verify(exactly = 1) { packageReviewRepository.delete(existing) } + } + + @Test + fun `다른 사용자가 리뷰를 삭제하면 예외가 발생한다`() { + // given + val existing = PackageReview( + id = 7L, + pkg = pkg, + userId = 5L, + rating = ReviewRating.of(2), + content = "삭제 불가" + ) + every { packageReviewRepository.findById(existing.id) } returns existing + + // when, then + shouldThrow { + service.deleteReview( + packageId = pkg.id, + reviewId = existing.id, + userId = 10L + ) + } + verify(exactly = 0) { packageReviewRepository.delete(any()) } + } +} diff --git a/src/test/kotlin/com/study/core/review/application/PackageReviewQueryServiceTest.kt b/src/test/kotlin/com/study/core/review/application/PackageReviewQueryServiceTest.kt new file mode 100644 index 0000000..385ff78 --- /dev/null +++ b/src/test/kotlin/com/study/core/review/application/PackageReviewQueryServiceTest.kt @@ -0,0 +1,119 @@ +package com.study.core.review.application + +import com.study.core.image.domain.Image +import com.study.core.image.domain.ImageRepository +import com.study.core.image.domain.vo.ImageAggregateType +import com.study.core.productpackage.domain.Package +import com.study.core.review.domain.PackageReview +import com.study.core.review.domain.PackageReviewRepository +import com.study.core.review.domain.ReviewRating +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import java.time.LocalDateTime + +class PackageReviewQueryServiceTest { + + private val packageReviewRepository: PackageReviewRepository = mockk() + private val imageRepository: ImageRepository = mockk() + private val packageReviewQueryService = PackageReviewQueryService(packageReviewRepository, imageRepository) + + private val pkg = Package( + id = 1L, + name = "패키지", + description = "설명" + ) + + @Test + fun `패키지별 리뷰를 최신순으로 조회하고 이미지를 매핑한다`() { + // given + val older = PackageReview( + id = 1L, + pkg = pkg, + userId = 10L, + rating = ReviewRating.of(3), + content = "이전 리뷰" + ).apply { + setCreatedAt(LocalDateTime.of(2024, 1, 1, 10, 0)) + } + val newer = PackageReview( + id = 2L, + pkg = pkg, + userId = 11L, + rating = ReviewRating.of(5), + content = "최신 리뷰" + ).apply { + setCreatedAt(LocalDateTime.of(2024, 1, 2, 9, 0)) + } + + every { packageReviewRepository.findByPackageId(pkg.id) } returns listOf(older, newer) + every { + imageRepository.findByAggregates( + ImageAggregateType.PACKAGE_REVIEW, + match { ids -> ids.containsAll(listOf(older.id, newer.id)) } + ) + } returns listOf( + image(aggregateId = newer.id, sortOrder = 0, original = "new-1.jpg"), + image(aggregateId = older.id, sortOrder = 0, original = "old-1.jpg"), + image(aggregateId = newer.id, sortOrder = 1, original = "new-2.jpg"), + ) + + // when + val responses = packageReviewQueryService.findReviewsByPackage(pkg.id) + + // then + responses.map { it.reviewId } shouldContainExactly listOf(newer.id, older.id) + responses.first { it.reviewId == newer.id }.images.map { it.originalFilename } shouldContainExactly listOf( + "new-1.jpg", + "new-2.jpg" + ) + responses.first { it.reviewId == older.id }.images.first().originalFilename shouldBe "old-1.jpg" + } + + @Test + fun `단일 리뷰를 응답으로 변환할 때 이미지를 조회한다`() { + // given + val review = PackageReview( + id = 50L, + pkg = pkg, + userId = 30L, + rating = ReviewRating.of(4), + content = "단일 조회" + ) + every { + imageRepository.findByAggregate(ImageAggregateType.PACKAGE_REVIEW, review.id) + } returns listOf( + image(aggregateId = review.id, sortOrder = 0, original = "one.jpg") + ) + + // when + val response = packageReviewQueryService.toResponse(review) + + // then + response.reviewId shouldBe review.id + response.images.first().originalFilename shouldBe "one.jpg" + } + + private fun PackageReview.setCreatedAt(time: LocalDateTime) { + val field = PackageReview::class.java.superclass!!.getDeclaredField("createdAt") + field.isAccessible = true + field.set(this, time) + } + + private fun image( + aggregateId: Long, + sortOrder: Int, + original: String + ): Image = Image( + aggregateType = ImageAggregateType.PACKAGE_REVIEW, + aggregateId = aggregateId, + originalFilename = original, + uniqueFilename = "unique-$original", + extension = original.substringAfterLast('.', "jpg"), + fileSize = 100, + url = "http://test/$original", + sortOrder = sortOrder + ) +} diff --git a/src/test/kotlin/com/study/core/review/domain/PackageReviewTest.kt b/src/test/kotlin/com/study/core/review/domain/PackageReviewTest.kt new file mode 100644 index 0000000..b20d81f --- /dev/null +++ b/src/test/kotlin/com/study/core/review/domain/PackageReviewTest.kt @@ -0,0 +1,92 @@ +package com.study.core.review.domain + +import com.study.core.productpackage.domain.Package +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class PackageReviewTest { + + private val pkg = Package( + id = 1L, + name = "패키지", + description = "설명" + ) + + @Test + fun `리뷰를 생성하면 평점과 내용이 설정된다`() { + // given + val userId = 10L + + // when + val review = PackageReview.of( + pkg = pkg, + userId = userId, + ratingScore = 5, + content = "좋아요" + ) + + // then + review.pkg.id shouldBe pkg.id + review.userId shouldBe userId + review.rating.value shouldBe 5 + review.content shouldBe "좋아요" + } + + @Test + fun `리뷰 내용이 비어있으면 예외가 발생한다`() { + // given + val emptyContent = " " + + // when // then + shouldThrow { + PackageReview.of( + pkg = pkg, + userId = 10L, + ratingScore = 4, + content = emptyContent + ) + } + } + + @Test + fun `리뷰를 수정하면 평점과 내용이 변경된다`() { + // given + val review = PackageReview.of( + pkg = pkg, + userId = 1L, + ratingScore = 3, + content = "보통이에요" + ) + + // when + review.changeReview( + rating = ReviewRating.of(1), + content = "별로예요" + ) + + // then + review.rating.value shouldBe 1 + review.content shouldBe "별로예요" + } + + @Test + fun `다른 사용자나 패키지의 리뷰면 검증에서 예외가 발생한다`() { + // given + val review = PackageReview.of( + pkg = pkg, + userId = 100L, + ratingScore = 4, + content = "만족합니다" + ) + + // when // then + assertThrows { + review.validateOwner(userId = 200L, pkgId = pkg.id) + } + assertThrows { + review.validateOwner(userId = 100L, pkgId = 999L) + } + } +} diff --git a/src/test/kotlin/com/study/core/review/ui/PackageReviewControllerTest.kt b/src/test/kotlin/com/study/core/review/ui/PackageReviewControllerTest.kt new file mode 100644 index 0000000..0212820 --- /dev/null +++ b/src/test/kotlin/com/study/core/review/ui/PackageReviewControllerTest.kt @@ -0,0 +1,145 @@ +package com.study.core.review.ui + +import com.fasterxml.jackson.databind.ObjectMapper +import com.study.core.global.enums.UserRole +import com.study.core.global.security.JwtProvider +import com.study.core.productpackage.domain.Package +import com.study.core.productpackage.infrastructure.PackageCommandRepository +import com.study.core.review.application.dto.PackageReviewResponse +import com.study.core.review.ui.request.CreatePackageReviewRequest +import com.study.core.review.ui.request.UpdatePackageReviewRequest +import com.study.core.testhelper.IntegrationTest +import io.kotest.matchers.shouldBe +import io.restassured.RestAssured.given +import io.restassured.builder.MultiPartSpecBuilder +import io.restassured.config.EncoderConfig +import io.restassured.config.RestAssuredConfig +import io.restassured.http.ContentType +import io.restassured.specification.MultiPartSpecification +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import java.nio.charset.StandardCharsets + +@IntegrationTest +class PackageReviewControllerTest { + + @Autowired + private lateinit var packageCommandRepository: PackageCommandRepository + + @Autowired + private lateinit var jwtProvider: JwtProvider + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `패키지 리뷰를 E2E로 생성 조회 수정 삭제한다`() { + // given + val pkgId = savePackage() + val userId = 101L + val token = jwtProvider.generateToken(userId, UserRole.USER).accessToken + + // when + val created = + given() + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .config(utf8MultipartConfig()) + .contentType("multipart/form-data; charset=UTF-8") + .multiPart( + jsonPart( + name = "request", + payload = CreatePackageReviewRequest( + userId = userId, + rating = 5, + content = "정말 좋아요" + ) + ) + ) + .`when`() + .post("/packages/$pkgId/reviews") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract() + .`as`(PackageReviewResponse::class.java) + + // then + created.packageId shouldBe pkgId + created.userId shouldBe userId + + // when + val updated = + given() + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .config(utf8MultipartConfig()) + .contentType("multipart/form-data; charset=UTF-8") + .multiPart( + jsonPart( + name = "request", + payload = UpdatePackageReviewRequest( + userId = userId, + rating = 3, + content = "조금 아쉬워요" + ) + ) + ) + .`when`() + .put("/packages/$pkgId/reviews/${created.reviewId}") + .then() + .statusCode(HttpStatus.OK.value()) + .extract() + .`as`(PackageReviewResponse::class.java) + + // then + updated.reviewId shouldBe created.reviewId + updated.rating shouldBe 3 + updated.content shouldBe "조금 아쉬워요" + + // when + given() + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .`when`() + .delete("/packages/$pkgId/reviews/${created.reviewId}?userId=$userId") + .then() + .statusCode(HttpStatus.NO_CONTENT.value()) + + // then + val reviewsAfterDelete = + given() + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .`when`() + .get("/packages/$pkgId/reviews") + .then() + .statusCode(HttpStatus.OK.value()) + .extract() + .`as`(Array::class.java) + + reviewsAfterDelete.toList().size shouldBe 0 + } + + private fun savePackage(): Long { + val saved = packageCommandRepository.save( + Package( + name = "패키지", + description = "패키지 설명" + ) + ) + return saved.id + } + + private fun bearer(token: String) = "Bearer $token" + + private fun jsonPart(name: String, payload: Any): MultiPartSpecification = + MultiPartSpecBuilder(objectMapper.writeValueAsString(payload)) + .controlName(name) + .charset(StandardCharsets.UTF_8) + .mimeType("application/json") + .build() + + private fun utf8MultipartConfig(): RestAssuredConfig = + RestAssuredConfig.config().encoderConfig( + EncoderConfig() + .defaultCharsetForContentType(StandardCharsets.UTF_8.name(), ContentType.MULTIPART) + ) +} diff --git a/src/test/kotlin/com/study/core/testhelper/IntegrationTest.kt b/src/test/kotlin/com/study/core/testhelper/IntegrationTest.kt new file mode 100644 index 0000000..57b2d18 --- /dev/null +++ b/src/test/kotlin/com/study/core/testhelper/IntegrationTest.kt @@ -0,0 +1,65 @@ +package com.study.core.testhelper + +import io.restassured.RestAssured +import jakarta.persistence.EntityManager +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.TestContext +import org.springframework.test.context.TestExecutionListeners +import org.springframework.test.context.support.AbstractTestExecutionListener +import org.springframework.test.context.support.DependencyInjectionTestExecutionListener +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = ["spring.profiles.active=test"] +) +@AutoConfigureMockMvc +@TestExecutionListeners( + listeners = [ + DependencyInjectionTestExecutionListener::class, + DatabaseCleanupListener::class, + ], +) +annotation class IntegrationTest + +class DatabaseCleanupListener : AbstractTestExecutionListener() { + override fun beforeTestExecution(testContext: TestContext) { + val applicationContext = testContext.applicationContext + val transactionManager = applicationContext.getBean(PlatformTransactionManager::class.java) + val entityManager = applicationContext.getBean(EntityManager::class.java) + + applyRestAssuredPort(applicationContext.environment.getProperty("local.server.port")) + cleanupDatabase(entityManager, transactionManager) + } + + private fun applyRestAssuredPort(port: String?) { + RestAssured.port = + requireNotNull(port?.toIntOrNull()) { + "RestAssured port 설정 불가: 'local.server.port'를 사용할 수 없습니다.." + } + } + + private fun cleanupDatabase( + entityManager: EntityManager, + transactionManager: PlatformTransactionManager, + ) { + TransactionTemplate(transactionManager).executeWithoutResult { + val tableNames = + entityManager + .createNativeQuery( + "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'PUBLIC'", + ).resultList + .filterIsInstance() + + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate() + tableNames.forEach { table -> + entityManager.createNativeQuery("TRUNCATE TABLE \"$table\"").executeUpdate() + } + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate() + } + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index b705e38..c62d37c 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -9,6 +9,9 @@ spring: default_batch_fetch_size: 100 jdbc: time_zone: UTC + h2: + console: + enabled: true security: oauth2: @@ -19,9 +22,8 @@ spring: client-secret: test-secret jwt: - secret: test-jwt-secret + secret: jwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecret log-token: false - jasypt: encryptor: password: test-password