Skip to content

Commit 58b2763

Browse files
robinjoonclaude
andauthored
feat: Review 수정 Mutation 추가 (#19) (#24)
updateReview Mutation을 추가하여 기존 회고의 KPT 단계별 내용을 수정할 수 있도록 한다. 본인 회고만 수정 가능하며, 소유권 검증을 포함한다. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 93c8051 commit 58b2763

11 files changed

Lines changed: 160 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Review 수정 Mutation 검증 체크리스트
2+
3+
## 필수 항목
4+
- [x] 아키텍처 원칙 준수 (docs/architecture.md 기준)
5+
- [x] 레이어 의존성 규칙 위반 없음
6+
- [x] 테스트 코드 작성 완료 (Domain, Application 필수)
7+
- [x] 모든 테스트 통과
8+
- [x] 기존 테스트 깨지지 않음
9+
10+
## 선택 항목 (해당 시)
11+
- [x] 본인 회고만 수정 가능 (소유권 검증)
12+
- [x] DGS Codegen 타입 자동 생성 확인
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Review 수정 Mutation 추가 계획
2+
3+
> Issue: #19
4+
5+
## 단계
6+
7+
- [x] 1단계: Domain — ReviewCommand.Update 추가 + Review 도메인 테스트
8+
- [x] 2단계: Domain — ReviewRepository에 update 메서드 추가
9+
- [x] 3단계: Application — ReviewService.update 구현 (소유권 검증 포함) + 테스트
10+
- [x] 4단계: GraphQL 스키마 — UpdateReviewInput, updateReview Mutation 추가
11+
- [x] 5단계: Infrastructure — ExposedReviewRepository.update 구현
12+
- [x] 6단계: Presentation — ReviewDataFetcher.updateReview 구현
13+
- [x] 7단계: 빌드 및 전체 테스트 통과 확인

src/main/kotlin/kr/io/team/loop/review/application/service/ReviewService.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,20 @@ class ReviewService(
4141
throw DuplicateEntityException("Review already exists for this period")
4242
}
4343

44+
@Transactional
45+
fun update(
46+
command: ReviewCommand.Update,
47+
memberId: MemberId,
48+
): Review {
49+
val review =
50+
reviewRepository.findById(command.reviewId)
51+
?: throw EntityNotFoundException("Review not found: ${command.reviewId.value}")
52+
if (!review.isOwnedBy(memberId)) {
53+
throw AccessDeniedException("Cannot update other member's review")
54+
}
55+
return reviewRepository.update(command)
56+
}
57+
4458
@Transactional(readOnly = true)
4559
fun findAll(query: ReviewQuery): List<Review> {
4660
val reviews = reviewRepository.findAll(query.copy(stepType = null))

src/main/kotlin/kr/io/team/loop/review/domain/model/Review.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,6 @@ data class Review(
1818
fun isOwnedBy(memberId: MemberId): Boolean = this.memberId == memberId
1919

2020
fun containsStepType(stepType: StepType): Boolean = steps.any { it.type == stepType }
21+
22+
fun withUpdatedSteps(newSteps: List<ReviewStep>): Review = copy(steps = newSteps)
2123
}

src/main/kotlin/kr/io/team/loop/review/domain/model/ReviewCommand.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ sealed interface ReviewCommand {
1010
val date: LocalDate,
1111
) : ReviewCommand
1212

13+
data class Update(
14+
val reviewId: ReviewId,
15+
val steps: List<ReviewStep>,
16+
) : ReviewCommand
17+
1318
data class Delete(
1419
val reviewId: ReviewId,
1520
) : ReviewCommand

src/main/kotlin/kr/io/team/loop/review/domain/repository/ReviewRepository.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import kr.io.team.loop.review.domain.model.ReviewQuery
88
interface ReviewRepository {
99
fun save(command: ReviewCommand.Create): Review
1010

11+
fun update(command: ReviewCommand.Update): Review
12+
1113
fun delete(command: ReviewCommand.Delete)
1214

1315
fun findAll(query: ReviewQuery): List<Review>

src/main/kotlin/kr/io/team/loop/review/infrastructure/persistence/ExposedReviewRepository.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import org.jetbrains.exposed.v1.core.lessEq
2121
import org.jetbrains.exposed.v1.jdbc.deleteWhere
2222
import org.jetbrains.exposed.v1.jdbc.insert
2323
import org.jetbrains.exposed.v1.jdbc.selectAll
24+
import org.jetbrains.exposed.v1.jdbc.update
2425
import org.springframework.stereotype.Repository
2526
import java.time.OffsetDateTime
2627

@@ -58,6 +59,18 @@ class ExposedReviewRepository : ReviewRepository {
5859
)
5960
}
6061

62+
override fun update(command: ReviewCommand.Update): Review {
63+
val now = OffsetDateTime.now()
64+
val stepsJson = command.steps.map { StepJson(type = it.type.name, content = it.content) }
65+
66+
ReviewTable.update({ ReviewTable.reviewId eq command.reviewId.value }) {
67+
it[steps] = stepsJson
68+
it[updatedAt] = now
69+
}
70+
71+
return findById(command.reviewId)!!
72+
}
73+
6174
override fun findAll(query: ReviewQuery): List<Review> {
6275
var condition: Op<Boolean> = Op.TRUE
6376
query.memberId?.let { condition = condition and (ReviewTable.memberId eq it.value) }

src/main/kotlin/kr/io/team/loop/review/presentation/datafetcher/ReviewDataFetcher.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import kotlinx.datetime.toLocalDateTime
1010
import kr.io.team.loop.codegen.types.CreateReviewInput
1111
import kr.io.team.loop.codegen.types.ReviewFilter
1212
import kr.io.team.loop.codegen.types.ReviewStepOutput
13+
import kr.io.team.loop.codegen.types.UpdateReviewInput
1314
import kr.io.team.loop.common.config.Authorize
1415
import kr.io.team.loop.common.domain.MemberId
1516
import kr.io.team.loop.review.application.service.ReviewService
@@ -83,6 +84,25 @@ class ReviewDataFetcher(
8384
return reviewService.create(command).toGraphql()
8485
}
8586

87+
@DgsMutation
88+
fun updateReview(
89+
@InputArgument input: UpdateReviewInput,
90+
@Authorize memberId: Long,
91+
): ReviewGraphql {
92+
val command =
93+
ReviewCommand.Update(
94+
reviewId = ReviewId(input.id.toLong()),
95+
steps =
96+
input.steps.map { step ->
97+
ReviewStep(
98+
type = StepTypeDomain.valueOf(step.type.name),
99+
content = step.content,
100+
)
101+
},
102+
)
103+
return reviewService.update(command, MemberId(memberId)).toGraphql()
104+
}
105+
86106
@DgsMutation
87107
fun deleteReview(
88108
@InputArgument id: String,

src/main/resources/schema/review.graphqls

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ extend type Mutation {
1616
input: CreateReviewInput!
1717
): Review!
1818

19+
"기존 KPT 회고의 단계별 내용을 수정한다. 본인 회고만 수정 가능. (인증 필수)"
20+
updateReview(
21+
"수정할 회고 정보"
22+
input: UpdateReviewInput!
23+
): Review!
24+
1925
"회고를 삭제한다. (본인 회고만)"
2026
deleteReview(
2127
"삭제할 회고 ID"
@@ -95,6 +101,14 @@ input CreateReviewInput {
95101
date: String!
96102
}
97103

104+
"""회고 수정 입력"""
105+
input UpdateReviewInput {
106+
"수정할 회고 ID"
107+
id: ID!
108+
"수정할 회고 단계 목록 (최소 1개)"
109+
steps: [ReviewStepInput!]!
110+
}
111+
98112
"""회고 단계 입력"""
99113
input ReviewStepInput {
100114
"단계 유형"

src/test/kotlin/kr/io/team/loop/review/application/service/ReviewServiceTest.kt

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,53 @@ class ReviewServiceTest :
9595
}
9696
}
9797

98+
Given("회고 수정 시") {
99+
val updateCommand =
100+
ReviewCommand.Update(
101+
reviewId = ReviewId(1L),
102+
steps =
103+
listOf(
104+
ReviewStep(type = StepType.KEEP, content = "수정된 좋은 점"),
105+
ReviewStep(type = StepType.TRY, content = "수정된 다짐"),
106+
),
107+
)
108+
109+
When("본인 회고를 수정하면") {
110+
every { reviewRepository.findById(ReviewId(1L)) } returns savedReview
111+
val updatedReview = savedReview.withUpdatedSteps(updateCommand.steps)
112+
every { reviewRepository.update(updateCommand) } returns updatedReview
113+
114+
val result = reviewService.update(updateCommand, memberId)
115+
116+
Then("수정된 회고를 반환한다") {
117+
result.steps shouldHaveSize 2
118+
result.steps[0].content shouldBe "수정된 좋은 점"
119+
result.steps[1].content shouldBe "수정된 다짐"
120+
}
121+
}
122+
123+
When("존재하지 않는 회고를 수정하면") {
124+
every { reviewRepository.findById(ReviewId(1L)) } returns null
125+
126+
Then("EntityNotFoundException이 발생한다") {
127+
shouldThrow<EntityNotFoundException> {
128+
reviewService.update(updateCommand, memberId)
129+
}
130+
}
131+
}
132+
133+
When("다른 사용자의 회고를 수정하면") {
134+
every { reviewRepository.findById(ReviewId(1L)) } returns savedReview
135+
val otherMemberId = MemberId(99L)
136+
137+
Then("AccessDeniedException이 발생한다") {
138+
shouldThrow<AccessDeniedException> {
139+
reviewService.update(updateCommand, otherMemberId)
140+
}
141+
}
142+
}
143+
}
144+
98145
Given("회고 목록 조회 시") {
99146
When("해당 사용자의 회고가 있으면") {
100147
val query = ReviewQuery(memberId = memberId)

0 commit comments

Comments
 (0)