diff --git a/bootstrap/src/main/resources/application.yml b/bootstrap/src/main/resources/application.yml index 3f0d280..e095c58 100644 --- a/bootstrap/src/main/resources/application.yml +++ b/bootstrap/src/main/resources/application.yml @@ -1,4 +1,7 @@ +server: + port: {PORT} + spring: config: import: - - application-jpa.yml + - application-persistence.yml diff --git a/community-application/src/main/kotlin/gloddy/.gitkeep b/bootstrap/src/test/kotlin/gloddy/.gitkeep similarity index 100% rename from community-application/src/main/kotlin/gloddy/.gitkeep rename to bootstrap/src/test/kotlin/gloddy/.gitkeep diff --git a/bootstrap/src/test/kotlin/gloddy/CommunityApplicationTests.kt b/bootstrap/src/test/kotlin/gloddy/CommunityApplicationTests.kt deleted file mode 100644 index 1361480..0000000 --- a/bootstrap/src/test/kotlin/gloddy/CommunityApplicationTests.kt +++ /dev/null @@ -1,13 +0,0 @@ -package gloddy - -import org.junit.jupiter.api.Test -import org.springframework.boot.test.context.SpringBootTest - -@SpringBootTest -class CommunityApplicationTests { - - @Test - fun contextLoads() { - } - -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index a193899..6a7c313 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,6 +30,14 @@ subprojects { dependencies{ implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.slf4j:slf4j-api:2.0.10") + implementation("ch.qos.logback:logback-core:1.4.14") + testCompileOnly("org.junit.jupiter:junit-jupiter-params:5.10.1") + testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.1") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.1") + testImplementation("org.springframework.boot:spring-boot-starter-test") { + exclude("junit") + } } group = "gloddy" diff --git a/community-application/build.gradle.kts b/community-application/build.gradle.kts index 3984d5d..4fd57e4 100644 --- a/community-application/build.gradle.kts +++ b/community-application/build.gradle.kts @@ -13,12 +13,5 @@ dependencies { implementation("org.springframework:spring-context") implementation("com.fasterxml.jackson.core:jackson-annotations:2.15.2") implementation("jakarta.validation:jakarta.validation-api") - - testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.3") - testImplementation("org.junit.jupiter:junit-jupiter-params:5.9.3") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.3") - - testImplementation("org.assertj:assertj-core:3.24.2") - testImplementation("org.mockito:mockito-junit-jupiter:5.3.1") - testImplementation("org.mockito:mockito-core:5.3.1") + testImplementation(testFixtures(project(":community-domain"))) } diff --git a/community-application/src/main/kotlin/gloddy/article/dto/command/ArticleCreateCommand.kt b/community-application/src/main/kotlin/gloddy/article/dto/command/ArticleCreateCommand.kt new file mode 100644 index 0000000..c488ef8 --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/article/dto/command/ArticleCreateCommand.kt @@ -0,0 +1,8 @@ +package gloddy.article.dto.command + +data class ArticleCreateCommand( + val categoryId: Long, + val title: String, + val content: String, + val images: List?, +) diff --git a/community-application/src/main/kotlin/gloddy/article/dto/read/ArticleIdReadData.kt b/community-application/src/main/kotlin/gloddy/article/dto/read/ArticleIdReadData.kt new file mode 100644 index 0000000..a9da087 --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/article/dto/read/ArticleIdReadData.kt @@ -0,0 +1,5 @@ +package gloddy.article.dto.read + +data class ArticleIdReadData( + val articleId: Long +) \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/article/port/in/ArticleCommandUseCase.kt b/community-application/src/main/kotlin/gloddy/article/port/in/ArticleCommandUseCase.kt new file mode 100644 index 0000000..ded8688 --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/article/port/in/ArticleCommandUseCase.kt @@ -0,0 +1,10 @@ +package gloddy.article.port.`in` + +import gloddy.article.dto.command.ArticleCreateCommand +import gloddy.article.dto.read.ArticleIdReadData + +interface ArticleCommandUseCase { + fun create(userId: Long, command: ArticleCreateCommand): ArticleIdReadData + fun delete(userId: Long, articleId: Long) + fun like(userId: Long, articleId: Long) +} \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/article/port/out/ArticleCommandPersistencePort.kt b/community-application/src/main/kotlin/gloddy/article/port/out/ArticleCommandPersistencePort.kt new file mode 100644 index 0000000..155a266 --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/article/port/out/ArticleCommandPersistencePort.kt @@ -0,0 +1,8 @@ +package gloddy.article.port.out + +import gloddy.article.Article + +interface ArticleCommandPersistencePort { + fun save(article: Article) : Article + fun delete(id: Long) +} \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/article/port/out/ArticleLikeCommandPersistencePort.kt b/community-application/src/main/kotlin/gloddy/article/port/out/ArticleLikeCommandPersistencePort.kt new file mode 100644 index 0000000..5ee5eb4 --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/article/port/out/ArticleLikeCommandPersistencePort.kt @@ -0,0 +1,8 @@ +package gloddy.article.port.out + +import gloddy.article.ArticleLike + +interface ArticleLikeCommandPersistencePort { + fun save(articleLike: ArticleLike): ArticleLike + fun delete(articleLike: ArticleLike) +} \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/article/port/out/ArticleLikeQueryPersistencePort.kt b/community-application/src/main/kotlin/gloddy/article/port/out/ArticleLikeQueryPersistencePort.kt new file mode 100644 index 0000000..c710d2c --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/article/port/out/ArticleLikeQueryPersistencePort.kt @@ -0,0 +1,8 @@ +package gloddy.article.port.out + +import gloddy.article.Article +import gloddy.article.ArticleLike + +interface ArticleLikeQueryPersistencePort { + fun findByUserIdAndArticleOrNull(userId: Long, article: Article): ArticleLike? +} \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/article/port/out/ArticleQueryPersistencePort.kt b/community-application/src/main/kotlin/gloddy/article/port/out/ArticleQueryPersistencePort.kt new file mode 100644 index 0000000..01f2379 --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/article/port/out/ArticleQueryPersistencePort.kt @@ -0,0 +1,7 @@ +package gloddy.article.port.out + +import gloddy.article.Article + +interface ArticleQueryPersistencePort { + fun findById(id: Long): Article +} \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/article/service/ArticleCommandService.kt b/community-application/src/main/kotlin/gloddy/article/service/ArticleCommandService.kt new file mode 100644 index 0000000..1e730e9 --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/article/service/ArticleCommandService.kt @@ -0,0 +1,59 @@ +package gloddy.article.service + +import gloddy.article.Article +import gloddy.article.ArticleLike +import gloddy.article.dto.command.ArticleCreateCommand +import gloddy.article.dto.read.ArticleIdReadData +import gloddy.article.port.`in`.ArticleCommandUseCase +import gloddy.article.port.out.ArticleCommandPersistencePort +import gloddy.article.port.out.ArticleLikeCommandPersistencePort +import gloddy.article.port.out.ArticleLikeQueryPersistencePort +import gloddy.article.port.out.ArticleQueryPersistencePort +import gloddy.category.port.out.CategoryQueryPersistencePort +import gloddy.core.CategoryId +import gloddy.core.UserId +import org.springframework.stereotype.Service + +@Service +class ArticleCommandService( + private val categoryQueryPersistencePort: CategoryQueryPersistencePort, + private val articleQueryPersistencePort: ArticleQueryPersistencePort, + private val articleCommandPersistencePort: ArticleCommandPersistencePort, + private val articleLikeCommandPersistencePort: ArticleLikeCommandPersistencePort, + private val articleLikeQueryPersistencePort: ArticleLikeQueryPersistencePort +) : ArticleCommandUseCase { + + override fun create(userId: Long, command: ArticleCreateCommand) : ArticleIdReadData { + + val category = categoryQueryPersistencePort.findById(CategoryId(command.categoryId)) + + val article = Article( + userId = UserId(userId), + category = category, + title = command.title, + content = command.content, + images = command.images, + ).let { articleCommandPersistencePort.save(it) } + return ArticleIdReadData(articleId = article.id!!.value) + } + + override fun delete(userId: Long, articleId: Long) { + val article = articleQueryPersistencePort.findById(articleId) + article.validateAuthorization(userId) + articleCommandPersistencePort.delete(article.id!!.value) + } + + override fun like(userId: Long, articleId: Long) { + + val article = articleQueryPersistencePort.findById(articleId) + + articleLikeQueryPersistencePort.findByUserIdAndArticleOrNull(userId, article) + ?.run { articleLikeCommandPersistencePort.delete(this) } + ?: articleLikeCommandPersistencePort.save( + ArticleLike( + userId = UserId(userId), + article = article + ) + ) + } +} \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/category/port/dto/CategoryReadData.kt b/community-application/src/main/kotlin/gloddy/category/port/dto/CategoryReadData.kt new file mode 100644 index 0000000..0ac7168 --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/category/port/dto/CategoryReadData.kt @@ -0,0 +1,6 @@ +package gloddy.category.port.dto + +data class CategoryReadData( + val id: Long, + val name: String +) \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/category/port/in/CategoryQueryUseCase.kt b/community-application/src/main/kotlin/gloddy/category/port/in/CategoryQueryUseCase.kt new file mode 100644 index 0000000..febc50a --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/category/port/in/CategoryQueryUseCase.kt @@ -0,0 +1,7 @@ +package gloddy.category.port.`in` + +import gloddy.category.port.dto.CategoryReadData + +interface CategoryQueryUseCase { + fun getAll(): List +} \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/category/port/out/CategoryQueryPersistencePort.kt b/community-application/src/main/kotlin/gloddy/category/port/out/CategoryQueryPersistencePort.kt new file mode 100644 index 0000000..da61348 --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/category/port/out/CategoryQueryPersistencePort.kt @@ -0,0 +1,9 @@ +package gloddy.category.port.out + +import gloddy.category.Category +import gloddy.core.CategoryId + +interface CategoryQueryPersistencePort { + fun findById(id: CategoryId): Category + fun findAll(): List +} \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/category/port/service/CategoryQueryService.kt b/community-application/src/main/kotlin/gloddy/category/port/service/CategoryQueryService.kt new file mode 100644 index 0000000..e8e37b8 --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/category/port/service/CategoryQueryService.kt @@ -0,0 +1,22 @@ +package gloddy.category.port.service + +import gloddy.category.port.dto.CategoryReadData +import gloddy.category.port.`in`.CategoryQueryUseCase +import gloddy.category.port.out.CategoryQueryPersistencePort +import org.springframework.stereotype.Service + +@Service +class CategoryQueryService( + private val categoryQueryPersistencePort: CategoryQueryPersistencePort, +) : CategoryQueryUseCase { + + override fun getAll(): List { + val categories = categoryQueryPersistencePort.findAll() + return categories.map { + CategoryReadData( + id = it.id!!.value, + name = it.name + ) + } + } +} \ No newline at end of file diff --git a/community-application/src/test/kotlin/gloddy/.gitkeep b/community-application/src/test/kotlin/gloddy/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/community-application/src/test/kotlin/gloddy/ServiceTest.kt b/community-application/src/test/kotlin/gloddy/ServiceTest.kt new file mode 100644 index 0000000..c0201c3 --- /dev/null +++ b/community-application/src/test/kotlin/gloddy/ServiceTest.kt @@ -0,0 +1,8 @@ +package gloddy + +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.junit.jupiter.MockitoExtension + +@ExtendWith(MockitoExtension::class) +abstract class ServiceTest { +} \ No newline at end of file diff --git a/community-domain/build.gradle.kts b/community-domain/build.gradle.kts index 121c644..b95847b 100644 --- a/community-domain/build.gradle.kts +++ b/community-domain/build.gradle.kts @@ -6,6 +6,9 @@ val bootJar: BootJar by tasks bootJar.enabled = false jar.enabled = true +plugins { + id("java-test-fixtures") +} dependencies { } diff --git a/community-domain/src/main/kotlin/gloddy/.gitkeep b/community-domain/src/main/kotlin/gloddy/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/community-domain/src/main/kotlin/gloddy/article/Article.kt b/community-domain/src/main/kotlin/gloddy/article/Article.kt new file mode 100644 index 0000000..137524e --- /dev/null +++ b/community-domain/src/main/kotlin/gloddy/article/Article.kt @@ -0,0 +1,40 @@ +package gloddy.article + +import gloddy.article.exception.ArticleNoAuthorizationException +import gloddy.article.vo.ArticleImage +import gloddy.category.Category +import gloddy.core.ArticleId +import gloddy.core.UserId + +data class Article( + val userId: UserId, + var category: Category, + var title: String, + var content: String, + var image: ArticleImage, + var commentCount: Int = 0, + var likeCount: Int = 0, + val id: ArticleId? = null, +) { + constructor( + userId: UserId, + category: Category, + title: String, + content: String, + images: List?, + id: ArticleId? = null + ) : this( + userId = userId, + category = category, + title = title, + content = content, + image = ArticleImage(images), + id = id + ) + + fun validateAuthorization(userId: Long) { + if (this.userId.value != userId) { + throw ArticleNoAuthorizationException() + } + } +} \ No newline at end of file diff --git a/community-domain/src/main/kotlin/gloddy/article/ArticleErrorCode.kt b/community-domain/src/main/kotlin/gloddy/article/ArticleErrorCode.kt new file mode 100644 index 0000000..b545490 --- /dev/null +++ b/community-domain/src/main/kotlin/gloddy/article/ArticleErrorCode.kt @@ -0,0 +1,13 @@ +package gloddy.article + +import gloddy.core.ErrorCode + +enum class ArticleErrorCode( + override val statusCode: Int, + override val errorCode: String, + override val message: String +) : ErrorCode { + NOT_FOUND(400, "ARTICLE_001", "해당 게시글을 찾을 수 없습니다."), + IMAGE_SIZE_OVER(400, "ARTICLE_002", "게시글에 이미지는 최대 3개 입니다."), + NO_AUTHORIZATION(401, "ARTICLE_003", "해당 게시글에 권한이 없습니다.") +} \ No newline at end of file diff --git a/community-domain/src/main/kotlin/gloddy/article/ArticleLike.kt b/community-domain/src/main/kotlin/gloddy/article/ArticleLike.kt new file mode 100644 index 0000000..2239da2 --- /dev/null +++ b/community-domain/src/main/kotlin/gloddy/article/ArticleLike.kt @@ -0,0 +1,19 @@ +package gloddy.article + +import gloddy.core.UserId + +data class ArticleLike( + val userId: UserId, + val article: Article, + val id: Long? = null, +) { + init { + verifyArticle(article) + } + + private fun verifyArticle(article: Article) { + if (article.id == null) { + throw RuntimeException("ArticleLike 생성 오류 : 영속화 되지 않은 Article을 입력으로 받았습니다.") + } + } +} diff --git a/community-domain/src/main/kotlin/gloddy/article/exception/ArticleImageSizeOverException.kt b/community-domain/src/main/kotlin/gloddy/article/exception/ArticleImageSizeOverException.kt new file mode 100644 index 0000000..ca6b512 --- /dev/null +++ b/community-domain/src/main/kotlin/gloddy/article/exception/ArticleImageSizeOverException.kt @@ -0,0 +1,6 @@ +package gloddy.article.exception + +import gloddy.article.ArticleErrorCode +import gloddy.core.GloddyCommunityException + +class ArticleImageSizeOverException : GloddyCommunityException(ArticleErrorCode.IMAGE_SIZE_OVER) \ No newline at end of file diff --git a/community-domain/src/main/kotlin/gloddy/article/exception/ArticleNoAuthorizationException.kt b/community-domain/src/main/kotlin/gloddy/article/exception/ArticleNoAuthorizationException.kt new file mode 100644 index 0000000..be68c5a --- /dev/null +++ b/community-domain/src/main/kotlin/gloddy/article/exception/ArticleNoAuthorizationException.kt @@ -0,0 +1,6 @@ +package gloddy.article.exception + +import gloddy.article.ArticleErrorCode +import gloddy.core.GloddyCommunityException + +class ArticleNoAuthorizationException : GloddyCommunityException(ArticleErrorCode.NO_AUTHORIZATION) \ No newline at end of file diff --git a/community-domain/src/main/kotlin/gloddy/article/exception/ArticleNotFoundException.kt b/community-domain/src/main/kotlin/gloddy/article/exception/ArticleNotFoundException.kt new file mode 100644 index 0000000..fb69e06 --- /dev/null +++ b/community-domain/src/main/kotlin/gloddy/article/exception/ArticleNotFoundException.kt @@ -0,0 +1,6 @@ +package gloddy.article.exception + +import gloddy.article.ArticleErrorCode +import gloddy.core.GloddyCommunityException + +class ArticleNotFoundException : GloddyCommunityException(ArticleErrorCode.NOT_FOUND) \ No newline at end of file diff --git a/community-domain/src/main/kotlin/gloddy/article/vo/ArticleImage.kt b/community-domain/src/main/kotlin/gloddy/article/vo/ArticleImage.kt new file mode 100644 index 0000000..34e8cc5 --- /dev/null +++ b/community-domain/src/main/kotlin/gloddy/article/vo/ArticleImage.kt @@ -0,0 +1,18 @@ +package gloddy.article.vo + +import gloddy.article.exception.ArticleImageSizeOverException + +data class ArticleImage( + val images: List? +) { + + init { + verifySize(images) + } + + private fun verifySize(images: List?) { + if (!images.isNullOrEmpty() && images.size > 3) { + throw ArticleImageSizeOverException() + } + } +} \ No newline at end of file diff --git a/community-domain/src/main/kotlin/gloddy/category/Category.kt b/community-domain/src/main/kotlin/gloddy/category/Category.kt new file mode 100644 index 0000000..8d381b2 --- /dev/null +++ b/community-domain/src/main/kotlin/gloddy/category/Category.kt @@ -0,0 +1,8 @@ +package gloddy.category + +import gloddy.core.CategoryId + +data class Category( + var name: String, + val id: CategoryId? = null, +) diff --git a/community-domain/src/main/kotlin/gloddy/category/CategoryErrorCode.kt b/community-domain/src/main/kotlin/gloddy/category/CategoryErrorCode.kt new file mode 100644 index 0000000..56f08ad --- /dev/null +++ b/community-domain/src/main/kotlin/gloddy/category/CategoryErrorCode.kt @@ -0,0 +1,11 @@ +package gloddy.category + +import gloddy.core.ErrorCode + +enum class CategoryErrorCode( + override val statusCode: Int, + override val errorCode: String, + override val message: String +): ErrorCode { + NOT_FOUND(400, "CATEGORY_001", "해당 카테고리를 찾을 수 없습니다.") +} \ No newline at end of file diff --git a/community-domain/src/main/kotlin/gloddy/category/exception/CategoryNotFoundException.kt b/community-domain/src/main/kotlin/gloddy/category/exception/CategoryNotFoundException.kt new file mode 100644 index 0000000..a8940b1 --- /dev/null +++ b/community-domain/src/main/kotlin/gloddy/category/exception/CategoryNotFoundException.kt @@ -0,0 +1,6 @@ +package gloddy.category.exception + +import gloddy.category.CategoryErrorCode +import gloddy.core.GloddyCommunityException + +class CategoryNotFoundException : GloddyCommunityException(CategoryErrorCode.NOT_FOUND) \ No newline at end of file diff --git a/community-domain/src/main/kotlin/gloddy/core/ErrorCode.kt b/community-domain/src/main/kotlin/gloddy/core/ErrorCode.kt new file mode 100644 index 0000000..1d7f274 --- /dev/null +++ b/community-domain/src/main/kotlin/gloddy/core/ErrorCode.kt @@ -0,0 +1,7 @@ +package gloddy.core + +interface ErrorCode { + val statusCode: Int + val errorCode: String + val message: String +} \ No newline at end of file diff --git a/community-domain/src/main/kotlin/gloddy/core/GloddyCommunityException.kt b/community-domain/src/main/kotlin/gloddy/core/GloddyCommunityException.kt new file mode 100644 index 0000000..3c0541e --- /dev/null +++ b/community-domain/src/main/kotlin/gloddy/core/GloddyCommunityException.kt @@ -0,0 +1,14 @@ +package gloddy.core + +open class GloddyCommunityException(code: ErrorCode) : RuntimeException() { + + val statusCode: Int + val errorCode: String + override val message: String + + init { + statusCode = code.statusCode + errorCode = code.errorCode + message = code.message + } +} \ No newline at end of file diff --git a/community-domain/src/main/kotlin/gloddy/core/Id.kt b/community-domain/src/main/kotlin/gloddy/core/Id.kt new file mode 100644 index 0000000..fcf6f21 --- /dev/null +++ b/community-domain/src/main/kotlin/gloddy/core/Id.kt @@ -0,0 +1,16 @@ +package gloddy.core + +@JvmInline +value class UserId( + val value: Long +) + +@JvmInline +value class ArticleId( + val value: Long +) + +@JvmInline +value class CategoryId( + val value: Long +) diff --git a/community-domain/src/test/kotlin/gloddy/.gitkeep b/community-domain/src/test/kotlin/gloddy/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/community-domain/src/test/kotlin/gloddy/article/ArticleCreateTest.kt b/community-domain/src/test/kotlin/gloddy/article/ArticleCreateTest.kt new file mode 100644 index 0000000..98373b2 --- /dev/null +++ b/community-domain/src/test/kotlin/gloddy/article/ArticleCreateTest.kt @@ -0,0 +1,59 @@ +package gloddy.article + +import gloddy.article.exception.ArticleImageSizeOverException +import gloddy.article.vo.ArticleImage +import gloddy.core.UserId +import gloddy.CategoryFixture.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +@DisplayName("Article 도메인 생성을") +class ArticleCreateTest { + + companion object { + @JvmStatic + fun articleProvider() = listOf( + Arguments.of(listOf("image1")), + Arguments.of(null) + ) + } + + @ParameterizedTest + @MethodSource("articleProvider") + @DisplayName("성공한다.") + fun success(image: List?) { + + val category = K_POP.toPersistDomain(1L) + + val article = Article( + userId = UserId(1L), + category = category, + title = "title", + content = "content", + images = image + ) + + assertEquals(article.category, category) + assertEquals(article.image, ArticleImage(image)) + assertEquals(article.commentCount, 0) + assertEquals(article.likeCount, 0) + } + + @Test + @DisplayName("이미지가 3개 초과하여 실패한다.") + fun fail_by_images_size_over() { + assertThrows(ArticleImageSizeOverException::class.java) { + Article( + userId = UserId(1L), + category = K_POP.toPersistDomain(1L), + title = "title", + content = "content", + images = listOf("image1", "image2", "image3", "image4") + ) + } + } +} \ No newline at end of file diff --git a/community-domain/src/test/kotlin/gloddy/article/ArticleLikeCreateTest.kt b/community-domain/src/test/kotlin/gloddy/article/ArticleLikeCreateTest.kt new file mode 100644 index 0000000..de383b4 --- /dev/null +++ b/community-domain/src/test/kotlin/gloddy/article/ArticleLikeCreateTest.kt @@ -0,0 +1,25 @@ +package gloddy.article + +import gloddy.core.UserId +import gloddy.ArticleFixture.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("게시글 좋아요 도메인 생성을") +class ArticleLikeCreateTest { + + @Test + @DisplayName("성공한다.") + fun success() { + + val article = JIHWAN.toPersistDomain(1L, 1L) + + val articleLike = ArticleLike( + userId = UserId(1L), + article = article + ) + + assertEquals(articleLike.article, article) + } +} \ No newline at end of file diff --git a/community-domain/src/testFixtures/kotlin/gloddy/ArticleFixture.kt b/community-domain/src/testFixtures/kotlin/gloddy/ArticleFixture.kt new file mode 100644 index 0000000..56f3780 --- /dev/null +++ b/community-domain/src/testFixtures/kotlin/gloddy/ArticleFixture.kt @@ -0,0 +1,37 @@ +package gloddy + +import gloddy.article.Article +import gloddy.article.vo.ArticleImage +import gloddy.category.Category +import gloddy.core.ArticleId +import gloddy.core.UserId +import gloddy.CategoryFixture.* + +enum class ArticleFixture( + private val userId: UserId?, + private val category: Category?, + private val title: String, + private val content: String, + private val image: ArticleImage, +) { + JIHWAN(null, QNA.toPersistDomain(2L), "한국 핫플", "요즘 한국 핫플이 어디에용?", ArticleImage(null)); + + fun toDomain(userId: Long, category: Category? = null): Article = + Article( + userId = UserId(userId), + category = category ?: this.category!!, + title = this.title, + content = this.content, + image = this.image + ) + + fun toPersistDomain(userId: Long, id: Long, category: Category? = null): Article = + Article( + userId = UserId(userId), + category = category ?: this.category!!, + title = this.title, + content = this.content, + image = this.image, + id = ArticleId(id) + ) +} \ No newline at end of file diff --git a/community-domain/src/testFixtures/kotlin/gloddy/CategoryFixture.kt b/community-domain/src/testFixtures/kotlin/gloddy/CategoryFixture.kt new file mode 100644 index 0000000..28cbf18 --- /dev/null +++ b/community-domain/src/testFixtures/kotlin/gloddy/CategoryFixture.kt @@ -0,0 +1,23 @@ +package gloddy + +import gloddy.category.Category +import gloddy.core.CategoryId + +enum class CategoryFixture( + private val names: String, +) { + K_POP("K-POP"), + QNA("Q&A"), + LANGUAGE("Language"); + + fun toDomain(): Category = + Category( + name = this.names + ) + + fun toPersistDomain(id: Long): Category = + Category( + name = names, + id = CategoryId(id) + ) +} \ No newline at end of file diff --git a/community-in-api/build.gradle.kts b/community-in-api/build.gradle.kts index 4dae864..7caad09 100644 --- a/community-in-api/build.gradle.kts +++ b/community-in-api/build.gradle.kts @@ -12,6 +12,8 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") testImplementation("org.springframework.boot:spring-boot-starter-test") } diff --git a/community-in-api/src/main/kotlin/gloddy/config/SwaggerConfig.kt b/community-in-api/src/main/kotlin/gloddy/config/SwaggerConfig.kt new file mode 100644 index 0000000..6265e0d --- /dev/null +++ b/community-in-api/src/main/kotlin/gloddy/config/SwaggerConfig.kt @@ -0,0 +1,33 @@ +package gloddy.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import io.swagger.v3.oas.models.servers.Server +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class SwaggerConfig { + + @Bean + fun communityOpenAPI(): OpenAPI { + return OpenAPI() + .components(Components() + .addSecuritySchemes("JWT", securityScheme())) + .addSecurityItem(SecurityRequirement().addList("JWT")) + .addServersItem(serversItem()) + } + + private fun securityScheme(): SecurityScheme { + return SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .`in`(SecurityScheme.In.HEADER) + .name("X-AUTH-TOKEN") + } + + private fun serversItem(): Server { + return Server().url("/api/v1/communities") + } +} diff --git a/community-in-api/src/main/kotlin/gloddy/config/WebConfig.kt b/community-in-api/src/main/kotlin/gloddy/config/WebConfig.kt new file mode 100644 index 0000000..e5c2cef --- /dev/null +++ b/community-in-api/src/main/kotlin/gloddy/config/WebConfig.kt @@ -0,0 +1,21 @@ +package gloddy.config + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.CorsRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebConfig : WebMvcConfigurer { + + override fun addCorsMappings(registry: CorsRegistry) { + registry.addMapping("/**") + .allowCredentials(true) + .allowedOrigins( + "http://localhost:3000", + "https://gloddy.vercel.app", + "https://gloddy-git-develop-gueit214.vercel.app" + ) + .allowedMethods("*") + .allowedHeaders("*") + } +} \ No newline at end of file diff --git a/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleCommandController.kt b/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleCommandController.kt new file mode 100644 index 0000000..8859345 --- /dev/null +++ b/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleCommandController.kt @@ -0,0 +1,42 @@ +package gloddy.controller.article + +import gloddy.article.dto.command.ArticleCreateCommand +import gloddy.article.dto.read.ArticleIdReadData +import gloddy.article.port.`in`.ArticleCommandUseCase +import gloddy.response.CommunityApiResponse +import gloddy.response.ApiResponseEntityWrapper +import gloddy.response.created +import gloddy.response.noContent +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/communities") +class ArticleCommandController( + private val articleCommandUseCase: ArticleCommandUseCase, +) : ArticleCommandControllerDocs { + + + @PostMapping("/articles/create") + override fun create( + @RequestHeader("USER_ID") userId: Long, + @RequestBody command: ArticleCreateCommand, + ): ResponseEntity> { + val data = articleCommandUseCase.create(userId, command) + return ApiResponseEntityWrapper(data).created() + } + + @PostMapping("/articles/{articleId}/delete") + override fun delete(@RequestHeader("USER_ID") userId: Long, @PathVariable("articleId") articleId: Long) + : ResponseEntity> { + articleCommandUseCase.delete(userId, articleId) + return ApiResponseEntityWrapper().noContent() + } + + @PostMapping("/articles/{articleId}/like") + override fun like(@RequestHeader("USER_ID") userId: Long, @PathVariable("articleId") articleId: Long) + : ResponseEntity> { + articleCommandUseCase.like(userId, articleId) + return ApiResponseEntityWrapper().noContent() + } +} \ No newline at end of file diff --git a/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleCommandControllerDocs.kt b/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleCommandControllerDocs.kt new file mode 100644 index 0000000..380a55e --- /dev/null +++ b/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleCommandControllerDocs.kt @@ -0,0 +1,31 @@ +package gloddy.controller.article + +import gloddy.article.dto.command.ArticleCreateCommand +import gloddy.article.dto.read.ArticleIdReadData +import gloddy.response.CommunityApiResponse +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.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestBody + +@Tag(name = "게시글") +interface ArticleCommandControllerDocs { + + @Operation(summary = "게시글 생성") + @ApiResponse(responseCode = "201", description = "게시글 생성 성공") + fun create(@Parameter(hidden = true) userId: Long, @RequestBody command: ArticleCreateCommand) + : ResponseEntity> + + @Operation(summary = "게시글 삭제") + @ApiResponse(responseCode = "204", description = "게시글 삭제 성공") + fun delete(@Parameter(hidden = true) userId: Long, @PathVariable("articleId") articleId: Long) + : ResponseEntity> + + @Operation(summary = "게시글 좋아요 및 좋아요 취소") + @ApiResponse(responseCode = "204", description = "게시글 좋아요 및 좋아요 취소 성공") + fun like(@Parameter(hidden = true) userId: Long, @PathVariable("articleId") articleId: Long) + : ResponseEntity> +} \ No newline at end of file diff --git a/community-in-api/src/main/kotlin/gloddy/controller/category/CategoryQueryController.kt b/community-in-api/src/main/kotlin/gloddy/controller/category/CategoryQueryController.kt new file mode 100644 index 0000000..e199d18 --- /dev/null +++ b/community-in-api/src/main/kotlin/gloddy/controller/category/CategoryQueryController.kt @@ -0,0 +1,26 @@ +package gloddy.controller.category + +import gloddy.category.port.dto.CategoryReadData +import gloddy.category.port.`in`.CategoryQueryUseCase +import gloddy.response.CommunityApiResponse +import gloddy.response.ApiResponseEntityWrapper +import gloddy.response.ok +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/communities") +class CategoryQueryController( + private val categoryQueryUseCase: CategoryQueryUseCase, +) : CategoryQueryControllerDocs { + + + @GetMapping("/categories") + override fun getAll(@RequestHeader("USER_ID") userId: Long): ResponseEntity>> { + val data = categoryQueryUseCase.getAll() + return ApiResponseEntityWrapper(data).ok() + } +} \ No newline at end of file diff --git a/community-in-api/src/main/kotlin/gloddy/controller/category/CategoryQueryControllerDocs.kt b/community-in-api/src/main/kotlin/gloddy/controller/category/CategoryQueryControllerDocs.kt new file mode 100644 index 0000000..4650fc0 --- /dev/null +++ b/community-in-api/src/main/kotlin/gloddy/controller/category/CategoryQueryControllerDocs.kt @@ -0,0 +1,17 @@ +package gloddy.controller.category + +import gloddy.category.port.dto.CategoryReadData +import gloddy.response.CommunityApiResponse +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.tags.Tag +import org.springframework.http.ResponseEntity + +@Tag(name = "카테고리") +interface CategoryQueryControllerDocs { + + @Operation(summary = "카테고리 전체 조회") + @ApiResponse(responseCode = "200", description = "카테고리 전체 조회 성공") + fun getAll(@Parameter(hidden = true) userId: Long) : ResponseEntity>> +} \ No newline at end of file diff --git a/community-in-api/src/main/kotlin/gloddy/exception/CommunityControllerAdvice.kt b/community-in-api/src/main/kotlin/gloddy/exception/CommunityControllerAdvice.kt new file mode 100644 index 0000000..df8923e --- /dev/null +++ b/community-in-api/src/main/kotlin/gloddy/exception/CommunityControllerAdvice.kt @@ -0,0 +1,32 @@ +package gloddy.exception + +import gloddy.core.GloddyCommunityException +import gloddy.response.ApiResponseEntityWrapper +import gloddy.response.CommunityApiResponse +import gloddy.response.fail +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice(basePackages = ["gloddy"]) +class CommunityControllerAdvice { + + companion object { + private const val INTERNAL_SERVER_ERROR_CODE = 500 + private const val INTERNAL_SERVER_ERROR_MESSAGE = "서버 내부 오류입니다." + private val logger = LoggerFactory.getLogger(CommunityControllerAdvice::class.java) + } + + @ExceptionHandler(GloddyCommunityException::class) + fun handleGloddyCummunityException(e: GloddyCommunityException): ResponseEntity> { + logger.error("Community Error\n{}", e.message, e) + return ApiResponseEntityWrapper().fail(e.statusCode, e.message) + } + + @ExceptionHandler(Exception::class) + fun handleInternalServerError(e: Exception): ResponseEntity> { + logger.error("Community Internal Error\n{}", e.message, e) + return ApiResponseEntityWrapper().fail(INTERNAL_SERVER_ERROR_CODE, INTERNAL_SERVER_ERROR_MESSAGE) + } +} \ No newline at end of file diff --git a/community-in-api/src/main/kotlin/gloddy/response/ApiResponseEntityWrapper.kt b/community-in-api/src/main/kotlin/gloddy/response/ApiResponseEntityWrapper.kt new file mode 100644 index 0000000..5954275 --- /dev/null +++ b/community-in-api/src/main/kotlin/gloddy/response/ApiResponseEntityWrapper.kt @@ -0,0 +1,23 @@ +package gloddy.response + +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity + +class ApiResponseEntityWrapper( + val data: T? = null +) { + fun getResponseEntity(statusCode: Int, message: String? = null): ResponseEntity> = + ResponseEntity.status(statusCode) + .body(CommunityApiResponse( + meta = CommunityApiResponse.Meta( + statusCode = statusCode, + message = message + ), + data = this.data + )) +} + +fun ApiResponseEntityWrapper.ok(): ResponseEntity> = this.getResponseEntity(HttpStatus.OK.value()) +fun ApiResponseEntityWrapper.created(): ResponseEntity> = this.getResponseEntity(HttpStatus.CREATED.value()) +fun ApiResponseEntityWrapper.noContent(): ResponseEntity> = this.getResponseEntity(HttpStatus.NO_CONTENT.value()) +fun ApiResponseEntityWrapper.fail(statusCode: Int, message: String) = this.getResponseEntity(statusCode, message) diff --git a/community-in-api/src/main/kotlin/gloddy/response/CommunityApiResponse.kt b/community-in-api/src/main/kotlin/gloddy/response/CommunityApiResponse.kt new file mode 100644 index 0000000..3b51d74 --- /dev/null +++ b/community-in-api/src/main/kotlin/gloddy/response/CommunityApiResponse.kt @@ -0,0 +1,11 @@ +package gloddy.response + +data class CommunityApiResponse( + val meta: Meta, + val data: T? +) { + data class Meta( + val statusCode: Int, + val message: String? + ) +} \ No newline at end of file diff --git a/community-infrastructure/build.gradle.kts b/community-infrastructure/build.gradle.kts index f31f475..1f8cff6 100644 --- a/community-infrastructure/build.gradle.kts +++ b/community-infrastructure/build.gradle.kts @@ -1,5 +1,9 @@ import org.springframework.boot.gradle.tasks.bundling.BootJar +plugins { + kotlin("plugin.jpa") version "1.9.22" +} + val jar: Jar by tasks val bootJar: BootJar by tasks @@ -16,6 +20,7 @@ dependencies { runtimeOnly("com.h2database:h2") runtimeOnly("com.mysql:mysql-connector-j") implementation("org.springframework.cloud:spring-cloud-starter-openfeign") + testImplementation(testFixtures(project(":community-domain"))) testImplementation("org.springframework.boot:spring-boot-starter-test") } @@ -24,3 +29,10 @@ dependencyManagement { mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}") } } + +allOpen { + annotation("jakarta.persistence.Entity") + annotation("jakarta.persistence.Embeddable") + annotation("jakarta.persistence.MappedSuperclass") +} + diff --git a/community-infrastructure/src/main/kotlin/gloddy/.gitkeep b/community-infrastructure/src/main/kotlin/gloddy/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/article/ArticleJpaEntity.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/ArticleJpaEntity.kt new file mode 100644 index 0000000..1c20eec --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/ArticleJpaEntity.kt @@ -0,0 +1,42 @@ +package gloddy.persistence.article + +import gloddy.persistence.category.CategoryJpaEntity +import gloddy.persistence.common.BaseTimeEntity +import gloddy.persistence.util.converter.StringArrayConverter +import jakarta.persistence.* +import org.hibernate.annotations.SQLRestriction + +@Entity +@Table(name = "article") +@SQLRestriction("deleted = false") +class ArticleJpaEntity( + + val userId: Long, + + @field:ManyToOne(fetch = FetchType.LAZY) + @field:JoinColumn(name = "category_id") + var category: CategoryJpaEntity, + + var title: String, + + @field:Column(name = "content", columnDefinition = "longtext") + var content: String, + + @field:Convert(converter = StringArrayConverter::class) + @field:Column(name = "images", columnDefinition = "longtext") + var images: List?, + + var commentCount: Int, + + var likeCount: Int, + + var deleted: Boolean = false, + + @field:Id + @field:GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, +) : BaseTimeEntity() { + fun changeDeletedToTrue() { + this.deleted = true + } +} \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/article/ArticleLikeJpaEntity.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/ArticleLikeJpaEntity.kt new file mode 100644 index 0000000..931504a --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/ArticleLikeJpaEntity.kt @@ -0,0 +1,17 @@ +package gloddy.persistence.article + +import gloddy.persistence.common.BaseTimeEntity +import jakarta.persistence.* + +@Entity +@Table(name = "article_like") +class ArticleLikeJpaEntity( + val userId: Long, + @field:ManyToOne(fetch = FetchType.LAZY) + @field:JoinColumn(name = "article_id") + val article: ArticleJpaEntity, + @field:Id + @field:GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null +) : BaseTimeEntity() { +} \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleCommandPersistenceAdapter.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleCommandPersistenceAdapter.kt new file mode 100644 index 0000000..bdaca04 --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleCommandPersistenceAdapter.kt @@ -0,0 +1,32 @@ +package gloddy.persistence.article.adapter + +import gloddy.article.Article +import gloddy.article.exception.ArticleNotFoundException +import gloddy.article.port.out.ArticleCommandPersistencePort +import gloddy.persistence.article.ArticleJpaEntity +import gloddy.persistence.article.repository.ArticleJpaRepository +import gloddy.persistence.util.mapper.toDomain +import gloddy.persistence.util.mapper.toEntity +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +@Transactional +class ArticleCommandPersistenceAdapter( + private val articleJpaRepository: ArticleJpaRepository +) : ArticleCommandPersistencePort { + + override fun save(article: Article): Article { + return articleJpaRepository.save(article.toEntity()).toDomain() + } + + override fun delete(id: Long) { + val articleJpaEntity = find(id) + articleJpaEntity.changeDeletedToTrue() + } + + fun find(id: Long): ArticleJpaEntity { + return articleJpaRepository.findByIdOrNull(id) ?: throw ArticleNotFoundException() + } +} \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleLikeCommandPersistenceAdapter.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleLikeCommandPersistenceAdapter.kt new file mode 100644 index 0000000..a0522fb --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleLikeCommandPersistenceAdapter.kt @@ -0,0 +1,24 @@ +package gloddy.persistence.article.adapter + +import gloddy.article.ArticleLike +import gloddy.article.port.out.ArticleLikeCommandPersistencePort +import gloddy.persistence.article.repository.ArticleLikeJpaRepository +import gloddy.persistence.util.mapper.toDomain +import gloddy.persistence.util.mapper.toEntity +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +@Transactional +class ArticleLikeCommandPersistenceAdapter( + private val articleLikeJpaRepository: ArticleLikeJpaRepository +) : ArticleLikeCommandPersistencePort { + + override fun save(articleLike: ArticleLike): ArticleLike { + return articleLikeJpaRepository.save(articleLike.toEntity()).toDomain() + } + + override fun delete(articleLike: ArticleLike) { + articleLikeJpaRepository.delete(articleLike.toEntity()) + } +} \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleLikeQueryPersistenceAdapter.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleLikeQueryPersistenceAdapter.kt new file mode 100644 index 0000000..cb63c8e --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleLikeQueryPersistenceAdapter.kt @@ -0,0 +1,19 @@ +package gloddy.persistence.article.adapter + +import gloddy.article.Article +import gloddy.article.ArticleLike +import gloddy.article.port.out.ArticleLikeQueryPersistencePort +import gloddy.persistence.article.repository.ArticleLikeJpaRepository +import gloddy.persistence.util.mapper.toDomain +import gloddy.persistence.util.mapper.toEntity +import org.springframework.stereotype.Component + +@Component +class ArticleLikeQueryPersistenceAdapter( + private val articleLikeJpaRepository: ArticleLikeJpaRepository, +) : ArticleLikeQueryPersistencePort { + + override fun findByUserIdAndArticleOrNull(userId: Long, article: Article): ArticleLike? { + return articleLikeJpaRepository.findByUserIdAndArticle(userId, article.toEntity())?.toDomain() + } +} \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleQueryPersistenceAdapter.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleQueryPersistenceAdapter.kt new file mode 100644 index 0000000..8bbe04b --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleQueryPersistenceAdapter.kt @@ -0,0 +1,19 @@ +package gloddy.persistence.article.adapter + +import gloddy.article.Article +import gloddy.article.exception.ArticleNotFoundException +import gloddy.article.port.out.ArticleQueryPersistencePort +import gloddy.persistence.article.repository.ArticleJpaRepository +import gloddy.persistence.util.mapper.toDomain +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Component + +@Component +class ArticleQueryPersistenceAdapter( + private val articleJpaRepository: ArticleJpaRepository, +) : ArticleQueryPersistencePort { + + override fun findById(id: Long): Article { + return (articleJpaRepository.findByIdOrNull(id) ?: throw ArticleNotFoundException()).toDomain() + } +} \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/article/repository/ArticleJpaRepository.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/repository/ArticleJpaRepository.kt new file mode 100644 index 0000000..cb6af88 --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/repository/ArticleJpaRepository.kt @@ -0,0 +1,7 @@ +package gloddy.persistence.article.repository + +import gloddy.persistence.article.ArticleJpaEntity +import org.springframework.data.jpa.repository.JpaRepository + +interface ArticleJpaRepository : JpaRepository { +} \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/article/repository/ArticleLikeJpaRepository.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/repository/ArticleLikeJpaRepository.kt new file mode 100644 index 0000000..a171c3d --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/repository/ArticleLikeJpaRepository.kt @@ -0,0 +1,9 @@ +package gloddy.persistence.article.repository + +import gloddy.persistence.article.ArticleJpaEntity +import gloddy.persistence.article.ArticleLikeJpaEntity +import org.springframework.data.jpa.repository.JpaRepository + +interface ArticleLikeJpaRepository : JpaRepository { + fun findByUserIdAndArticle(userId: Long, article: ArticleJpaEntity): ArticleLikeJpaEntity? +} \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/category/CategoryJpaEntity.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/category/CategoryJpaEntity.kt new file mode 100644 index 0000000..dd729d1 --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/category/CategoryJpaEntity.kt @@ -0,0 +1,15 @@ +package gloddy.persistence.category + +import jakarta.persistence.* + +@Entity +@Table(name = "category") +class CategoryJpaEntity( + + var name: String, + + @field:Id + @field:GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, +) { +} \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/category/adapter/CategoryQueryPersistenceAdapter.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/category/adapter/CategoryQueryPersistenceAdapter.kt new file mode 100644 index 0000000..eead95f --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/category/adapter/CategoryQueryPersistenceAdapter.kt @@ -0,0 +1,25 @@ +package gloddy.persistence.category.adapter + +import gloddy.category.Category +import gloddy.category.exception.CategoryNotFoundException +import gloddy.category.port.out.CategoryQueryPersistencePort +import gloddy.core.CategoryId +import gloddy.persistence.category.repository.CategoryJpaRepository +import gloddy.persistence.util.mapper.toDomain +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Component + +@Component +class CategoryQueryPersistenceAdapter( + private val categoryJpaRepository: CategoryJpaRepository, +) : CategoryQueryPersistencePort { + + override fun findById(id: CategoryId): Category { + return (categoryJpaRepository.findByIdOrNull(id.value) ?: throw CategoryNotFoundException()).toDomain() + } + + override fun findAll(): List { + return categoryJpaRepository.findAll() + .map { it.toDomain() } + } +} \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/category/repository/CategoryJpaRepository.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/category/repository/CategoryJpaRepository.kt new file mode 100644 index 0000000..6dbeb51 --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/category/repository/CategoryJpaRepository.kt @@ -0,0 +1,7 @@ +package gloddy.persistence.category.repository + +import gloddy.persistence.category.CategoryJpaEntity +import org.springframework.data.jpa.repository.JpaRepository + +interface CategoryJpaRepository : JpaRepository { +} \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/common/BaseTimeEntity.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/common/BaseTimeEntity.kt new file mode 100644 index 0000000..b32dc5e --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/common/BaseTimeEntity.kt @@ -0,0 +1,21 @@ +package gloddy.persistence.common + +import jakarta.persistence.Column +import jakarta.persistence.EntityListeners +import jakarta.persistence.MappedSuperclass +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.LocalDateTime + +@MappedSuperclass +@EntityListeners(AuditingEntityListener::class) +open class BaseTimeEntity( + @Column(name = "created_at") + @CreatedDate + var createdAt: LocalDateTime? = null, + @Column(name = "updated_at") + @LastModifiedDate + var updatedAt: LocalDateTime? = null +) { +} \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/config/JpaConfig.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/config/JpaConfig.kt new file mode 100644 index 0000000..b406d23 --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/config/JpaConfig.kt @@ -0,0 +1,9 @@ +package gloddy.persistence.config + +import org.springframework.context.annotation.Configuration +import org.springframework.data.jpa.repository.config.EnableJpaAuditing + +@Configuration +@EnableJpaAuditing +class JpaConfig { +} \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/util/converter/StringArrayConverter.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/util/converter/StringArrayConverter.kt new file mode 100644 index 0000000..eeedbda --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/util/converter/StringArrayConverter.kt @@ -0,0 +1,28 @@ +package gloddy.persistence.util.converter + +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter + +@Converter +class StringArrayConverter : AttributeConverter, String> { + + companion object { + private const val DELIMITER = "," + } + + override fun convertToDatabaseColumn(attribute: List?): String? { + if (attribute.isNullOrEmpty()) { + return null + } + return attribute.joinToString(DELIMITER) + } + + override fun convertToEntityAttribute(dbData: String?): List { + if (dbData.isNullOrEmpty()) { + return emptyList() + } + return dbData.split(DELIMITER) + .filter { it.isNotEmpty() } + .toList() + } +} \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/util/mapper/ArticleMapper.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/util/mapper/ArticleMapper.kt new file mode 100644 index 0000000..ee4ca61 --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/util/mapper/ArticleMapper.kt @@ -0,0 +1,47 @@ +package gloddy.persistence.util.mapper + +import gloddy.article.Article +import gloddy.article.ArticleLike +import gloddy.article.vo.ArticleImage +import gloddy.core.ArticleId +import gloddy.core.UserId +import gloddy.persistence.article.ArticleJpaEntity +import gloddy.persistence.article.ArticleLikeJpaEntity + +fun Article.toEntity() : ArticleJpaEntity = + ArticleJpaEntity( + userId = this.userId.value, + category = this.category.toEntity(), + title = this.title, + content = this.content, + images = this.image.images, + commentCount = this.commentCount, + likeCount = this.likeCount, + id = this.id?.value + ) + +fun ArticleJpaEntity.toDomain() : Article = + Article( + userId = UserId(this.userId), + category = this.category.toDomain(), + title = this.title, + content = this.content, + image = ArticleImage(this.images), + commentCount = this.commentCount, + likeCount = this.likeCount, + id = ArticleId(this.id!!) + ) + +fun ArticleLike.toEntity() : ArticleLikeJpaEntity = + ArticleLikeJpaEntity( + userId = this.userId.value, + article = this.article.toEntity(), + id = this.id + ) + +fun ArticleLikeJpaEntity.toDomain() : ArticleLike = + ArticleLike( + userId = UserId(this.userId), + article = this.article.toDomain(), + id = this.id + ) \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/util/mapper/CategoryMapper.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/util/mapper/CategoryMapper.kt new file mode 100644 index 0000000..ba5777e --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/util/mapper/CategoryMapper.kt @@ -0,0 +1,17 @@ +package gloddy.persistence.util.mapper + +import gloddy.category.Category +import gloddy.core.CategoryId +import gloddy.persistence.category.CategoryJpaEntity + +fun Category.toEntity() : CategoryJpaEntity = + CategoryJpaEntity( + name = this.name, + id = this.id?.value + ) + +fun CategoryJpaEntity.toDomain() : Category = + Category( + name = this.name, + id = CategoryId(this.id!!) + ) \ No newline at end of file diff --git a/community-infrastructure/src/main/resources/application-jpa.yml b/community-infrastructure/src/main/resources/application-jpa.yml deleted file mode 100644 index 8b13789..0000000 --- a/community-infrastructure/src/main/resources/application-jpa.yml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/community-infrastructure/src/main/resources/application-persistence.yml b/community-infrastructure/src/main/resources/application-persistence.yml new file mode 100644 index 0000000..24ff877 --- /dev/null +++ b/community-infrastructure/src/main/resources/application-persistence.yml @@ -0,0 +1,18 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${COMMUNITY_DATASOURCE_USERNAME} + password: ${COMMUNITY_DATASOURCE_PASSWORD} + url: ${COMMUNITY_DATASOURCE_URL} + + jpa: + hibernate: + ddl-auto: none + naming: + physical-strategy: org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy + show-sql: ${SHOW_SQL} + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + default_batch_fetch_size: 50 + format_sql: true diff --git a/community-infrastructure/src/test/kotlin/gloddy/.gitkeep b/community-infrastructure/src/test/kotlin/gloddy/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/community-infrastructure/src/test/kotlin/gloddy/InfrastructureTestContextLoader.kt b/community-infrastructure/src/test/kotlin/gloddy/InfrastructureTestContextLoader.kt new file mode 100644 index 0000000..dcb7ec6 --- /dev/null +++ b/community-infrastructure/src/test/kotlin/gloddy/InfrastructureTestContextLoader.kt @@ -0,0 +1,8 @@ +package gloddy + +import org.springframework.boot.autoconfigure.SpringBootApplication + +@SpringBootApplication +class InfrastructureTestContextLoader { + fun contextLoads() {} +} diff --git a/community-infrastructure/src/test/kotlin/gloddy/PersistenceTest.kt b/community-infrastructure/src/test/kotlin/gloddy/PersistenceTest.kt new file mode 100644 index 0000000..cf35018 --- /dev/null +++ b/community-infrastructure/src/test/kotlin/gloddy/PersistenceTest.kt @@ -0,0 +1,44 @@ +package gloddy + +import gloddy.category.Category +import gloddy.persistence.article.repository.ArticleJpaRepository +import gloddy.persistence.article.repository.ArticleLikeJpaRepository +import gloddy.persistence.category.repository.CategoryJpaRepository +import gloddy.persistence.util.mapper.toDomain +import gloddy.persistence.util.mapper.toEntity +import jakarta.persistence.EntityManager +import org.junit.jupiter.api.BeforeEach +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.test.context.ActiveProfiles + +@DataJpaTest +@ActiveProfiles("test") +abstract class PersistenceTest { + + protected lateinit var CATEGORY: List + @Autowired + protected lateinit var categoryJpaRepository: CategoryJpaRepository + @Autowired + protected lateinit var articleJpaRepository: ArticleJpaRepository + @Autowired + protected lateinit var articleLikeJpaRepository: ArticleLikeJpaRepository + @Autowired + protected lateinit var em: EntityManager + + @BeforeEach + fun init() { + val categories = categoryJpaRepository.saveAll( + CategoryFixture.values().map { + it.toDomain().toEntity() + } + ) + CATEGORY = categories.map { it.toDomain() } + flushAndClear() + } + + protected fun flushAndClear() { + em.flush() + em.clear() + } +} \ No newline at end of file diff --git a/community-infrastructure/src/test/kotlin/gloddy/article/ArticleCommandPersistenceAdapterTest.kt b/community-infrastructure/src/test/kotlin/gloddy/article/ArticleCommandPersistenceAdapterTest.kt new file mode 100644 index 0000000..be5d6b4 --- /dev/null +++ b/community-infrastructure/src/test/kotlin/gloddy/article/ArticleCommandPersistenceAdapterTest.kt @@ -0,0 +1,72 @@ +package gloddy.article + +import gloddy.ArticleFixture +import gloddy.PersistenceTest +import gloddy.article.exception.ArticleNotFoundException +import gloddy.persistence.article.adapter.ArticleCommandPersistenceAdapter +import gloddy.persistence.util.mapper.toEntity +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.data.repository.findByIdOrNull +import kotlin.properties.Delegates + +@DisplayName("ArticleCommandPersistenceAdapterTest의") +class ArticleCommandPersistenceAdapterTest : PersistenceTest() { + + private val USER_ID = 1L + private lateinit var article: Article + + private lateinit var articleCommandPersistenceAdapter: ArticleCommandPersistenceAdapter + + @BeforeEach + fun setUp() { + article = ArticleFixture.JIHWAN.toDomain(USER_ID, CATEGORY[0]) + articleCommandPersistenceAdapter = ArticleCommandPersistenceAdapter(articleJpaRepository) + } + + @Nested + @DisplayName("save 메소드는") + inner class Save { + + @Test + @DisplayName("Article 도메인 입력을 받으면 DB에 저장하고 반환한다.") + fun success_save_and_returns() { + //given & when + val savedArticle = articleCommandPersistenceAdapter.save(article) + + //then + assertNotNull(savedArticle.id) + assertEquals(savedArticle.userId.value, USER_ID) + assertEquals(savedArticle.commentCount, 0) + assertEquals(savedArticle.likeCount, 0) + } + } + + @Nested + @DisplayName("delete 메소드는") + inner class Delete { + + private var articleId by Delegates.notNull() + + @BeforeEach + fun given() { + val saveArticle = articleJpaRepository.save(article.toEntity()) + articleId = saveArticle.id!! + flushAndClear() + } + + @Test + @DisplayName("articleId를 입력 받으면 해당 Article을 DB에서 조회하고 deleted 필드를 true로 변경 후 DB에 반영한다.") + fun success_delete() { + //when + articleCommandPersistenceAdapter.delete(articleId) + flushAndClear() + + //then + assertThrows(ArticleNotFoundException::class.java) { articleCommandPersistenceAdapter.find(articleId) } + } + } +} \ No newline at end of file diff --git a/community-infrastructure/src/test/kotlin/gloddy/article/ArticleLikeCommandPersistenceAdapterTest.kt b/community-infrastructure/src/test/kotlin/gloddy/article/ArticleLikeCommandPersistenceAdapterTest.kt new file mode 100644 index 0000000..f682d2d --- /dev/null +++ b/community-infrastructure/src/test/kotlin/gloddy/article/ArticleLikeCommandPersistenceAdapterTest.kt @@ -0,0 +1,80 @@ +package gloddy.article + +import gloddy.ArticleFixture.* +import gloddy.PersistenceTest +import gloddy.core.UserId +import gloddy.persistence.article.adapter.ArticleLikeCommandPersistenceAdapter +import gloddy.persistence.util.mapper.toDomain +import gloddy.persistence.util.mapper.toEntity +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.data.repository.findByIdOrNull + +@DisplayName("ArticleLikeCommandPersistenceAdapter 클래스의") +class ArticleLikeCommandPersistenceAdapterTest: PersistenceTest() { + + private val USER_ID = 1L + private lateinit var persistedArticle: Article + + private lateinit var articleLikeCommandPersistenceAdapter: ArticleLikeCommandPersistenceAdapter + + @BeforeEach + fun setUp() { + persistedArticle = articleJpaRepository.save(JIHWAN.toDomain(USER_ID, CATEGORY[0]).toEntity()).toDomain() + articleLikeCommandPersistenceAdapter = ArticleLikeCommandPersistenceAdapter(articleLikeJpaRepository) + } + + @Nested + @DisplayName("save 메소드는") + inner class Save { + + @Test + @DisplayName("ArticleLike 도메인을 입력받고 DB에 저장하고 Id가 존재하는 ArticleLike를 반환한다.") + fun success_save_and_returns() { + //given + val articleLike = ArticleLike( + userId = UserId(USER_ID), + article = persistedArticle + ) + + //when + val saveArticleLike = articleLikeCommandPersistenceAdapter.save(articleLike) + + //then + assertNotNull(saveArticleLike.id) + assertEquals(saveArticleLike.userId.value, USER_ID) + assertEquals(saveArticleLike.article, persistedArticle) + } + } + + @Nested + @DisplayName("delete 메소드는") + inner class Delete { + + private lateinit var persistedArticleLike: ArticleLike + + @BeforeEach + fun given() { + persistedArticleLike = articleLikeCommandPersistenceAdapter.save( + ArticleLike( + userId = UserId(USER_ID), + article = persistedArticle + ) + ) + flushAndClear() + } + + @Test + @DisplayName("영속화 된 ArticleLike를 입력받고 DB에서 삭제한다.") + fun success_delete() { + //when + articleLikeCommandPersistenceAdapter.delete(persistedArticleLike) + + //then + assertNull(articleLikeJpaRepository.findByIdOrNull(persistedArticleLike.id!!)) + } + } +} \ No newline at end of file diff --git a/community-infrastructure/src/test/kotlin/gloddy/article/ArticleLikeQueryPersistenceTest.kt b/community-infrastructure/src/test/kotlin/gloddy/article/ArticleLikeQueryPersistenceTest.kt new file mode 100644 index 0000000..f73705e --- /dev/null +++ b/community-infrastructure/src/test/kotlin/gloddy/article/ArticleLikeQueryPersistenceTest.kt @@ -0,0 +1,62 @@ +package gloddy.article + +import gloddy.ArticleFixture +import gloddy.PersistenceTest +import gloddy.core.UserId +import gloddy.persistence.article.adapter.ArticleLikeQueryPersistenceAdapter +import gloddy.persistence.util.mapper.toDomain +import gloddy.persistence.util.mapper.toEntity +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +@DisplayName("ArticleLikeQueryPersistence 클래스의") +class ArticleLikeQueryPersistenceTest: PersistenceTest() { + + private val USER_ID = 1L + private lateinit var persistedArticle: Article + + private lateinit var articleLikeQueryPersistenceAdapter: ArticleLikeQueryPersistenceAdapter + + @BeforeEach + fun setUp() { + persistedArticle = articleJpaRepository.save(ArticleFixture.JIHWAN.toDomain(USER_ID, CATEGORY[0]).toEntity()).toDomain() + articleLikeQueryPersistenceAdapter = ArticleLikeQueryPersistenceAdapter(articleLikeJpaRepository) + } + + @Nested + @DisplayName("findByUserIdAndArticleOrNull 메소드는") + inner class FindByUserIdAndArticleOrNull { + + @Test + @DisplayName("ArticleLike가 DB에 존재하면 반환한다.") + fun success_returns_domain_when_exist() { + //given + articleLikeJpaRepository.save(ArticleLike( + userId = UserId(USER_ID), + article = persistedArticle + ).toEntity()) + + //when + val articleLike = + articleLikeQueryPersistenceAdapter.findByUserIdAndArticleOrNull(USER_ID, persistedArticle) + + //then + assertNotNull(articleLike) + assertEquals(articleLike!!.article, persistedArticle) + } + + @Test + @DisplayName("ArticleLike가 DB에 존재하지 않으면 null을 반환한다.") + fun success_returns_null_when_no_exist() { + //when + val articleLike = + articleLikeQueryPersistenceAdapter.findByUserIdAndArticleOrNull(USER_ID, persistedArticle) + + //then + assertNull(articleLike) + } + } +} \ No newline at end of file diff --git a/community-infrastructure/src/test/resources/application-test.yml b/community-infrastructure/src/test/resources/application-test.yml new file mode 100644 index 0000000..a51b998 --- /dev/null +++ b/community-infrastructure/src/test/resources/application-test.yml @@ -0,0 +1,15 @@ +spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:community;MODE=MYSQL;DATABASE_TO_UPPER=false + username: sa + password: + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + format_sql: true + show-sql: true \ No newline at end of file