From c3071f0b41d7738701dc1d7a3957826150043647 Mon Sep 17 00:00:00 2001 From: robinjoon Date: Fri, 6 Mar 2026 20:45:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Review=20=EC=82=AD=EC=A0=9C=20Mutation?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 본인 회고만 삭제 가능하도록 소유권 검증 포함한 deleteReview Mutation 구현 Co-Authored-By: Claude Opus 4.6 --- docs/plan/#20-review-delete/checklist.md | 11 +++++ docs/plan/#20-review-delete/plan.md | 15 +++++++ .../application/service/ReviewService.kt | 16 ++++++++ .../loop/review/domain/model/ReviewCommand.kt | 4 ++ .../domain/repository/ReviewRepository.kt | 2 + .../persistence/ExposedReviewRepository.kt | 5 +++ .../datafetcher/ReviewDataFetcher.kt | 11 +++++ src/main/resources/schema/review.graphqls | 6 +++ .../application/service/ReviewServiceTest.kt | 41 +++++++++++++++++++ 9 files changed, 111 insertions(+) create mode 100644 docs/plan/#20-review-delete/checklist.md create mode 100644 docs/plan/#20-review-delete/plan.md diff --git a/docs/plan/#20-review-delete/checklist.md b/docs/plan/#20-review-delete/checklist.md new file mode 100644 index 0000000..34f37ef --- /dev/null +++ b/docs/plan/#20-review-delete/checklist.md @@ -0,0 +1,11 @@ +# Review 삭제 Mutation 검증 체크리스트 + +## 필수 항목 +- [x] 아키텍처 원칙 준수 (docs/architecture.md 기준) +- [x] 레이어 의존성 규칙 위반 없음 +- [x] 테스트 코드 작성 완료 (Domain, Application 필수) +- [x] 모든 테스트 통과 +- [x] 기존 테스트 깨지지 않음 + +## 선택 항목 (해당 시) +- [x] 소유권 검증 (본인 회고만 삭제 가능) diff --git a/docs/plan/#20-review-delete/plan.md b/docs/plan/#20-review-delete/plan.md new file mode 100644 index 0000000..957ea11 --- /dev/null +++ b/docs/plan/#20-review-delete/plan.md @@ -0,0 +1,15 @@ +# Review 삭제 Mutation 추가 계획 + +> Issue: #20 + +## 단계 + +- [x] 1단계: Domain — ReviewCommand.Delete 추가 +- [x] 2단계: Domain — ReviewRepository에 delete 메서드 추가 +- [x] 3단계: Application — ReviewService.delete 테스트 작성 (RED) +- [x] 4단계: Application — ReviewService.delete 구현 (GREEN) +- [x] 5단계: Infrastructure — ExposedReviewRepository.delete 구현 +- [x] 6단계: Presentation — GraphQL 스키마에 deleteReview Mutation 추가 +- [x] 7단계: Presentation — ReviewDataFetcher.deleteReview 구현 +- [x] 8단계: DGS Codegen 빌드 확인 +- [x] 9단계: 전체 테스트 통과 확인 diff --git a/src/main/kotlin/kr/io/team/loop/review/application/service/ReviewService.kt b/src/main/kotlin/kr/io/team/loop/review/application/service/ReviewService.kt index eb502e7..0a4b1db 100644 --- a/src/main/kotlin/kr/io/team/loop/review/application/service/ReviewService.kt +++ b/src/main/kotlin/kr/io/team/loop/review/application/service/ReviewService.kt @@ -3,7 +3,9 @@ package kr.io.team.loop.review.application.service import kotlinx.datetime.LocalDate import kotlinx.datetime.minus import kr.io.team.loop.common.domain.MemberId +import kr.io.team.loop.common.domain.exception.AccessDeniedException import kr.io.team.loop.common.domain.exception.DuplicateEntityException +import kr.io.team.loop.common.domain.exception.EntityNotFoundException import kr.io.team.loop.review.application.dto.ReviewStatsDto import kr.io.team.loop.review.domain.model.Review import kr.io.team.loop.review.domain.model.ReviewCommand @@ -17,6 +19,20 @@ import org.springframework.transaction.annotation.Transactional class ReviewService( private val reviewRepository: ReviewRepository, ) { + @Transactional + fun delete( + command: ReviewCommand.Delete, + memberId: MemberId, + ) { + val review = + reviewRepository.findById(command.reviewId) + ?: throw EntityNotFoundException("Review not found: ${command.reviewId.value}") + if (!review.isOwnedBy(memberId)) { + throw AccessDeniedException("Review does not belong to member: ${memberId.value}") + } + reviewRepository.delete(command) + } + @Transactional fun create(command: ReviewCommand.Create): Review = try { diff --git a/src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewCommand.kt b/src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewCommand.kt index 1dd54fd..30f5bc2 100644 --- a/src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewCommand.kt +++ b/src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewCommand.kt @@ -9,4 +9,8 @@ sealed interface ReviewCommand { val steps: List, val date: LocalDate, ) : ReviewCommand + + data class Delete( + val reviewId: ReviewId, + ) : ReviewCommand } diff --git a/src/main/kotlin/kr/io/team/loop/review/domain/repository/ReviewRepository.kt b/src/main/kotlin/kr/io/team/loop/review/domain/repository/ReviewRepository.kt index fbc6464..4e9bae6 100644 --- a/src/main/kotlin/kr/io/team/loop/review/domain/repository/ReviewRepository.kt +++ b/src/main/kotlin/kr/io/team/loop/review/domain/repository/ReviewRepository.kt @@ -8,6 +8,8 @@ import kr.io.team.loop.review.domain.model.ReviewQuery interface ReviewRepository { fun save(command: ReviewCommand.Create): Review + fun delete(command: ReviewCommand.Delete) + fun findAll(query: ReviewQuery): List fun findById(id: kr.io.team.loop.review.domain.model.ReviewId): Review? diff --git a/src/main/kotlin/kr/io/team/loop/review/infrastructure/persistence/ExposedReviewRepository.kt b/src/main/kotlin/kr/io/team/loop/review/infrastructure/persistence/ExposedReviewRepository.kt index 4643340..a342287 100644 --- a/src/main/kotlin/kr/io/team/loop/review/infrastructure/persistence/ExposedReviewRepository.kt +++ b/src/main/kotlin/kr/io/team/loop/review/infrastructure/persistence/ExposedReviewRepository.kt @@ -18,6 +18,7 @@ import org.jetbrains.exposed.v1.core.count import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.greaterEq import org.jetbrains.exposed.v1.core.lessEq +import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.selectAll import org.springframework.stereotype.Repository @@ -25,6 +26,10 @@ import java.time.OffsetDateTime @Repository class ExposedReviewRepository : ReviewRepository { + override fun delete(command: ReviewCommand.Delete) { + ReviewTable.deleteWhere { reviewId eq command.reviewId.value } + } + override fun save(command: ReviewCommand.Create): Review { val now = OffsetDateTime.now() val periodKey = PeriodKey.daily(command.date) diff --git a/src/main/kotlin/kr/io/team/loop/review/presentation/datafetcher/ReviewDataFetcher.kt b/src/main/kotlin/kr/io/team/loop/review/presentation/datafetcher/ReviewDataFetcher.kt index 8396c80..c0baa55 100644 --- a/src/main/kotlin/kr/io/team/loop/review/presentation/datafetcher/ReviewDataFetcher.kt +++ b/src/main/kotlin/kr/io/team/loop/review/presentation/datafetcher/ReviewDataFetcher.kt @@ -15,6 +15,7 @@ import kr.io.team.loop.common.domain.MemberId import kr.io.team.loop.review.application.service.ReviewService import kr.io.team.loop.review.domain.model.Review import kr.io.team.loop.review.domain.model.ReviewCommand +import kr.io.team.loop.review.domain.model.ReviewId import kr.io.team.loop.review.domain.model.ReviewQuery import kr.io.team.loop.review.domain.model.ReviewStep import kotlin.time.Clock @@ -82,6 +83,16 @@ class ReviewDataFetcher( return reviewService.create(command).toGraphql() } + @DgsMutation + fun deleteReview( + @InputArgument id: String, + @Authorize memberId: Long, + ): Boolean { + val command = ReviewCommand.Delete(reviewId = ReviewId(id.toLong())) + reviewService.delete(command, MemberId(memberId)) + return true + } + private fun Review.toGraphql(): ReviewGraphql = ReviewGraphql( id = id.value.toString(), diff --git a/src/main/resources/schema/review.graphqls b/src/main/resources/schema/review.graphqls index ed93d43..6387a35 100644 --- a/src/main/resources/schema/review.graphqls +++ b/src/main/resources/schema/review.graphqls @@ -15,6 +15,12 @@ extend type Mutation { "생성할 회고 정보" input: CreateReviewInput! ): Review! + + "회고를 삭제한다. (본인 회고만)" + deleteReview( + "삭제할 회고 ID" + id: ID! + ): Boolean! } """회고""" diff --git a/src/test/kotlin/kr/io/team/loop/review/application/service/ReviewServiceTest.kt b/src/test/kotlin/kr/io/team/loop/review/application/service/ReviewServiceTest.kt index df068c4..11a5c7c 100644 --- a/src/test/kotlin/kr/io/team/loop/review/application/service/ReviewServiceTest.kt +++ b/src/test/kotlin/kr/io/team/loop/review/application/service/ReviewServiceTest.kt @@ -5,10 +5,14 @@ import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.mockk.every +import io.mockk.justRun import io.mockk.mockk +import io.mockk.verify import kotlinx.datetime.LocalDate import kr.io.team.loop.common.domain.MemberId +import kr.io.team.loop.common.domain.exception.AccessDeniedException import kr.io.team.loop.common.domain.exception.DuplicateEntityException +import kr.io.team.loop.common.domain.exception.EntityNotFoundException import kr.io.team.loop.review.domain.model.PeriodKey import kr.io.team.loop.review.domain.model.Review import kr.io.team.loop.review.domain.model.ReviewCommand @@ -208,4 +212,41 @@ class ReviewServiceTest : } } } + + Given("회고 삭제 시") { + val reviewId = ReviewId(1L) + val command = ReviewCommand.Delete(reviewId = reviewId) + + When("본인 회고이면") { + every { reviewRepository.findById(reviewId) } returns savedReview + justRun { reviewRepository.delete(command) } + + reviewService.delete(command, memberId) + + Then("삭제가 수행된다") { + verify { reviewRepository.delete(command) } + } + } + + When("회고가 존재하지 않으면") { + every { reviewRepository.findById(reviewId) } returns null + + Then("EntityNotFoundException이 발생한다") { + shouldThrow { + reviewService.delete(command, memberId) + } + } + } + + When("다른 사용자의 회고이면") { + val otherMemberId = MemberId(99L) + every { reviewRepository.findById(reviewId) } returns savedReview + + Then("AccessDeniedException이 발생한다") { + shouldThrow { + reviewService.delete(command, otherMemberId) + } + } + } + } })