diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/controller/ArticleController.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/controller/ArticleController.kt index c9cbad9..cdef66b 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/controller/ArticleController.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/controller/ArticleController.kt @@ -61,7 +61,7 @@ class ArticleController( "제목·본문·대표 이미지(선택)로 새 문서를 만든다. `multipart/form-data`로 전송하며 이미지는 최대 10MB. " + "생성된 문서는 리비전 1로 히스토리에 기록되고, 제목은 이후 수정할 수 없다. " + "로그인한 상태로 만들면 생성자가 기록되어 내 기여 통계의 '새로 만든 문서 수'에 반영된다. " + - "제목은 앞뒤 공백 제거·유니코드(NFC) 정규화·연속 공백 축소 후 저장되며, 대소문자 구분 없이 유일해야 한다.", + "`categoryId`로 분류를 지정할 수 있다(선택). 제목은 앞뒤 공백 제거·유니코드(NFC) 정규화·연속 공백 축소 후 저장되며, 대소문자 구분 없이 유일해야 한다.", ) @ApiResponses( value = [ @@ -182,7 +182,8 @@ class ArticleController( description = "문서 본문을 교체하고, 이미지를 함께 보내면 대표 이미지도 교체한다(안 보내면 기존 이미지 유지). " + "로그인한 학생 계정만 가능하며 수정할 때마다 새 리비전이 생성된다. 제목은 수정할 수 없다. " + - "편집 요약(`summary`, 최대 500자)과 가벼운 수정 여부(`minorEdit`)를 선택적으로 함께 보낼 수 있다. " + + "편집 요약(`summary`, 최대 500자)과 가벼운 수정 여부(`minorEdit`)를 선택적으로 함께 보낼 수 있고, " + + "`categoryId`를 보내면 분류가 바뀐다(생략하면 유지). " + "`multipart/form-data`로 전송하며 이미지는 최대 10MB.", ) @ApiResponses( diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/request/CreateArticleReqDto.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/request/CreateArticleReqDto.kt index 9d594a5..f733e4b 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/request/CreateArticleReqDto.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/request/CreateArticleReqDto.kt @@ -15,4 +15,6 @@ data class CreateArticleReqDto( val content: String, @field:Schema(description = "대표 이미지 파일 (선택, 최대 10MB)") val image: MultipartFile? = null, + @field:Schema(description = "문서 분류 ID (선택). 분류 목록 API로 확인한다.", example = "1") + val categoryId: Long? = null, ) diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/request/UpdateArticleReqDto.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/request/UpdateArticleReqDto.kt index 3310f0c..9d63e8b 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/request/UpdateArticleReqDto.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/request/UpdateArticleReqDto.kt @@ -18,4 +18,6 @@ data class UpdateArticleReqDto( val summary: String? = null, @field:Schema(description = "가벼운 수정 여부 (선택, 기본 false)", example = "false") val minorEdit: Boolean = false, + @field:Schema(description = "바꿀 문서 분류 ID (선택, 생략하면 기존 분류 유지). 분류를 비우는 기능은 지원하지 않는다.", example = "1") + val categoryId: Long? = null, ) diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/response/ArticleResDto.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/response/ArticleResDto.kt index 4251097..fafdf53 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/response/ArticleResDto.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/response/ArticleResDto.kt @@ -2,6 +2,7 @@ package io.github.wntopia.gikipedia.server.domain.article.dto.response import io.github.wntopia.gikipedia.server.domain.article.entity.ArticleJpaEntity import io.github.wntopia.gikipedia.server.domain.article.entity.ArticleMongoEntity +import io.github.wntopia.gikipedia.server.domain.category.dto.response.CategoryResDto import io.swagger.v3.oas.annotations.media.Schema import java.time.Instant @@ -18,6 +19,8 @@ data class ArticleResDto( val createdAt: Instant?, @field:Schema(description = "마지막 수정 시각 (ISO-8601, UTC)") val updatedAt: Instant?, + @field:Schema(description = "문서 분류 (분류가 없으면 null)") + val category: CategoryResDto? = null, ) { companion object { fun from(entity: ArticleJpaEntity): ArticleResDto = @@ -28,6 +31,7 @@ data class ArticleResDto( imageUrl = entity.imageUrl, createdAt = entity.createdAt, updatedAt = entity.updatedAt, + category = entity.category?.let { CategoryResDto(requireNotNull(it.id), it.name) }, ) fun from(entity: ArticleMongoEntity): ArticleResDto = @@ -38,6 +42,7 @@ data class ArticleResDto( imageUrl = entity.imageUrl, createdAt = entity.articleCreatedAt, updatedAt = entity.articleUpdatedAt, + category = entity.categoryId?.let { id -> entity.categoryName?.let { CategoryResDto(id, it) } }, ) } } diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/entity/ArticleJpaEntity.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/entity/ArticleJpaEntity.kt index 8f87129..54a06ff 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/entity/ArticleJpaEntity.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/entity/ArticleJpaEntity.kt @@ -1,5 +1,6 @@ package io.github.wntopia.gikipedia.server.domain.article.entity +import io.github.wntopia.gikipedia.server.domain.category.entity.CategoryJpaEntity import io.github.wntopia.gikipedia.server.global.entity.BaseJpaEntity import jakarta.persistence.Column import jakarta.persistence.Entity @@ -42,6 +43,15 @@ class ArticleJpaEntity( var imageUrl: String? = imageUrl protected set + @field:ManyToOne(fetch = FetchType.LAZY) + @field:JoinColumn(name = "category_id") + var category: CategoryJpaEntity? = null + protected set + + fun changeCategory(category: CategoryJpaEntity?) { + this.category = category + } + /** title은 생성 시점에만 정해지고 이후 수정 불가능하다 — 여기서 받지 않는다. */ fun update( content: String, diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/entity/ArticleMongoEntity.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/entity/ArticleMongoEntity.kt index 9f4fe26..aff90f4 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/entity/ArticleMongoEntity.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/entity/ArticleMongoEntity.kt @@ -23,4 +23,6 @@ class ArticleMongoEntity( @field:Field("article_updated_at") val articleUpdatedAt: Instant, @field:Field("revision") val revision: Int, @field:Field("search_text") val searchText: String, + @field:Field("category_id") val categoryId: Long? = null, + @field:Field("category_name") val categoryName: String? = null, ) : BaseMongoEntity() diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/repository/ArticleRepository.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/repository/ArticleRepository.kt index 2225ea9..2253423 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/repository/ArticleRepository.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/repository/ArticleRepository.kt @@ -12,6 +12,27 @@ import org.springframework.data.repository.query.Param interface ArticleRepository : JpaRepository { fun existsByTitle(title: String): Boolean + /** + * 분류를 함께 가져오는 단건 조회. 분류는 지연 로딩이라, 트랜잭션 밖(정합성 배치 등)에서 `findById` 결과의 + * `category.name`을 읽으면 `LazyInitializationException`이 난다. + */ + @Query("SELECT a FROM ArticleJpaEntity a LEFT JOIN FETCH a.category WHERE a.id = :id") + fun findByIdWithCategory( + @Param("id") id: Long, + ): ArticleJpaEntity? + + /** 분류에 속한 문서를 최근 수정 순으로. 본문은 읽지 않는다. */ + @Query( + value = + "SELECT a.id AS id, a.title AS title, a.createdAt AS createdAt, a.updatedAt AS updatedAt " + + "FROM ArticleJpaEntity a WHERE a.category.id = :categoryId ORDER BY a.updatedAt DESC, a.id DESC", + countQuery = "SELECT COUNT(a) FROM ArticleJpaEntity a WHERE a.category.id = :categoryId", + ) + fun findByCategory( + @Param("categoryId") categoryId: Long, + pageable: Pageable, + ): Page + /** 최근에 수정된 문서 순(수정 시각 내림차순). 본문은 읽지 않는다. */ @Query( value = diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleConsistencyCheckScheduler.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleConsistencyCheckScheduler.kt index 022bf50..00e8dc9 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleConsistencyCheckScheduler.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleConsistencyCheckScheduler.kt @@ -95,7 +95,7 @@ class ArticleConsistencyCheckScheduler( trueRevision: Int, ) { try { - val article = articleRepository.findById(articleId).orElse(null) + val article = articleRepository.findByIdWithCategory(articleId) if (article == null) { log.warn("복구 대상 article을 MySQL에서 찾을 수 없음, 건너뜀 (articleId={})", articleId) return @@ -115,6 +115,8 @@ class ArticleConsistencyCheckScheduler( imageUrl = article.imageUrl, articleCreatedAt = createdAt, articleUpdatedAt = updatedAt, + categoryId = article.category?.id, + categoryName = article.category?.name, ) } catch (e: Exception) { log.error("article {} 복구 중 실패, 다음 article은 계속 진행", articleId, e) diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleMongoSynchronizer.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleMongoSynchronizer.kt index 90b3e5e..9902716 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleMongoSynchronizer.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleMongoSynchronizer.kt @@ -35,6 +35,8 @@ class ArticleMongoSynchronizer( imageUrl: String?, articleCreatedAt: Instant, articleUpdatedAt: Instant, + categoryId: Long? = null, + categoryName: String? = null, ) { val searchText = ArticleSearchTextConverter.convert(content) val byDocumentId = Query(Criteria.where("document_id").`is`(articleId)) @@ -48,6 +50,8 @@ class ArticleMongoSynchronizer( .setOnInsert("image_url", imageUrl) .setOnInsert("article_created_at", articleCreatedAt) .setOnInsert("article_updated_at", articleUpdatedAt) + .setOnInsert("category_id", categoryId) + .setOnInsert("category_name", categoryName) val result = try { @@ -81,7 +85,9 @@ class ArticleMongoSynchronizer( .set("search_text", searchText) .set("image_url", imageUrl) .set("article_created_at", articleCreatedAt) - .set("article_updated_at", articleUpdatedAt), + .set("article_updated_at", articleUpdatedAt) + .set("category_id", categoryId) + .set("category_name", categoryName), ArticleMongoEntity::class.java, ) } diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/CreateArticleServiceImpl.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/CreateArticleServiceImpl.kt index 7480702..adcac6a 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/CreateArticleServiceImpl.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/CreateArticleServiceImpl.kt @@ -7,6 +7,7 @@ import io.github.wntopia.gikipedia.server.domain.article.repository.ArticleRepos import io.github.wntopia.gikipedia.server.domain.article.service.ARTICLE_IMAGE_KEY_PREFIX import io.github.wntopia.gikipedia.server.domain.article.service.ArticleCacheStore import io.github.wntopia.gikipedia.server.domain.article.service.CreateArticleService +import io.github.wntopia.gikipedia.server.domain.category.repository.CategoryRepository import io.github.wntopia.gikipedia.server.domain.history.event.ArticleUpdatedEvent import io.github.wntopia.gikipedia.server.domain.link.service.ArticleLinkIndexer import io.github.wntopia.gikipedia.server.domain.user.service.ContributionRecorder @@ -30,6 +31,7 @@ class CreateArticleServiceImpl( private val authenticationReader: AuthenticationReader, private val contributionRecorder: ContributionRecorder, private val articleLinkIndexer: ArticleLinkIndexer, + private val categoryRepository: CategoryRepository, ) : CreateArticleService { @Transactional override fun execute( @@ -41,6 +43,12 @@ class CreateArticleServiceImpl( // 비로그인 생성도 허용하므로 로그인한 경우에만 생성자를 기록한다. val createdByUserId = authenticationReader.getCurrentUser(session)?.id + val category = + reqDto.categoryId?.let { + categoryRepository.findById(it).orElseThrow { + ExpectedException("존재하지 않는 분류입니다.", HttpStatus.NOT_FOUND) + } + } val imageUrl = reqDto.image?.takeIf { !it.isEmpty }?.let { seaweedUploader.upload( @@ -52,7 +60,9 @@ class CreateArticleServiceImpl( val saved = try { articleRepository.saveAndFlush( - ArticleJpaEntity(title, reqDto.content, imageUrl, createdByUserId = createdByUserId), + ArticleJpaEntity(title, reqDto.content, imageUrl, createdByUserId = createdByUserId).also { + it.changeCategory(category) + }, ) } catch (e: DataIntegrityViolationException) { throw duplicateTitle() @@ -72,6 +82,8 @@ class CreateArticleServiceImpl( imageUrl = saved.imageUrl, createdAt = requireNotNull(saved.createdAt) { "saved.createdAt이 null입니다 (articleId=${saved.id})" }, updatedAt = requireNotNull(saved.updatedAt) { "saved.updatedAt이 null입니다 (articleId=${saved.id})" }, + categoryId = category?.id, + categoryName = category?.name, ), ) diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/QueryArticleServiceImpl.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/QueryArticleServiceImpl.kt index ee08774..200f8cb 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/QueryArticleServiceImpl.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/QueryArticleServiceImpl.kt @@ -30,9 +30,9 @@ class QueryArticleServiceImpl( // 하나만 실행될 때 그 호출에 한해서만 짧게 트랜잭션이 열리고 닫힌다. private fun queryFromMysql(articleId: Long): ArticleResDto = articleRepository - .findById(articleId) - .orElseThrow { notFound() } - .let(ArticleResDto::from) + .findByIdWithCategory(articleId) + ?.let(ArticleResDto::from) + ?: throw notFound() /** * Redis miss 시 Mongo를 조회해 backfill한다. Mongo까지 miss면(정상적으로는 생성 이벤트가 이미 diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/UpdateArticleServiceImpl.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/UpdateArticleServiceImpl.kt index a11417f..da00949 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/UpdateArticleServiceImpl.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/UpdateArticleServiceImpl.kt @@ -5,12 +5,15 @@ import io.github.wntopia.gikipedia.server.domain.article.dto.response.ArticleRes import io.github.wntopia.gikipedia.server.domain.article.service.ARTICLE_IMAGE_KEY_PREFIX import io.github.wntopia.gikipedia.server.domain.article.service.ArticleUpdateTransactionHelper import io.github.wntopia.gikipedia.server.domain.article.service.UpdateArticleService +import io.github.wntopia.gikipedia.server.domain.category.repository.CategoryRepository import io.github.wntopia.gikipedia.server.domain.history.dto.EditMetadata import io.github.wntopia.gikipedia.server.global.security.session.AuthenticationReader import io.github.wntopia.gikipedia.server.global.storage.SeaweedUploader import jakarta.servlet.http.HttpSession +import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.support.TransactionTemplate +import team.themoment.sdk.exception.ExpectedException @Service class UpdateArticleServiceImpl( @@ -18,6 +21,7 @@ class UpdateArticleServiceImpl( private val authenticationReader: AuthenticationReader, private val transactionTemplate: TransactionTemplate, private val articleUpdateTransactionHelper: ArticleUpdateTransactionHelper, + private val categoryRepository: CategoryRepository, ) : UpdateArticleService { /** * SeaweedFS 업로드(블로킹 네트워크 호출)는 DB와 무관하므로 트랜잭션 밖에서 끝내둔다. `@Transactional`로 메서드 @@ -52,6 +56,13 @@ class UpdateArticleServiceImpl( val previousContent = article.content val imageUrl = uploadedImageUrl ?: article.imageUrl article.update(reqDto.content, imageUrl) + reqDto.categoryId?.let { categoryId -> + article.changeCategory( + categoryRepository.findById(categoryId).orElseThrow { + ExpectedException("존재하지 않는 분류입니다.", HttpStatus.NOT_FOUND) + }, + ) + } previousContent to reqDto.content } }, diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/controller/CategoryController.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/controller/CategoryController.kt new file mode 100644 index 0000000..a7fc189 --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/controller/CategoryController.kt @@ -0,0 +1,86 @@ +package io.github.wntopia.gikipedia.server.domain.category.controller + +import io.github.wntopia.gikipedia.server.domain.category.dto.request.CreateCategoryReqDto +import io.github.wntopia.gikipedia.server.domain.category.dto.response.CategoryArticleResDto +import io.github.wntopia.gikipedia.server.domain.category.dto.response.CategoryListItemResDto +import io.github.wntopia.gikipedia.server.domain.category.dto.response.CategoryResDto +import io.github.wntopia.gikipedia.server.domain.category.service.CreateCategoryService +import io.github.wntopia.gikipedia.server.domain.category.service.QueryCategoriesService +import io.github.wntopia.gikipedia.server.domain.category.service.QueryCategoryArticlesService +import io.github.wntopia.gikipedia.server.global.dto.PageResDto +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 jakarta.servlet.http.HttpSession +import jakarta.validation.Valid +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +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.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import team.themoment.sdk.response.CommonApiResponse + +@Tag(name = "Category", description = "문서 분류 API. 문서는 생성·수정 시 `categoryId`로 분류를 지정한다.") +@RestController +@RequestMapping("/api/v1/categories") +class CategoryController( + private val createCategoryService: CreateCategoryService, + private val queryCategoriesService: QueryCategoriesService, + private val queryCategoryArticlesService: QueryCategoryArticlesService, +) { + @Operation( + summary = "분류 생성 (관리자)", + description = "새 분류를 만든다. ADMIN·ROOT 권한만 호출할 수 있고, 이름은 앞뒤 공백을 제거한 뒤 중복될 수 없다.", + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "201", description = "생성 성공. `data`에 만들어진 분류가 담긴다."), + ApiResponse(responseCode = "400", description = "이름이 비어 있거나 50자를 초과함"), + ApiResponse(responseCode = "401", description = "로그인하지 않음"), + ApiResponse(responseCode = "403", description = "관리자 권한 없음"), + ApiResponse(responseCode = "409", description = "같은 이름의 분류가 이미 존재함"), + ], + ) + @PostMapping + fun create( + @Valid @RequestBody reqDto: CreateCategoryReqDto, + @Parameter(hidden = true) session: HttpSession, + ): CommonApiResponse = CommonApiResponse.created("", createCategoryService.execute(reqDto, session)) + + @Operation( + summary = "분류 목록 조회", + description = "모든 분류와 분류별 문서 수를 이름 순으로 반환한다. 홈의 '분류' 목록에 쓴다. 로그인 없이 호출할 수 있다.", + ) + @ApiResponses(value = [ApiResponse(responseCode = "200", description = "조회 성공")]) + @GetMapping + fun list(): List = queryCategoriesService.execute() + + @Operation( + summary = "분류별 문서 목록 조회", + description = "해당 분류에 속한 문서를 최근 수정 순으로 페이지 단위로 반환한다. 로그인 없이 호출할 수 있다.", + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "조회 성공"), + ApiResponse(responseCode = "400", description = "page 또는 size가 범위를 벗어남"), + ApiResponse(responseCode = "404", description = "존재하지 않는 분류"), + ], + ) + @GetMapping("/{categoryId}/articles") + fun articles( + @Parameter(description = "분류 ID", example = "1") @PathVariable categoryId: Long, + @Parameter(description = "페이지 번호 (0부터, 기본 0)", example = "0") + @RequestParam(defaultValue = "0") + @Min(0) page: Int, + @Parameter(description = "페이지 크기 (1~100, 기본 20)", example = "20") + @RequestParam(defaultValue = "20") + @Min(1) + @Max(100) size: Int, + ): PageResDto = queryCategoryArticlesService.execute(categoryId, page, size) +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/dto/request/CreateCategoryReqDto.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/dto/request/CreateCategoryReqDto.kt new file mode 100644 index 0000000..94906cf --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/dto/request/CreateCategoryReqDto.kt @@ -0,0 +1,12 @@ +package io.github.wntopia.gikipedia.server.domain.category.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + +data class CreateCategoryReqDto( + @field:Schema(description = "분류 이름 (최대 50자, 앞뒤 공백은 제거되며 중복 불가)", example = "동아리") + @field:NotBlank(message = "분류 이름은 비어 있을 수 없습니다.") + @field:Size(max = 50, message = "분류 이름은 50자를 초과할 수 없습니다.") + val name: String, +) diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/dto/response/CategoryArticleResDto.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/dto/response/CategoryArticleResDto.kt new file mode 100644 index 0000000..5d71d8d --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/dto/response/CategoryArticleResDto.kt @@ -0,0 +1,19 @@ +package io.github.wntopia.gikipedia.server.domain.category.dto.response + +import io.github.wntopia.gikipedia.server.domain.article.repository.ArticleRecentView +import io.swagger.v3.oas.annotations.media.Schema +import java.time.Instant + +data class CategoryArticleResDto( + @field:Schema(description = "문서 ID", example = "1") + val id: Long, + @field:Schema(description = "문서 제목") + val title: String, + @field:Schema(description = "마지막 수정 시각 (ISO-8601, UTC)") + val updatedAt: Instant, +) { + companion object { + fun from(view: ArticleRecentView): CategoryArticleResDto = + CategoryArticleResDto(view.id, view.title, view.updatedAt) + } +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/dto/response/CategoryListItemResDto.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/dto/response/CategoryListItemResDto.kt new file mode 100644 index 0000000..fea9f63 --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/dto/response/CategoryListItemResDto.kt @@ -0,0 +1,18 @@ +package io.github.wntopia.gikipedia.server.domain.category.dto.response + +import io.github.wntopia.gikipedia.server.domain.category.repository.CategoryCountView +import io.swagger.v3.oas.annotations.media.Schema + +data class CategoryListItemResDto( + @field:Schema(description = "분류 ID", example = "1") + val id: Long, + @field:Schema(description = "분류 이름", example = "학교") + val name: String, + @field:Schema(description = "이 분류에 속한 문서 수", example = "12") + val articleCount: Long, +) { + companion object { + fun from(view: CategoryCountView): CategoryListItemResDto = + CategoryListItemResDto(view.id, view.name, view.articleCount) + } +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/dto/response/CategoryResDto.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/dto/response/CategoryResDto.kt new file mode 100644 index 0000000..7d6656b --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/dto/response/CategoryResDto.kt @@ -0,0 +1,12 @@ +package io.github.wntopia.gikipedia.server.domain.category.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import java.io.Serializable + +/** 문서 응답에 붙는 분류 요약. Redis 캐시에도 담기므로 [Serializable]이다. */ +data class CategoryResDto( + @field:Schema(description = "분류 ID", example = "1") + val id: Long, + @field:Schema(description = "분류 이름", example = "학교") + val name: String, +) : Serializable diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/entity/CategoryJpaEntity.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/entity/CategoryJpaEntity.kt new file mode 100644 index 0000000..08b2ac2 --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/entity/CategoryJpaEntity.kt @@ -0,0 +1,17 @@ +package io.github.wntopia.gikipedia.server.domain.category.entity + +import io.github.wntopia.gikipedia.server.global.entity.BaseJpaEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint + +/** + * 문서 분류(학교 / 동아리 / 행사 / 인물 등). 문서 간 계층 구조(`ArticleJpaEntity.parent`)와는 별개의 평면 목록이다. + * 분류 이름은 만든 뒤 바꾸지 않는 것을 전제로, Mongo 읽기 뷰와 캐시에 이름을 그대로 복사해 둔다. + */ +@Entity +@Table(name = "categories", uniqueConstraints = [UniqueConstraint(name = "uk_categories_name", columnNames = ["name"])]) +class CategoryJpaEntity( + @field:Column(name = "name", nullable = false, updatable = false, length = 50) val name: String, +) : BaseJpaEntity() diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/repository/CategoryRepository.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/repository/CategoryRepository.kt new file mode 100644 index 0000000..51654f4 --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/repository/CategoryRepository.kt @@ -0,0 +1,23 @@ +package io.github.wntopia.gikipedia.server.domain.category.repository + +import io.github.wntopia.gikipedia.server.domain.category.entity.CategoryJpaEntity +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query + +interface CategoryRepository : JpaRepository { + fun existsByName(name: String): Boolean + + /** 분류 목록과 분류별 문서 수. 문서가 없는 분류도 포함한다. */ + @Query( + "SELECT c.id AS id, c.name AS name, COUNT(a) AS articleCount " + + "FROM CategoryJpaEntity c LEFT JOIN ArticleJpaEntity a ON a.category = c " + + "GROUP BY c.id, c.name ORDER BY c.name", + ) + fun findAllWithArticleCount(): List +} + +interface CategoryCountView { + val id: Long + val name: String + val articleCount: Long +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/CreateCategoryService.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/CreateCategoryService.kt new file mode 100644 index 0000000..67505f7 --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/CreateCategoryService.kt @@ -0,0 +1,12 @@ +package io.github.wntopia.gikipedia.server.domain.category.service + +import io.github.wntopia.gikipedia.server.domain.category.dto.request.CreateCategoryReqDto +import io.github.wntopia.gikipedia.server.domain.category.dto.response.CategoryResDto +import jakarta.servlet.http.HttpSession + +interface CreateCategoryService { + fun execute( + reqDto: CreateCategoryReqDto, + session: HttpSession, + ): CategoryResDto +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/QueryCategoriesService.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/QueryCategoriesService.kt new file mode 100644 index 0000000..4033165 --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/QueryCategoriesService.kt @@ -0,0 +1,7 @@ +package io.github.wntopia.gikipedia.server.domain.category.service + +import io.github.wntopia.gikipedia.server.domain.category.dto.response.CategoryListItemResDto + +interface QueryCategoriesService { + fun execute(): List +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/QueryCategoryArticlesService.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/QueryCategoryArticlesService.kt new file mode 100644 index 0000000..eea3b87 --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/QueryCategoryArticlesService.kt @@ -0,0 +1,12 @@ +package io.github.wntopia.gikipedia.server.domain.category.service + +import io.github.wntopia.gikipedia.server.domain.category.dto.response.CategoryArticleResDto +import io.github.wntopia.gikipedia.server.global.dto.PageResDto + +interface QueryCategoryArticlesService { + fun execute( + categoryId: Long, + page: Int, + size: Int, + ): PageResDto +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/impl/CreateCategoryServiceImpl.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/impl/CreateCategoryServiceImpl.kt new file mode 100644 index 0000000..58a83de --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/impl/CreateCategoryServiceImpl.kt @@ -0,0 +1,41 @@ +package io.github.wntopia.gikipedia.server.domain.category.service.impl + +import io.github.wntopia.gikipedia.server.domain.category.dto.request.CreateCategoryReqDto +import io.github.wntopia.gikipedia.server.domain.category.dto.response.CategoryResDto +import io.github.wntopia.gikipedia.server.domain.category.entity.CategoryJpaEntity +import io.github.wntopia.gikipedia.server.domain.category.repository.CategoryRepository +import io.github.wntopia.gikipedia.server.domain.category.service.CreateCategoryService +import io.github.wntopia.gikipedia.server.global.security.session.AuthenticationReader +import io.github.wntopia.gikipedia.server.global.text.TitleNormalizer +import jakarta.servlet.http.HttpSession +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import team.themoment.sdk.exception.ExpectedException + +@Service +class CreateCategoryServiceImpl( + private val authenticationReader: AuthenticationReader, + private val categoryRepository: CategoryRepository, +) : CreateCategoryService { + @Transactional + override fun execute( + reqDto: CreateCategoryReqDto, + session: HttpSession, + ): CategoryResDto { + authenticationReader.requireAdmin(session) + val name = TitleNormalizer.normalize(reqDto.name) + if (name.isEmpty() || categoryRepository.existsByName(name)) throw duplicate() + + val saved = + try { + categoryRepository.saveAndFlush(CategoryJpaEntity(name)) + } catch (e: DataIntegrityViolationException) { + throw duplicate() + } + return CategoryResDto(requireNotNull(saved.id), saved.name) + } + + private fun duplicate() = ExpectedException("이미 존재하는 분류입니다.", HttpStatus.CONFLICT) +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/impl/QueryCategoriesServiceImpl.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/impl/QueryCategoriesServiceImpl.kt new file mode 100644 index 0000000..22944e0 --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/impl/QueryCategoriesServiceImpl.kt @@ -0,0 +1,16 @@ +package io.github.wntopia.gikipedia.server.domain.category.service.impl + +import io.github.wntopia.gikipedia.server.domain.category.dto.response.CategoryListItemResDto +import io.github.wntopia.gikipedia.server.domain.category.repository.CategoryRepository +import io.github.wntopia.gikipedia.server.domain.category.service.QueryCategoriesService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class QueryCategoriesServiceImpl( + private val categoryRepository: CategoryRepository, +) : QueryCategoriesService { + @Transactional(readOnly = true) + override fun execute(): List = + categoryRepository.findAllWithArticleCount().map(CategoryListItemResDto::from) +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/impl/QueryCategoryArticlesServiceImpl.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/impl/QueryCategoryArticlesServiceImpl.kt new file mode 100644 index 0000000..c789c7a --- /dev/null +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/impl/QueryCategoryArticlesServiceImpl.kt @@ -0,0 +1,37 @@ +package io.github.wntopia.gikipedia.server.domain.category.service.impl + +import io.github.wntopia.gikipedia.server.domain.article.repository.ArticleRepository +import io.github.wntopia.gikipedia.server.domain.category.dto.response.CategoryArticleResDto +import io.github.wntopia.gikipedia.server.domain.category.repository.CategoryRepository +import io.github.wntopia.gikipedia.server.domain.category.service.QueryCategoryArticlesService +import io.github.wntopia.gikipedia.server.global.dto.PageResDto +import org.springframework.data.domain.PageRequest +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import team.themoment.sdk.exception.ExpectedException + +@Service +class QueryCategoryArticlesServiceImpl( + private val categoryRepository: CategoryRepository, + private val articleRepository: ArticleRepository, +) : QueryCategoryArticlesService { + @Transactional(readOnly = true) + override fun execute( + categoryId: Long, + page: Int, + size: Int, + ): PageResDto { + if (!categoryRepository.existsById(categoryId)) { + throw ExpectedException("존재하지 않는 분류입니다.", HttpStatus.NOT_FOUND) + } + val result = articleRepository.findByCategory(categoryId, PageRequest.of(page, size)) + return PageResDto( + result.content.map(CategoryArticleResDto::from), + page, + size, + result.totalElements, + result.hasNext(), + ) + } +} diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/history/event/ArticleUpdatedEvent.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/history/event/ArticleUpdatedEvent.kt index aed84f0..ff82490 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/history/event/ArticleUpdatedEvent.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/history/event/ArticleUpdatedEvent.kt @@ -16,4 +16,6 @@ data class ArticleUpdatedEvent( val imageUrl: String?, val createdAt: Instant, val updatedAt: Instant, + val categoryId: Long? = null, + val categoryName: String? = null, ) diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/history/event/ArticleUpdatedEventListener.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/history/event/ArticleUpdatedEventListener.kt index c809f44..52e1dd0 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/history/event/ArticleUpdatedEventListener.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/history/event/ArticleUpdatedEventListener.kt @@ -27,6 +27,8 @@ class ArticleUpdatedEventListener( imageUrl = event.imageUrl, articleCreatedAt = event.createdAt, articleUpdatedAt = event.updatedAt, + categoryId = event.categoryId, + categoryName = event.categoryName, ) } } diff --git a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/history/service/ArticleHistoryRecorder.kt b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/history/service/ArticleHistoryRecorder.kt index 2b452a2..49bb108 100644 --- a/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/history/service/ArticleHistoryRecorder.kt +++ b/src/main/kotlin/io/github/wntopia/gikipedia/server/domain/history/service/ArticleHistoryRecorder.kt @@ -106,6 +106,8 @@ class ArticleHistoryRecorder( imageUrl = article.imageUrl, createdAt = requireNotNull(article.createdAt) { "article.createdAt이 null입니다 (articleId=$articleId)" }, updatedAt = requireNotNull(article.updatedAt) { "article.updatedAt이 null입니다 (articleId=$articleId)" }, + categoryId = article.category?.id, + categoryName = article.category?.name, ) /** 리비전 1과 이후 snapshotInterval 간격마다 스냅샷을 남긴다(예: interval=10 → 1, 11, 21...). */ diff --git a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/ArticleResDtoTest.kt b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/ArticleResDtoTest.kt new file mode 100644 index 0000000..fa36c8e --- /dev/null +++ b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/dto/ArticleResDtoTest.kt @@ -0,0 +1,59 @@ +package io.github.wntopia.gikipedia.server.domain.article.dto + +import io.github.wntopia.gikipedia.server.domain.article.dto.response.ArticleResDto +import io.github.wntopia.gikipedia.server.domain.article.entity.ArticleJpaEntity +import io.github.wntopia.gikipedia.server.domain.article.entity.ArticleMongoEntity +import io.github.wntopia.gikipedia.server.domain.category.dto.response.CategoryResDto +import io.github.wntopia.gikipedia.server.domain.category.entity.CategoryJpaEntity +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.test.util.ReflectionTestUtils +import java.time.Instant + +class ArticleResDtoTest { + @Test + @DisplayName("MySQL 엔티티로 만든 응답에 분류가 담긴다") + fun fromJpaEntityIncludesCategory() { + val category = CategoryJpaEntity("학교").also { ReflectionTestUtils.setField(it, "id", 3L) } + val entity = + ArticleJpaEntity(title = "제목", content = "내용").also { + ReflectionTestUtils.setField(it, "id", 1L) + it.changeCategory(category) + } + + assertThat(ArticleResDto.from(entity).category).isEqualTo(CategoryResDto(3L, "학교")) + } + + @Test + @DisplayName("Mongo 읽기 뷰로 만든 응답에도 복사해 둔 분류가 담긴다") + fun fromMongoEntityIncludesCategory() { + val withCategory = mongoView(categoryId = 3L, categoryName = "학교") + val withoutCategory = mongoView(categoryId = null, categoryName = null) + + assertThat(ArticleResDto.from(withCategory).category).isEqualTo(CategoryResDto(3L, "학교")) + assertThat(ArticleResDto.from(withoutCategory).category).isNull() + } + + /** + * 생성자 대신 mock으로 만든다 — 다른 PR(#32 검색)이 Mongo 뷰에 필수 필드를 추가해도 이 테스트가 깨지지 않게 하기 위함이다. + * `from()`이 읽는 값만 채운다. + */ + private fun mongoView( + categoryId: Long?, + categoryName: String?, + ): ArticleMongoEntity = + mock().also { + val now = Instant.now() + whenever(it.documentId).thenReturn(1L) + whenever(it.title).thenReturn("제목") + whenever(it.content).thenReturn("내용") + whenever(it.imageUrl).thenReturn(null) + whenever(it.articleCreatedAt).thenReturn(now) + whenever(it.articleUpdatedAt).thenReturn(now) + whenever(it.categoryId).thenReturn(categoryId) + whenever(it.categoryName).thenReturn(categoryName) + } +} diff --git a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleConsistencyCheckSchedulerTest.kt b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleConsistencyCheckSchedulerTest.kt index 787155e..535cb11 100644 --- a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleConsistencyCheckSchedulerTest.kt +++ b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/ArticleConsistencyCheckSchedulerTest.kt @@ -18,7 +18,6 @@ import org.mockito.kotlin.whenever import org.springframework.data.domain.Pageable import org.springframework.test.util.ReflectionTestUtils import java.time.Instant -import java.util.Optional private data class RevisionView( private val articleId: Long, @@ -51,7 +50,7 @@ class ArticleConsistencyCheckSchedulerTest { ReflectionTestUtils.setField(article, "id", 1L) ReflectionTestUtils.setField(article, "createdAt", Instant.now()) ReflectionTestUtils.setField(article, "updatedAt", Instant.now()) - whenever(articleRepository.findById(1L)).thenReturn(Optional.of(article)) + whenever(articleRepository.findByIdWithCategory(1L)).thenReturn(article) } private fun mongoDoc( @@ -75,7 +74,10 @@ class ArticleConsistencyCheckSchedulerTest { scheduler.checkAndRepair() - verify(articleMongoSynchronizer, never()).sync(any(), any(), any(), any(), any(), any(), any()) + verify( + articleMongoSynchronizer, + never(), + ).sync(any(), any(), any(), any(), any(), any(), any(), anyOrNull(), anyOrNull()) } @Test @@ -88,7 +90,9 @@ class ArticleConsistencyCheckSchedulerTest { scheduler.checkAndRepair() - verify(articleMongoSynchronizer).sync(eq(1L), eq(5), eq("제목"), eq("내용"), eq(null), any(), any()) + verify( + articleMongoSynchronizer, + ).sync(eq(1L), eq(5), eq("제목"), eq("내용"), eq(null), any(), any(), anyOrNull(), anyOrNull()) } @Test @@ -100,7 +104,9 @@ class ArticleConsistencyCheckSchedulerTest { scheduler.checkAndRepair() // 히스토리가 없는(=한 번도 수정 안 된) 문서의 정답 리비전은 1이다. - verify(articleMongoSynchronizer).sync(eq(1L), eq(1), any(), any(), anyOrNull(), any(), any()) + verify( + articleMongoSynchronizer, + ).sync(eq(1L), eq(1), any(), any(), anyOrNull(), any(), any(), anyOrNull(), anyOrNull()) } @Test @@ -110,11 +116,14 @@ class ArticleConsistencyCheckSchedulerTest { articleHistoryRepository.findLatestRevisionPerArticleIn(listOf(1L)), ).thenReturn(listOf(RevisionView(1L, 5))) whenever(articleMongoRepository.findByDocumentIdIn(listOf(1L))).thenReturn(listOf(mongoDoc(1L, 3))) - whenever(articleRepository.findById(1L)).thenReturn(Optional.empty()) + whenever(articleRepository.findByIdWithCategory(1L)).thenReturn(null) scheduler.checkAndRepair() - verify(articleMongoSynchronizer, never()).sync(any(), any(), any(), any(), any(), any(), any()) + verify( + articleMongoSynchronizer, + never(), + ).sync(any(), any(), any(), any(), any(), any(), any(), anyOrNull(), anyOrNull()) } @Test @@ -124,7 +133,7 @@ class ArticleConsistencyCheckSchedulerTest { whenever(articleRepository.findIdsAfter(eq(2L), any())).thenReturn(emptyList()) whenever(articleHistoryRepository.findLatestRevisionPerArticleIn(any())).thenReturn(emptyList()) whenever(articleMongoRepository.findByDocumentIdIn(any())).thenReturn(emptyList()) - whenever(articleRepository.findById(2L)).thenReturn(Optional.empty()) + whenever(articleRepository.findByIdWithCategory(2L)).thenReturn(null) scheduler.checkAndRepair() diff --git a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/CreateArticleServiceImplTest.kt b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/CreateArticleServiceImplTest.kt index 3b9e184..7f0876f 100644 --- a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/CreateArticleServiceImplTest.kt +++ b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/CreateArticleServiceImplTest.kt @@ -5,6 +5,9 @@ import io.github.wntopia.gikipedia.server.domain.article.entity.ArticleJpaEntity import io.github.wntopia.gikipedia.server.domain.article.repository.ArticleRepository import io.github.wntopia.gikipedia.server.domain.article.service.ArticleCacheStore import io.github.wntopia.gikipedia.server.domain.auth.dto.response.DataGsmUserInfoResDto +import io.github.wntopia.gikipedia.server.domain.category.dto.response.CategoryResDto +import io.github.wntopia.gikipedia.server.domain.category.entity.CategoryJpaEntity +import io.github.wntopia.gikipedia.server.domain.category.repository.CategoryRepository import io.github.wntopia.gikipedia.server.domain.history.event.ArticleUpdatedEvent import io.github.wntopia.gikipedia.server.domain.link.service.ArticleLinkIndexer import io.github.wntopia.gikipedia.server.domain.user.service.ContributionRecorder @@ -38,6 +41,7 @@ class CreateArticleServiceImplTest { private val authenticationReader = mock() private val contributionRecorder = mock() private val articleLinkIndexer = mock() + private val categoryRepository = mock() private val session = mock() private val service = @@ -49,6 +53,7 @@ class CreateArticleServiceImplTest { authenticationReader, contributionRecorder, articleLinkIndexer, + categoryRepository, ) @BeforeEach @@ -154,4 +159,35 @@ class CreateArticleServiceImplTest { verify(articleLinkIndexer).reindex(1L, "제목", "[[다른 문서]] 참고") } + + @Test + @DisplayName("분류를 지정하면 문서에 분류가 붙고 동기화 이벤트에도 분류가 실린다") + fun attachesCategory() { + val category = CategoryJpaEntity("학교").also { ReflectionTestUtils.setField(it, "id", 3L) } + whenever(categoryRepository.findById(3L)).thenReturn(java.util.Optional.of(category)) + + val response = service.execute(CreateArticleReqDto(title = "제목", content = "내용", categoryId = 3L), session) + + assertThat(response.category).isEqualTo(CategoryResDto(3L, "학교")) + val captor = argumentCaptor() + verify(eventPublisher).publishEvent(captor.capture()) + assertThat(captor.firstValue.categoryId).isEqualTo(3L) + assertThat(captor.firstValue.categoryName).isEqualTo("학교") + } + + @Test + @DisplayName("없는 분류를 지정하면 이미지를 올리기 전에 404를 던진다") + fun rejectsUnknownCategory() { + whenever(categoryRepository.findById(99L)).thenReturn(java.util.Optional.empty()) + + assertThatThrownBy { + service.execute( + CreateArticleReqDto(title = "제목", content = "내용", categoryId = 99L), + session, + ) + }.isInstanceOfSatisfying(ExpectedException::class.java) { + assertThat(it.statusCode).isEqualTo(HttpStatus.NOT_FOUND) + } + verify(seaweedUploader, never()).upload(any(), any()) + } } diff --git a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/QueryArticleServiceImplTest.kt b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/QueryArticleServiceImplTest.kt index 1c294b5..f5cb3a2 100644 --- a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/QueryArticleServiceImplTest.kt +++ b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/QueryArticleServiceImplTest.kt @@ -20,7 +20,6 @@ import org.mockito.kotlin.whenever import org.springframework.test.util.ReflectionTestUtils import team.themoment.sdk.exception.ExpectedException import java.time.Instant -import java.util.Optional /** 읽기 경로(feature flag, Redis→Mongo, MySQL 폴백 없음) 검증. */ class QueryArticleServiceImplTest { @@ -46,7 +45,7 @@ class QueryArticleServiceImplTest { @Test @DisplayName("MYSQL 모드면 캐시/Mongo를 건드리지 않고 MySQL만 조회한다") fun mysqlModeQueriesMysqlOnly() { - whenever(articleRepository.findById(1L)).thenReturn(Optional.of(article)) + whenever(articleRepository.findByIdWithCategory(1L)).thenReturn(article) val result = service(ArticleReadSource.MYSQL).execute(1L) @@ -98,6 +97,6 @@ class QueryArticleServiceImplTest { assertThatThrownBy { service(ArticleReadSource.REDIS_MONGO).execute(1L) } .isInstanceOf(ExpectedException::class.java) - verify(articleRepository, never()).findById(org.mockito.kotlin.any()) + verify(articleRepository, never()).findByIdWithCategory(org.mockito.kotlin.any()) } } diff --git a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/UpdateArticleServiceImplTest.kt b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/UpdateArticleServiceImplTest.kt index a0e7728..beae133 100644 --- a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/UpdateArticleServiceImplTest.kt +++ b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/article/service/impl/UpdateArticleServiceImplTest.kt @@ -6,6 +6,8 @@ import io.github.wntopia.gikipedia.server.domain.article.entity.ArticleJpaEntity import io.github.wntopia.gikipedia.server.domain.article.repository.ArticleRepository import io.github.wntopia.gikipedia.server.domain.article.service.ArticleCacheStore import io.github.wntopia.gikipedia.server.domain.article.service.ArticleUpdateTransactionHelper +import io.github.wntopia.gikipedia.server.domain.category.entity.CategoryJpaEntity +import io.github.wntopia.gikipedia.server.domain.category.repository.CategoryRepository import io.github.wntopia.gikipedia.server.domain.history.service.ArticleHistoryRecorder import io.github.wntopia.gikipedia.server.domain.link.service.ArticleLinkIndexer import io.github.wntopia.gikipedia.server.global.security.session.AuthenticationReader @@ -45,6 +47,7 @@ class UpdateArticleServiceImplTest { private val articleLinkIndexer = mock() private val transactionTemplate = mock() private val session = mock() + private val categoryRepository = mock() private val articleUpdateTransactionHelper = ArticleUpdateTransactionHelper(articleRepository, articleHistoryRecorder, articleCacheStore, articleLinkIndexer) @@ -55,6 +58,7 @@ class UpdateArticleServiceImplTest { authenticationReader, transactionTemplate, articleUpdateTransactionHelper, + categoryRepository, ) private val article = ArticleJpaEntity(title = "제목", content = "이전 내용", imageUrl = "old.png") @@ -119,4 +123,28 @@ class UpdateArticleServiceImplTest { verify(articleCacheStore).putAfterCommit(response) } + + @Test + @DisplayName("categoryId를 보내면 문서의 분류가 바뀌고, 안 보내면 유지된다") + fun changesCategoryOnlyWhenProvided() { + val category = CategoryJpaEntity("동아리").also { ReflectionTestUtils.setField(it, "id", 4L) } + whenever(categoryRepository.findById(4L)).thenReturn(java.util.Optional.of(category)) + + service.execute(1L, UpdateArticleReqDto(content = "새 내용", categoryId = 4L), session) + assertThat(article.category).isSameAs(category) + + service.execute(1L, UpdateArticleReqDto(content = "또 새 내용"), session) + assertThat(article.category).isSameAs(category) + } + + @Test + @DisplayName("없는 분류로 바꾸려 하면 404를 던진다") + fun rejectsUnknownCategory() { + whenever(categoryRepository.findById(99L)).thenReturn(java.util.Optional.empty()) + + assertThatThrownBy { service.execute(1L, UpdateArticleReqDto(content = "새 내용", categoryId = 99L), session) } + .isInstanceOfSatisfying(ExpectedException::class.java) { + assertThat(it.statusCode).isEqualTo(org.springframework.http.HttpStatus.NOT_FOUND) + } + } } diff --git a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/category/controller/CategoryControllerValidationTest.kt b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/category/controller/CategoryControllerValidationTest.kt new file mode 100644 index 0000000..c82aa3c --- /dev/null +++ b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/category/controller/CategoryControllerValidationTest.kt @@ -0,0 +1,45 @@ +package io.github.wntopia.gikipedia.server.domain.category.controller + +import io.github.wntopia.gikipedia.server.domain.category.service.QueryCategoryArticlesService +import io.github.wntopia.gikipedia.server.global.dto.PageResDto +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean + +/** 범위를 벗어난 `page`/`size`가 500이 아니라 400으로 거절되는지 확인한다. */ +class CategoryControllerValidationTest { + private val articlesService = mock() + private lateinit var mockMvc: MockMvc + + @BeforeEach + fun setUp() { + val validator = LocalValidatorFactoryBean().apply { afterPropertiesSet() } + mockMvc = + MockMvcBuilders + .standaloneSetup(CategoryController(mock(), mock(), articlesService)) + .setValidator(validator) + .build() + whenever(articlesService.execute(any(), any(), any())).thenReturn(PageResDto(emptyList(), 0, 20, 0, false)) + } + + @Test + @DisplayName("page가 음수이거나 size가 범위를 벗어나면 400") + fun rejectsOutOfRangePaging() { + mockMvc.perform(get("/api/v1/categories/1/articles").param("page", "-1")).andExpect(status().isBadRequest) + mockMvc.perform(get("/api/v1/categories/1/articles").param("size", "101")).andExpect(status().isBadRequest) + } + + @Test + @DisplayName("기본값은 통과한다") + fun acceptsDefaults() { + mockMvc.perform(get("/api/v1/categories/1/articles")).andExpect(status().isOk) + } +} diff --git a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/category/repository/CategoryRepositoryIntegrationTest.kt b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/category/repository/CategoryRepositoryIntegrationTest.kt new file mode 100644 index 0000000..424417d --- /dev/null +++ b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/category/repository/CategoryRepositoryIntegrationTest.kt @@ -0,0 +1,87 @@ +package io.github.wntopia.gikipedia.server.domain.category.repository + +import io.github.wntopia.gikipedia.server.domain.article.entity.ArticleJpaEntity +import io.github.wntopia.gikipedia.server.domain.article.repository.ArticleRepository +import io.github.wntopia.gikipedia.server.domain.category.entity.CategoryJpaEntity +import io.github.wntopia.gikipedia.server.global.config.JpaAuditingConfig +import jakarta.persistence.EntityManager +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.hibernate.Hibernate +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.data.domain.PageRequest + +@DataJpaTest +@Import(JpaAuditingConfig::class) +class CategoryRepositoryIntegrationTest { + @Autowired + private lateinit var categoryRepository: CategoryRepository + + @Autowired + private lateinit var articleRepository: ArticleRepository + + @Autowired + private lateinit var entityManager: EntityManager + + private fun article( + title: String, + category: CategoryJpaEntity?, + ) = articleRepository.saveAndFlush( + ArticleJpaEntity(title = title, content = "x").also { it.changeCategory(category) }, + ) + + @Test + fun `분류 이름은 중복될 수 없다`() { + categoryRepository.saveAndFlush(CategoryJpaEntity("학교")) + + assertThatThrownBy { categoryRepository.saveAndFlush(CategoryJpaEntity("학교")) } + .isInstanceOf(DataIntegrityViolationException::class.java) + } + + @Test + fun `분류 목록은 이름 순이고 문서가 없는 분류도 0으로 나온다`() { + val school = categoryRepository.saveAndFlush(CategoryJpaEntity("학교")) + categoryRepository.saveAndFlush(CategoryJpaEntity("동아리")) + article("A", school) + article("B", school) + article("분류 없음", null) + + val list = categoryRepository.findAllWithArticleCount() + + assertThat(list.map { it.name }).containsExactly("동아리", "학교") + assertThat(list.map { it.articleCount }).containsExactly(0L, 2L) + } + + @Test + fun `분류별 문서 목록은 해당 분류 문서만 최근 수정 순으로 가져온다`() { + val school = categoryRepository.saveAndFlush(CategoryJpaEntity("학교")) + val club = categoryRepository.saveAndFlush(CategoryJpaEntity("동아리")) + article("학교 문서 1", school) + val latest = article("학교 문서 2", school) + article("동아리 문서", club) + + val page = articleRepository.findByCategory(requireNotNull(school.id), PageRequest.of(0, 10)) + + assertThat(page.content.map { it.title }).containsExactly("학교 문서 2", "학교 문서 1") + assertThat(page.content.first().id).isEqualTo(latest.id) + assertThat(page.totalElements).isEqualTo(2) + } + + @Test + fun `fetch join 조회는 세션 밖에서도 분류 이름을 읽을 수 있다`() { + val school = categoryRepository.saveAndFlush(CategoryJpaEntity("학교")) + val saved = article("분류 문서", school) + entityManager.clear() + + val loaded = requireNotNull(articleRepository.findByIdWithCategory(requireNotNull(saved.id))) + entityManager.clear() // 세션이 닫힌 것과 같은 상태(분리됨) + + assertThat(Hibernate.isInitialized(loaded.category)).isTrue() + assertThat(loaded.category?.name).isEqualTo("학교") + assertThat(articleRepository.findByIdWithCategory(Long.MAX_VALUE)).isNull() + } +} diff --git a/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/CategoryServicesTest.kt b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/CategoryServicesTest.kt new file mode 100644 index 0000000..da6226f --- /dev/null +++ b/src/test/kotlin/io/github/wntopia/gikipedia/server/domain/category/service/CategoryServicesTest.kt @@ -0,0 +1,87 @@ +package io.github.wntopia.gikipedia.server.domain.category.service + +import io.github.wntopia.gikipedia.server.domain.article.repository.ArticleRepository +import io.github.wntopia.gikipedia.server.domain.category.dto.request.CreateCategoryReqDto +import io.github.wntopia.gikipedia.server.domain.category.entity.CategoryJpaEntity +import io.github.wntopia.gikipedia.server.domain.category.repository.CategoryRepository +import io.github.wntopia.gikipedia.server.domain.category.service.impl.CreateCategoryServiceImpl +import io.github.wntopia.gikipedia.server.domain.category.service.impl.QueryCategoryArticlesServiceImpl +import io.github.wntopia.gikipedia.server.global.security.session.AuthenticationReader +import jakarta.servlet.http.HttpSession +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.http.HttpStatus +import org.springframework.test.util.ReflectionTestUtils +import team.themoment.sdk.exception.ExpectedException + +class CategoryServicesTest { + private val authenticationReader = mock() + private val categoryRepository = mock() + private val articleRepository = mock() + private val session = mock() + + private val create = CreateCategoryServiceImpl(authenticationReader, categoryRepository) + private val queryArticles = QueryCategoryArticlesServiceImpl(categoryRepository, articleRepository) + + private fun status(e: Throwable) = (e as ExpectedException).statusCode + + @Test + @DisplayName("관리자가 이름을 다듬어 분류를 만든다") + fun createsCategoryAsAdmin() { + whenever(categoryRepository.existsByName("동아리")).thenReturn(false) + whenever(categoryRepository.saveAndFlush(any())).thenAnswer { + it.getArgument(0).also { e -> ReflectionTestUtils.setField(e, "id", 5L) } + } + + val result = create.execute(CreateCategoryReqDto(" 동아리 "), session) + + verify(authenticationReader).requireAdmin(session) + assertThat(result.id).isEqualTo(5L) + assertThat(result.name).isEqualTo("동아리") + } + + @Test + @DisplayName("관리자가 아니면 분류를 만들 수 없고 아무것도 저장하지 않는다") + fun rejectsNonAdmin() { + doThrow( + ExpectedException("관리자만 접근할 수 있습니다.", HttpStatus.FORBIDDEN), + ).whenever(authenticationReader).requireAdmin(session) + + assertThatThrownBy { create.execute(CreateCategoryReqDto("동아리"), session) } + .satisfies({ assertThat(status(it)).isEqualTo(HttpStatus.FORBIDDEN) }) + verify(categoryRepository, never()).saveAndFlush(any()) + } + + @Test + @DisplayName("이미 있는 이름이면 409, 동시 생성으로 제약에 걸려도 409") + fun rejectsDuplicateName() { + whenever(categoryRepository.existsByName("학교")).thenReturn(true) + assertThatThrownBy { create.execute(CreateCategoryReqDto("학교"), session) } + .satisfies({ assertThat(status(it)).isEqualTo(HttpStatus.CONFLICT) }) + + whenever(categoryRepository.existsByName("행사")).thenReturn(false) + whenever( + categoryRepository.saveAndFlush(any()), + ).thenThrow(DataIntegrityViolationException("dup")) + assertThatThrownBy { create.execute(CreateCategoryReqDto("행사"), session) } + .satisfies({ assertThat(status(it)).isEqualTo(HttpStatus.CONFLICT) }) + } + + @Test + @DisplayName("없는 분류의 문서 목록은 404") + fun unknownCategoryArticles() { + whenever(categoryRepository.existsById(9L)).thenReturn(false) + + assertThatThrownBy { queryArticles.execute(9L, 0, 20) } + .satisfies({ assertThat(status(it)).isEqualTo(HttpStatus.NOT_FOUND) }) + } +}