From 570d82d4946b5ba01ab0021fcd2bc411125155c1 Mon Sep 17 00:00:00 2001 From: robinjoon Date: Fri, 6 Mar 2026 20:45:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Review=20=EC=88=98=EC=A0=95=20Mutation?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updateReview Mutation을 추가하여 기존 회고의 KPT 단계별 내용을 수정할 수 있도록 한다. 본인 회고만 수정 가능하며, 소유권 검증을 포함한다. Co-Authored-By: Claude Opus 4.6 --- docs/plan/#19-review-update/checklist.md | 12 +++++ docs/plan/#19-review-update/plan.md | 13 +++++ .../application/service/ReviewService.kt | 16 ++++++ .../team/loop/review/domain/model/Review.kt | 2 + .../loop/review/domain/model/ReviewCommand.kt | 5 ++ .../domain/repository/ReviewRepository.kt | 2 + .../persistence/ExposedReviewRepository.kt | 13 +++++ .../datafetcher/ReviewDataFetcher.kt | 21 ++++++++ src/main/resources/schema/review.graphqls | 14 ++++++ .../application/service/ReviewServiceTest.kt | 49 +++++++++++++++++++ .../loop/review/domain/model/ReviewTest.kt | 18 +++++++ 11 files changed, 165 insertions(+) create mode 100644 docs/plan/#19-review-update/checklist.md create mode 100644 docs/plan/#19-review-update/plan.md 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 eb502e7..28447bb 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 @@ -25,6 +27,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 1dd54fd..7a88edf 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,9 @@ sealed interface ReviewCommand { val steps: List, val date: LocalDate, ) : ReviewCommand + + data class Update( + val reviewId: ReviewId, + val steps: List, + ) : 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..4a70fe0 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 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..4610c74 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 @@ -20,6 +20,7 @@ import org.jetbrains.exposed.v1.core.greaterEq import org.jetbrains.exposed.v1.core.lessEq 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 @@ -53,6 +54,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 8396c80..5314c9f 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,11 +10,13 @@ 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 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 +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() + } + 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..c68bb37 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! + + "기존 KPT 회고의 단계별 내용을 수정한다. 본인 회고만 수정 가능. (인증 필수)" + updateReview( + "수정할 회고 정보" + input: UpdateReviewInput! + ): Review! } """회고""" @@ -89,6 +95,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 df068c4..6b7d77b 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 @@ -8,7 +8,9 @@ import io.mockk.every import io.mockk.mockk 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 @@ -91,6 +93,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를 반환한다") {