diff --git a/docs/plan/#19-review-update/checklist.md b/docs/plan/#19-review-update/checklist.md new file mode 100644 index 0000000..624eb3b --- /dev/null +++ b/docs/plan/#19-review-update/checklist.md @@ -0,0 +1,12 @@ +# Review 수정 Mutation 검증 체크리스트 + +## 필수 항목 +- [x] 아키텍처 원칙 준수 (docs/architecture.md 기준) +- [x] 레이어 의존성 규칙 위반 없음 +- [x] 테스트 코드 작성 완료 (Domain, Application 필수) +- [x] 모든 테스트 통과 +- [x] 기존 테스트 깨지지 않음 + +## 선택 항목 (해당 시) +- [x] 본인 회고만 수정 가능 (소유권 검증) +- [x] DGS Codegen 타입 자동 생성 확인 diff --git a/docs/plan/#19-review-update/plan.md b/docs/plan/#19-review-update/plan.md new file mode 100644 index 0000000..65c61e7 --- /dev/null +++ b/docs/plan/#19-review-update/plan.md @@ -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단계: 빌드 및 전체 테스트 통과 확인 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 0a4b1db..a688fd3 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 @@ -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 { val reviews = reviewRepository.findAll(query.copy(stepType = null)) diff --git a/src/main/kotlin/kr/io/team/loop/review/domain/model/Review.kt b/src/main/kotlin/kr/io/team/loop/review/domain/model/Review.kt index 4e834ac..c5d5bb0 100644 --- a/src/main/kotlin/kr/io/team/loop/review/domain/model/Review.kt +++ b/src/main/kotlin/kr/io/team/loop/review/domain/model/Review.kt @@ -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): Review = copy(steps = newSteps) } 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 30f5bc2..4bbf04f 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 @@ -10,6 +10,11 @@ sealed interface ReviewCommand { val date: LocalDate, ) : ReviewCommand + data class Update( + val reviewId: ReviewId, + val steps: List, + ) : 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 4e9bae6..02976b8 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 update(command: ReviewCommand.Update): Review + fun delete(command: ReviewCommand.Delete) fun findAll(query: ReviewQuery): List 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 a342287..c175da3 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 @@ -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 @@ -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 { var condition: Op = Op.TRUE query.memberId?.let { condition = condition and (ReviewTable.memberId eq it.value) } 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 c0baa55..e478f3a 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 @@ -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 @@ -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, diff --git a/src/main/resources/schema/review.graphqls b/src/main/resources/schema/review.graphqls index 6387a35..48e69a3 100644 --- a/src/main/resources/schema/review.graphqls +++ b/src/main/resources/schema/review.graphqls @@ -16,6 +16,12 @@ extend type Mutation { input: CreateReviewInput! ): Review! + "기존 KPT 회고의 단계별 내용을 수정한다. 본인 회고만 수정 가능. (인증 필수)" + updateReview( + "수정할 회고 정보" + input: UpdateReviewInput! + ): Review! + "회고를 삭제한다. (본인 회고만)" deleteReview( "삭제할 회고 ID" @@ -95,6 +101,14 @@ input CreateReviewInput { date: String! } +"""회고 수정 입력""" +input UpdateReviewInput { + "수정할 회고 ID" + id: ID! + "수정할 회고 단계 목록 (최소 1개)" + steps: [ReviewStepInput!]! +} + """회고 단계 입력""" input ReviewStepInput { "단계 유형" 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 11a5c7c..b7ca501 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 @@ -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 { + reviewService.update(updateCommand, memberId) + } + } + } + + When("다른 사용자의 회고를 수정하면") { + every { reviewRepository.findById(ReviewId(1L)) } returns savedReview + val otherMemberId = MemberId(99L) + + Then("AccessDeniedException이 발생한다") { + shouldThrow { + reviewService.update(updateCommand, otherMemberId) + } + } + } + } + Given("회고 목록 조회 시") { When("해당 사용자의 회고가 있으면") { val query = ReviewQuery(memberId = memberId) diff --git a/src/test/kotlin/kr/io/team/loop/review/domain/model/ReviewTest.kt b/src/test/kotlin/kr/io/team/loop/review/domain/model/ReviewTest.kt index ee304ad..8211de8 100644 --- a/src/test/kotlin/kr/io/team/loop/review/domain/model/ReviewTest.kt +++ b/src/test/kotlin/kr/io/team/loop/review/domain/model/ReviewTest.kt @@ -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를 반환한다") {