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
12 changes: 12 additions & 0 deletions docs/plan/#19-review-update/checklist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Review 수정 Mutation 검증 체크리스트

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

## 선택 항목 (해당 시)
- [x] 본인 회고만 수정 가능 (소유권 검증)
- [x] DGS Codegen 타입 자동 생성 확인
13 changes: 13 additions & 0 deletions docs/plan/#19-review-update/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Review 수정 Mutation 추가 계획

> Issue: #19

## 단계

- [x] 1단계: Domain — ReviewCommand.Update 추가 + Review 도메인 테스트
- [x] 2단계: Domain — ReviewRepository에 update 메서드 추가
- [x] 3단계: Application — ReviewService.update 구현 (소유권 검증 포함) + 테스트
- [x] 4단계: GraphQL 스키마 — UpdateReviewInput, updateReview Mutation 추가
- [x] 5단계: Infrastructure — ExposedReviewRepository.update 구현
- [x] 6단계: Presentation — ReviewDataFetcher.updateReview 구현
- [x] 7단계: 빌드 및 전체 테스트 통과 확인
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ class ReviewService(
throw DuplicateEntityException("Review already exists for this period")
}

@Transactional
fun update(
command: ReviewCommand.Update,
memberId: MemberId,
): Review {
val review =
reviewRepository.findById(command.reviewId)
?: throw EntityNotFoundException("Review not found: ${command.reviewId.value}")
if (!review.isOwnedBy(memberId)) {
throw AccessDeniedException("Cannot update other member's review")
}
return reviewRepository.update(command)
}

@Transactional(readOnly = true)
fun findAll(query: ReviewQuery): List<Review> {
val reviews = reviewRepository.findAll(query.copy(stepType = null))
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/kr/io/team/loop/review/domain/model/Review.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ data class Review(
fun isOwnedBy(memberId: MemberId): Boolean = this.memberId == memberId

fun containsStepType(stepType: StepType): Boolean = steps.any { it.type == stepType }

fun withUpdatedSteps(newSteps: List<ReviewStep>): Review = copy(steps = newSteps)
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ sealed interface ReviewCommand {
val date: LocalDate,
) : ReviewCommand

data class Update(
val reviewId: ReviewId,
val steps: List<ReviewStep>,
) : ReviewCommand

data class Delete(
val reviewId: ReviewId,
) : ReviewCommand
Expand Down
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 update(command: ReviewCommand.Update): Review

fun delete(command: ReviewCommand.Delete)

fun findAll(query: ReviewQuery): List<Review>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ 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.jetbrains.exposed.v1.jdbc.update
import org.springframework.stereotype.Repository
import java.time.OffsetDateTime

Expand Down Expand Up @@ -58,6 +59,18 @@ class ExposedReviewRepository : ReviewRepository {
)
}

override fun update(command: ReviewCommand.Update): Review {
val now = OffsetDateTime.now()
val stepsJson = command.steps.map { StepJson(type = it.type.name, content = it.content) }

ReviewTable.update({ ReviewTable.reviewId eq command.reviewId.value }) {
it[steps] = stepsJson
it[updatedAt] = now
}

return findById(command.reviewId)!!
}

override fun findAll(query: ReviewQuery): List<Review> {
var condition: Op<Boolean> = Op.TRUE
query.memberId?.let { condition = condition and (ReviewTable.memberId eq it.value) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import kotlinx.datetime.toLocalDateTime
import kr.io.team.loop.codegen.types.CreateReviewInput
import kr.io.team.loop.codegen.types.ReviewFilter
import kr.io.team.loop.codegen.types.ReviewStepOutput
import kr.io.team.loop.codegen.types.UpdateReviewInput
import kr.io.team.loop.common.config.Authorize
import kr.io.team.loop.common.domain.MemberId
import kr.io.team.loop.review.application.service.ReviewService
Expand Down Expand Up @@ -83,6 +84,25 @@ class ReviewDataFetcher(
return reviewService.create(command).toGraphql()
}

@DgsMutation
fun updateReview(
@InputArgument input: UpdateReviewInput,
@Authorize memberId: Long,
): ReviewGraphql {
val command =
ReviewCommand.Update(
reviewId = ReviewId(input.id.toLong()),
steps =
input.steps.map { step ->
ReviewStep(
type = StepTypeDomain.valueOf(step.type.name),
content = step.content,
)
},
)
return reviewService.update(command, MemberId(memberId)).toGraphql()
}

@DgsMutation
fun deleteReview(
@InputArgument id: String,
Expand Down
14 changes: 14 additions & 0 deletions src/main/resources/schema/review.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ extend type Mutation {
input: CreateReviewInput!
): Review!

"기존 KPT 회고의 단계별 내용을 수정한다. 본인 회고만 수정 가능. (인증 필수)"
updateReview(
"수정할 회고 정보"
input: UpdateReviewInput!
): Review!

"회고를 삭제한다. (본인 회고만)"
deleteReview(
"삭제할 회고 ID"
Expand Down Expand Up @@ -95,6 +101,14 @@ input CreateReviewInput {
date: String!
}

"""회고 수정 입력"""
input UpdateReviewInput {
"수정할 회고 ID"
id: ID!
"수정할 회고 단계 목록 (최소 1개)"
steps: [ReviewStepInput!]!
}

"""회고 단계 입력"""
input ReviewStepInput {
"단계 유형"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,53 @@ class ReviewServiceTest :
}
}

Given("회고 수정 시") {
val updateCommand =
ReviewCommand.Update(
reviewId = ReviewId(1L),
steps =
listOf(
ReviewStep(type = StepType.KEEP, content = "수정된 좋은 점"),
ReviewStep(type = StepType.TRY, content = "수정된 다짐"),
),
)

When("본인 회고를 수정하면") {
every { reviewRepository.findById(ReviewId(1L)) } returns savedReview
val updatedReview = savedReview.withUpdatedSteps(updateCommand.steps)
every { reviewRepository.update(updateCommand) } returns updatedReview

val result = reviewService.update(updateCommand, memberId)

Then("수정된 회고를 반환한다") {
result.steps shouldHaveSize 2
result.steps[0].content shouldBe "수정된 좋은 점"
result.steps[1].content shouldBe "수정된 다짐"
}
}

When("존재하지 않는 회고를 수정하면") {
every { reviewRepository.findById(ReviewId(1L)) } returns null

Then("EntityNotFoundException이 발생한다") {
shouldThrow<EntityNotFoundException> {
reviewService.update(updateCommand, memberId)
}
}
}

When("다른 사용자의 회고를 수정하면") {
every { reviewRepository.findById(ReviewId(1L)) } returns savedReview
val otherMemberId = MemberId(99L)

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

Given("회고 목록 조회 시") {
When("해당 사용자의 회고가 있으면") {
val query = ReviewQuery(memberId = memberId)
Expand Down
18 changes: 18 additions & 0 deletions src/test/kotlin/kr/io/team/loop/review/domain/model/ReviewTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@ class ReviewTest :
}
}

Given("Review 수정 시") {
When("새로운 steps로 수정하면") {
val newSteps =
listOf(
ReviewStep(type = StepType.KEEP, content = "수정된 좋은 점"),
ReviewStep(type = StepType.TRY, content = "수정된 다짐"),
)
val updated = review.withUpdatedSteps(newSteps)

Then("steps가 변경된 새 Review를 반환한다") {
updated.steps shouldBe newSteps
updated.id shouldBe review.id
updated.memberId shouldBe review.memberId
updated.startDate shouldBe review.startDate
}
}
}

Given("Review에 특정 StepType 포함 여부 확인 시") {
When("KEEP이 포함되어 있으면") {
Then("true를 반환한다") {
Expand Down