diff --git a/docs/plan/#21-task-title-update/checklist.md b/docs/plan/#21-task-title-update/checklist.md new file mode 100644 index 0000000..3b052fa --- /dev/null +++ b/docs/plan/#21-task-title-update/checklist.md @@ -0,0 +1,16 @@ +# Task 제목 수정 검증 체크리스트 + +## 필수 항목 +- [x] 아키텍처 원칙 준수 (docs/architecture.md 기준) +- [x] 레이어 의존성 규칙 위반 없음 +- [x] 테스트 코드 작성 완료 (Domain, Application 필수) +- [x] 모든 테스트 통과 +- [x] 기존 테스트 깨지지 않음 + +## 선택 항목 +- [x] 소유권 검증 (본인 할일만 수정 가능) + +## PR 리뷰 반영 +- [x] updateTaskStatus + updateTaskTitle → updateTask 통합 +- [x] UpdateTaskInput에 title?, status? optional 필드 +- [x] 기존 updateTaskStatus 관련 코드 제거 diff --git a/docs/plan/#21-task-title-update/plan.md b/docs/plan/#21-task-title-update/plan.md new file mode 100644 index 0000000..31f5086 --- /dev/null +++ b/docs/plan/#21-task-title-update/plan.md @@ -0,0 +1,21 @@ +# Task 제목 수정 Mutation 추가 계획 + +> Issue: #21 + +## 단계 + +- [x] 1단계: Domain — TaskCommand.UpdateTitle 추가 +- [x] 2단계: Domain — TaskRepository.updateTitle 추가 +- [x] 3단계: Application — TaskService.updateTitle TDD (RED → GREEN → REFACTOR) +- [x] 4단계: Infrastructure — ExposedTaskRepository.updateTitle 구현 +- [x] 5단계: Presentation — GraphQL 스키마 + DataFetcher 추가 +- [x] 6단계: DGS Codegen 빌드 및 전체 테스트 검증 + +## PR 리뷰 반영 — updateTask 통합 리팩토링 + +- [x] 7단계: Domain — TaskCommand.UpdateStatus + UpdateTitle → Update 통합 +- [x] 8단계: Domain — TaskRepository.updateStatus + updateTitle → update 통합 +- [x] 9단계: Application — TaskService TDD (기존 테스트 수정 + 새 케이스) +- [x] 10단계: Infrastructure — ExposedTaskRepository.update 통합 구현 +- [x] 11단계: Presentation — GraphQL 스키마 updateTask 통합 + DataFetcher 수정 +- [x] 12단계: DGS Codegen 빌드 및 전체 테스트 검증 diff --git a/src/main/kotlin/kr/io/team/loop/task/application/service/TaskService.kt b/src/main/kotlin/kr/io/team/loop/task/application/service/TaskService.kt index 9df1635..cf352df 100644 --- a/src/main/kotlin/kr/io/team/loop/task/application/service/TaskService.kt +++ b/src/main/kotlin/kr/io/team/loop/task/application/service/TaskService.kt @@ -21,8 +21,8 @@ class TaskService( fun findAll(query: TaskQuery): List = taskRepository.findAll(query) @Transactional - fun updateStatus( - command: TaskCommand.UpdateStatus, + fun update( + command: TaskCommand.Update, memberId: MemberId, ): Task { val task = @@ -31,7 +31,7 @@ class TaskService( if (!task.isOwnedBy(memberId)) { throw AccessDeniedException("Task does not belong to member: ${memberId.value}") } - return taskRepository.updateStatus(command) + return taskRepository.update(command) } @Transactional diff --git a/src/main/kotlin/kr/io/team/loop/task/domain/model/TaskCommand.kt b/src/main/kotlin/kr/io/team/loop/task/domain/model/TaskCommand.kt index 4f52a39..6447389 100644 --- a/src/main/kotlin/kr/io/team/loop/task/domain/model/TaskCommand.kt +++ b/src/main/kotlin/kr/io/team/loop/task/domain/model/TaskCommand.kt @@ -13,9 +13,10 @@ sealed interface TaskCommand { val taskDate: LocalDate, ) : TaskCommand - data class UpdateStatus( + data class Update( val taskId: TaskId, - val status: TaskStatus, + val title: TaskTitle? = null, + val status: TaskStatus? = null, ) : TaskCommand data class Delete( diff --git a/src/main/kotlin/kr/io/team/loop/task/domain/repository/TaskRepository.kt b/src/main/kotlin/kr/io/team/loop/task/domain/repository/TaskRepository.kt index 1c33bf0..78a43ea 100644 --- a/src/main/kotlin/kr/io/team/loop/task/domain/repository/TaskRepository.kt +++ b/src/main/kotlin/kr/io/team/loop/task/domain/repository/TaskRepository.kt @@ -8,7 +8,7 @@ import kr.io.team.loop.task.domain.model.TaskQuery interface TaskRepository { fun save(command: TaskCommand.Create): Task - fun updateStatus(command: TaskCommand.UpdateStatus): Task + fun update(command: TaskCommand.Update): Task fun delete(command: TaskCommand.Delete) diff --git a/src/main/kotlin/kr/io/team/loop/task/infrastructure/persistence/ExposedTaskRepository.kt b/src/main/kotlin/kr/io/team/loop/task/infrastructure/persistence/ExposedTaskRepository.kt index 98ec23c..6627715 100644 --- a/src/main/kotlin/kr/io/team/loop/task/infrastructure/persistence/ExposedTaskRepository.kt +++ b/src/main/kotlin/kr/io/team/loop/task/infrastructure/persistence/ExposedTaskRepository.kt @@ -48,10 +48,11 @@ class ExposedTaskRepository : TaskRepository { ) } - override fun updateStatus(command: TaskCommand.UpdateStatus): Task { + override fun update(command: TaskCommand.Update): Task { val now = OffsetDateTime.now() TaskTable.update({ TaskTable.taskId eq command.taskId.value }) { - it[status] = command.status.name + command.title?.let { newTitle -> it[title] = newTitle.value } + command.status?.let { newStatus -> it[status] = newStatus.name } it[updatedAt] = now } return findById(command.taskId)!! diff --git a/src/main/kotlin/kr/io/team/loop/task/presentation/datafetcher/TaskDataFetcher.kt b/src/main/kotlin/kr/io/team/loop/task/presentation/datafetcher/TaskDataFetcher.kt index 38ef7de..04a113e 100644 --- a/src/main/kotlin/kr/io/team/loop/task/presentation/datafetcher/TaskDataFetcher.kt +++ b/src/main/kotlin/kr/io/team/loop/task/presentation/datafetcher/TaskDataFetcher.kt @@ -7,7 +7,7 @@ import com.netflix.graphql.dgs.InputArgument import kotlinx.datetime.LocalDate import kr.io.team.loop.codegen.types.CreateTaskInput import kr.io.team.loop.codegen.types.TaskFilter -import kr.io.team.loop.codegen.types.UpdateTaskStatusInput +import kr.io.team.loop.codegen.types.UpdateTaskInput import kr.io.team.loop.common.config.Authorize import kr.io.team.loop.common.domain.GoalId import kr.io.team.loop.common.domain.MemberId @@ -56,16 +56,17 @@ class TaskDataFetcher( } @DgsMutation - fun updateTaskStatus( - @InputArgument input: UpdateTaskStatusInput, + fun updateTask( + @InputArgument input: UpdateTaskInput, @Authorize memberId: Long, ): TaskGraphql { val command = - TaskCommand.UpdateStatus( + TaskCommand.Update( taskId = TaskId(input.id.toLong()), - status = TaskStatus.valueOf(input.status.name), + title = input.title?.let { TaskTitle(it) }, + status = input.status?.let { TaskStatus.valueOf(it.name) }, ) - return taskService.updateStatus(command, MemberId(memberId)).toGraphql() + return taskService.update(command, MemberId(memberId)).toGraphql() } @DgsMutation diff --git a/src/main/resources/schema/task.graphqls b/src/main/resources/schema/task.graphqls index fa6b010..f2e9d47 100644 --- a/src/main/resources/schema/task.graphqls +++ b/src/main/resources/schema/task.graphqls @@ -13,10 +13,10 @@ extend type Mutation { input: CreateTaskInput! ): Task! - "할일의 완료/미완료 상태를 변경한다. (본인 할일만)" - updateTaskStatus( - "상태 변경 정보" - input: UpdateTaskStatusInput! + "할일을 수정한다. 제목, 상태 등 변경할 필드만 전달한다. (본인 할일만)" + updateTask( + "수정할 할일 정보" + input: UpdateTaskInput! ): Task! "할일을 삭제한다. (본인 할일만)" @@ -72,10 +72,12 @@ input CreateTaskInput { date: String! } -"""할일 상태 변경 입력""" -input UpdateTaskStatusInput { - "변경할 할일 ID" +"""할일 수정 입력. 변경할 필드만 전달한다.""" +input UpdateTaskInput { + "수정할 할일 ID" id: ID! - "변경할 상태" - status: TaskStatus! + "새 제목 (1~200자, 선택)" + title: String + "새 상태 (선택)" + status: TaskStatus } diff --git a/src/test/kotlin/kr/io/team/loop/task/application/service/TaskServiceTest.kt b/src/test/kotlin/kr/io/team/loop/task/application/service/TaskServiceTest.kt index 57b443e..de6b7e2 100644 --- a/src/test/kotlin/kr/io/team/loop/task/application/service/TaskServiceTest.kt +++ b/src/test/kotlin/kr/io/team/loop/task/application/service/TaskServiceTest.kt @@ -91,39 +91,70 @@ class TaskServiceTest : } } - Given("할일 상태 변경 시") { - When("본인 할일이면") { + Given("할일 수정 시") { + When("본인 할일의 상태를 변경하면") { val updatedTask = savedTask.copy(status = TaskStatus.DONE, updatedAt = Instant.now()) - val command = TaskCommand.UpdateStatus(taskId = TaskId(1L), status = TaskStatus.DONE) + val command = TaskCommand.Update(taskId = TaskId(1L), status = TaskStatus.DONE) every { taskRepository.findById(TaskId(1L)) } returns savedTask - every { taskRepository.updateStatus(command) } returns updatedTask + every { taskRepository.update(command) } returns updatedTask - val result = taskService.updateStatus(command, memberId) + val result = taskService.update(command, memberId) Then("변경된 할일을 반환한다") { result.status shouldBe TaskStatus.DONE } } + When("본인 할일의 제목을 수정하면") { + val newTitle = TaskTitle("수학 문제 풀기") + val updatedTask = savedTask.copy(title = newTitle, updatedAt = Instant.now()) + val command = TaskCommand.Update(taskId = TaskId(1L), title = newTitle) + + every { taskRepository.findById(TaskId(1L)) } returns savedTask + every { taskRepository.update(command) } returns updatedTask + + val result = taskService.update(command, memberId) + + Then("수정된 할일을 반환한다") { + result.title.value shouldBe "수학 문제 풀기" + } + } + + When("제목과 상태를 동시에 수정하면") { + val newTitle = TaskTitle("수학 문제 풀기") + val updatedTask = savedTask.copy(title = newTitle, status = TaskStatus.DONE, updatedAt = Instant.now()) + val command = TaskCommand.Update(taskId = TaskId(1L), title = newTitle, status = TaskStatus.DONE) + + every { taskRepository.findById(TaskId(1L)) } returns savedTask + every { taskRepository.update(command) } returns updatedTask + + val result = taskService.update(command, memberId) + + Then("수정된 할일을 반환한다") { + result.title.value shouldBe "수학 문제 풀기" + result.status shouldBe TaskStatus.DONE + } + } + When("존재하지 않는 할일이면") { - val command = TaskCommand.UpdateStatus(taskId = TaskId(99L), status = TaskStatus.DONE) + val command = TaskCommand.Update(taskId = TaskId(99L), status = TaskStatus.DONE) every { taskRepository.findById(TaskId(99L)) } returns null Then("EntityNotFoundException이 발생한다") { shouldThrow { - taskService.updateStatus(command, memberId) + taskService.update(command, memberId) } } } When("본인 할일이 아니면") { - val command = TaskCommand.UpdateStatus(taskId = TaskId(1L), status = TaskStatus.DONE) + val command = TaskCommand.Update(taskId = TaskId(1L), status = TaskStatus.DONE) every { taskRepository.findById(TaskId(1L)) } returns savedTask Then("AccessDeniedException이 발생한다") { shouldThrow { - taskService.updateStatus(command, otherMemberId) + taskService.update(command, otherMemberId) } } }