Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/plan/#20-review-delete/checklist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Review 삭제 Mutation 검증 체크리스트

## 필수 항목
- [x] 아키텍처 원칙 준수 (docs/architecture.md 기준)
- [x] 레이어 의존성 규칙 위반 없음
- [x] 테스트 코드 작성 완료 (Domain, Application 필수)
- [x] 모든 테스트 통과
- [x] 기존 테스트 깨지지 않음

## 선택 항목 (해당 시)
- [x] 소유권 검증 (본인 회고만 삭제 가능)
15 changes: 15 additions & 0 deletions docs/plan/#20-review-delete/plan.md
Original file line number Diff line number Diff line change
@@ -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단계: 전체 테스트 통과 확인
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,8 @@ sealed interface ReviewCommand {
val steps: List<ReviewStep>,
val date: LocalDate,
) : ReviewCommand

data class Delete(
val reviewId: ReviewId,
) : ReviewCommand
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Review>

fun findById(id: kr.io.team.loop.review.domain.model.ReviewId): Review?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,18 @@ 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
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
6 changes: 6 additions & 0 deletions src/main/resources/schema/review.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ extend type Mutation {
"생성할 회고 정보"
input: CreateReviewInput!
): Review!

"회고를 삭제한다. (본인 회고만)"
deleteReview(
"삭제할 회고 ID"
id: ID!
): Boolean!
}

"""회고"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<EntityNotFoundException> {
reviewService.delete(command, memberId)
}
}
}

When("다른 사용자의 회고이면") {
val otherMemberId = MemberId(99L)
every { reviewRepository.findById(reviewId) } returns savedReview

Then("AccessDeniedException이 발생한다") {
shouldThrow<AccessDeniedException> {
reviewService.delete(command, otherMemberId)
}
}
}
}
})