diff --git a/README.md b/README.md index c6ace8e6..fa488771 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ Task 간 “선후 관계”를 관리합니다. * `GET /v1/members/now` 현재 진행 중인 일정 ID * `GET /v1/statistics` 주간 통계 조회 * Task 응답에 의존성 메타(`previousTaskIds`,`nextTaskIds`) 포함 + * Task 의존성 페이로드: **생성 시** 각 의존 관계마다 `fromId` 또는 `toId` 중 하나는 반드시 `0`(새로 생성될 자기 자신)이어야 하며, `removeDependencies`는 비워두어야 + 합니다. **수정 시에는 0을 사용할 수 없습니다.** * **작업 → 일정 복사**: `POST /v1/tasks/{taskId}/schedules`로 기존 작업을 지정 시각에 일정으로 등록할 수 있습니다. * **삭제 플래그**: `DELETE /v1/tasks/{taskId}?deleteSchedules=` 혹은 `DELETE /v0|v1/schedules/{scheduleId}?deleteTaskAlso=` 로 연관 삭제 여부를 선택합니다(미설정 시 연관만 해제). diff --git a/src/main/java/me/gg/pinit/pinittask/application/task/dto/TaskDependencyAdjustCommand.java b/src/main/java/me/gg/pinit/pinittask/application/task/dto/TaskDependencyAdjustCommand.java index d811faa8..c3e85392 100644 --- a/src/main/java/me/gg/pinit/pinittask/application/task/dto/TaskDependencyAdjustCommand.java +++ b/src/main/java/me/gg/pinit/pinittask/application/task/dto/TaskDependencyAdjustCommand.java @@ -64,6 +64,21 @@ public TaskPatch getTaskPatch() { .setDifficulty(difficulty); } + /** + * 업데이트 요청에서는 0 플레이스홀더 사용을 금지한다. + * (0은 새 Task 생성 시 자기 자신을 의미하므로 create 전용) + */ + public void validateNoPlaceholderForUpdate() { + if (taskId == null) { + return; + } + boolean hasPlaceholder = addDependencies.stream().anyMatch(this::hasPlaceholder) + || removeDependencies.stream().anyMatch(this::hasPlaceholder); + if (hasPlaceholder) { + throw new IllegalArgumentException("수정 요청에서는 fromId/toId에 0을 사용할 수 없습니다."); + } + } + public List getRemoveDependencies(Long selfId) { return removeDependencies.stream() .map(d -> new Dependency(ownerId, resolveId(d.getFromId(), selfId), resolveId(d.getToId(), selfId))) @@ -77,9 +92,14 @@ public List getAddDependencies(Long selfId) { } private Long resolveId(Long rawId, Long selfId) { - if (rawId == null) { + if (rawId == 0L) { return selfId; } return rawId; } + + private boolean hasPlaceholder(DependencyDto dto) { + return dto.getFromId() != null && dto.getFromId() == 0L + || dto.getToId() != null && dto.getToId() == 0L; + } } diff --git a/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskAdjustmentService.java b/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskAdjustmentService.java index 5c135df4..f234d605 100644 --- a/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskAdjustmentService.java +++ b/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskAdjustmentService.java @@ -31,6 +31,7 @@ public Task updateTask(Long memberId, TaskDependencyAdjustCommand command) { if (taskId == null) { throw new IllegalArgumentException("taskId는 null일 수 없습니다."); } + command.validateNoPlaceholderForUpdate(); List removedDependencies = command.getRemoveDependencies(taskId); List addedDependencies = command.getAddDependencies(taskId); dependencyService.assertNoCycle(memberId, removedDependencies, addedDependencies); diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DependencyRequest.java b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DependencyRequest.java index d1a885d1..817fde7a 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DependencyRequest.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/DependencyRequest.java @@ -5,10 +5,10 @@ public record DependencyRequest( @NotNull - @Schema(description = "선행 일정 ID", example = "1") + @Schema(description = "선행 작업 ID (새 작업 생성 시 자기 자신은 0, 수정 시 0 금지)", example = "1") Long fromId, @NotNull - @Schema(description = "후행 일정 ID", example = "2") + @Schema(description = "후행 작업 ID (새 작업 생성 시 자기 자신은 0, 수정 시 0 금지)", example = "2") Long toId ) { } diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskCreateRequest.java b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskCreateRequest.java new file mode 100644 index 00000000..cdadfcc1 --- /dev/null +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskCreateRequest.java @@ -0,0 +1,76 @@ +package me.gg.pinit.pinittask.interfaces.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; +import me.gg.pinit.pinittask.application.schedule.dto.DependencyDto; +import me.gg.pinit.pinittask.application.task.dto.TaskDependencyAdjustCommand; +import me.gg.pinit.pinittask.interfaces.utils.FibonacciDifficulty; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public record TaskCreateRequest( + @NotBlank + @Schema(description = "작업 제목", example = "스터디 준비") + String title, + @NotBlank + @Schema(description = "작업 설명", example = "다음 주 발표 자료 정리") + String description, + @NotNull + @Schema(description = "마감 기한", example = "{\"dateTime\":\"2024-03-01T18:00:00\",\"zoneId\":\"Asia/Seoul\"}") + @Valid + DateTimeWithZone dueDate, + @NotNull + @Min(1) + @Max(9) + @Schema(description = "중요도 (1~9)", example = "5") + Integer importance, + @NotNull + @FibonacciDifficulty + @Schema(description = "난이도 (피보나치 수: 1,2,3,5,8,13,21)", example = "5") + Integer difficulty, + @Schema(description = "추가할 의존 관계 목록 (생성 시 각 항목에 fromId 또는 toId 중 하나는 0)") + List<@Valid DependencyRequest> addDependencies +) { + public TaskDependencyAdjustCommand toCommand(Long taskId, Long ownerId, DateTimeUtils dateTimeUtils) { + validateMustContainSelfPlaceholder(addDependencies); + List remove = List.of(); // 생성 시 remove는 허용하지 않음 + List add = toDependencyDtos(addDependencies); + return new TaskDependencyAdjustCommand( + taskId, + ownerId, + title, + description, + dateTimeUtils.toZonedDateTime(dueDate.dateTime(), dueDate.zoneId()), + importance, + difficulty, + remove, + add + ); + } + + private List toDependencyDtos(List requests) { + return Optional.ofNullable(requests) + .orElseGet(ArrayList::new) + .stream() + .map(request -> new DependencyDto(null, request.fromId(), request.toId())) + .toList(); + } + + private void validateMustContainSelfPlaceholder(List dependencies) { + Optional.ofNullable(dependencies) + .orElseGet(ArrayList::new) + .forEach(dep -> { + if (dep.fromId() != 0L && dep.toId() != 0L) { + throw new IllegalArgumentException("작업 생성 시 의존 관계에는 fromId 또는 toId 중 하나가 0이어야 합니다."); + } + }); + } +} + diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskRequest.java b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskUpdateRequest.java similarity index 76% rename from src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskRequest.java rename to src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskUpdateRequest.java index d8e4d56e..72559a5d 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskRequest.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/dto/TaskUpdateRequest.java @@ -15,7 +15,7 @@ import java.util.List; import java.util.Optional; -public record TaskRequest( +public record TaskUpdateRequest( @NotBlank @Schema(description = "작업 제목", example = "스터디 준비") String title, @@ -35,12 +35,14 @@ public record TaskRequest( @FibonacciDifficulty @Schema(description = "난이도 (피보나치 수: 1,2,3,5,8,13,21)", example = "5") Integer difficulty, - @Schema(description = "제거할 의존 관계 목록") + @Schema(description = "제거할 의존 관계 목록 (수정 시 0 사용 금지)") List<@Valid DependencyRequest> removeDependencies, - @Schema(description = "추가할 의존 관계 목록") + @Schema(description = "추가할 의존 관계 목록 (수정 시 0 사용 금지)") List<@Valid DependencyRequest> addDependencies ) { public TaskDependencyAdjustCommand toCommand(Long taskId, Long ownerId, DateTimeUtils dateTimeUtils) { + validateNoPlaceholder(removeDependencies); + validateNoPlaceholder(addDependencies); List remove = toDependencyDtos(removeDependencies); List add = toDependencyDtos(addDependencies); return new TaskDependencyAdjustCommand( @@ -56,6 +58,16 @@ public TaskDependencyAdjustCommand toCommand(Long taskId, Long ownerId, DateTime ); } + private void validateNoPlaceholder(List dependencies) { + Optional.ofNullable(dependencies) + .orElseGet(ArrayList::new) + .forEach(dep -> { + if (dep.fromId() == 0L || dep.toId() == 0L) { + throw new IllegalArgumentException("수정 요청에서는 의존 관계 ID에 0을 사용할 수 없습니다."); + } + }); + } + private List toDependencyDtos(List requests) { return Optional.ofNullable(requests) .orElseGet(ArrayList::new) diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1.java b/src/main/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1.java index fae4ffa8..96d44444 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1.java @@ -47,7 +47,7 @@ public class TaskControllerV1 { @PostMapping @Operation(summary = "작업 생성", description = "새 작업과 의존 관계를 등록합니다.") public ResponseEntity createTask(@Parameter(hidden = true) @MemberId Long memberId, - @Valid @RequestBody TaskRequest request) { + @Valid @RequestBody TaskCreateRequest request) { Task saved = taskAdjustmentService.createTask(memberId, request.toCommand(null, memberId, dateTimeUtils)); return ResponseEntity.status(HttpStatus.CREATED).body(TaskResponse.from(saved)); } @@ -56,7 +56,7 @@ public ResponseEntity createTask(@Parameter(hidden = true) @Member @Operation(summary = "작업 수정", description = "작업 본문과 의존 관계를 함께 수정합니다.") public ResponseEntity updateTask(@Parameter(hidden = true) @MemberId Long memberId, @PathVariable Long taskId, - @Valid @RequestBody TaskRequest request) { + @Valid @RequestBody TaskUpdateRequest request) { Task updated = taskAdjustmentService.updateTask(memberId, request.toCommand(taskId, memberId, dateTimeUtils)); return ResponseEntity.ok(TaskResponse.from(updated)); } diff --git a/src/test/java/me/gg/pinit/pinittask/application/task/dto/TaskDependencyAdjustCommandTest.java b/src/test/java/me/gg/pinit/pinittask/application/task/dto/TaskDependencyAdjustCommandTest.java new file mode 100644 index 00000000..f5c6426b --- /dev/null +++ b/src/test/java/me/gg/pinit/pinittask/application/task/dto/TaskDependencyAdjustCommandTest.java @@ -0,0 +1,59 @@ +package me.gg.pinit.pinittask.application.task.dto; + +import me.gg.pinit.pinittask.application.schedule.dto.DependencyDto; +import me.gg.pinit.pinittask.domain.dependency.model.Dependency; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class TaskDependencyAdjustCommandTest { + + @Test + void resolvesSelfPlaceholderForZeroIds() { + Long ownerId = 1L; + Long selfId = 99L; + TaskDependencyAdjustCommand command = new TaskDependencyAdjustCommand( + null, + ownerId, + "t", + "d", + ZonedDateTime.now(), + 5, + 3, + List.of(), + List.of( + new DependencyDto(null, 0L, 5L), // fromId=0 -> self + new DependencyDto(null, 4L, 0L) // toId=0 -> self + ) + ); + + List deps = command.getAddDependencies(selfId); + + assertThat(deps) + .extracting(Dependency::getFromId, Dependency::getToId) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(selfId, 5L), + org.assertj.core.groups.Tuple.tuple(4L, selfId) + ); + } + + @Test + void validateNoPlaceholderForUpdate_rejectsZeroIds() { + TaskDependencyAdjustCommand command = new TaskDependencyAdjustCommand( + 10L, + 1L, + "t", + "d", + ZonedDateTime.now(), + 5, + 3, + List.of(), + List.of(new DependencyDto(null, 0L, 2L)) + ); + + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, command::validateNoPlaceholderForUpdate); + } +} diff --git a/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskAdjustmentServiceTest.java b/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskAdjustmentServiceTest.java index 30a96ac1..db24c69b 100644 --- a/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskAdjustmentServiceTest.java +++ b/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskAdjustmentServiceTest.java @@ -23,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.tuple; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.when; @@ -168,6 +169,26 @@ void updateTask_checksCycleAndAdjustsDependenciesAroundUpdate() { assertThat(savedDependenciesCaptor.getValue()).isSameAs(addedDependencies); } + @Test + @DisplayName("updateTask에서 0 플레이스홀더 사용 시 예외") + void updateTask_rejectsPlaceholderZero() { + Long memberId = 1L; + Long taskId = 10L; + TaskDependencyAdjustCommand command = new TaskDependencyAdjustCommand( + taskId, + memberId, + "title", + "desc", + ZonedDateTime.now(), + 5, + 5, + List.of(), + List.of(new DependencyDto(null, 0L, 2L)) + ); + + assertThrows(IllegalArgumentException.class, () -> taskAdjustmentService.updateTask(memberId, command)); + } + @SuppressWarnings("unchecked") private ArgumentCaptor> dependencyListCaptor() { return ArgumentCaptor.forClass(List.class); diff --git a/src/test/java/me/gg/pinit/pinittask/interfaces/dto/TaskCreateRequestTest.java b/src/test/java/me/gg/pinit/pinittask/interfaces/dto/TaskCreateRequestTest.java new file mode 100644 index 00000000..76d436aa --- /dev/null +++ b/src/test/java/me/gg/pinit/pinittask/interfaces/dto/TaskCreateRequestTest.java @@ -0,0 +1,28 @@ +package me.gg.pinit.pinittask.interfaces.dto; + +import me.gg.pinit.pinittask.application.datetime.DateTimeUtils; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class TaskCreateRequestTest { + + @Test + void toCommand_requiresPlaceholderZeroForEachDependency() { + TaskCreateRequest req = new TaskCreateRequest( + "t", + "d", + new DateTimeWithZone(LocalDateTime.now(), ZoneId.of("UTC")), + 5, + 3, + List.of(new DependencyRequest(10L, 20L)) // neither is 0 + ); + + assertThrows(IllegalArgumentException.class, + () -> req.toCommand(null, 1L, new DateTimeUtils())); + } +} diff --git a/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1IntegrationTest.java b/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1IntegrationTest.java index 7a1e45c2..3d9f9c55 100644 --- a/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1IntegrationTest.java +++ b/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV1IntegrationTest.java @@ -7,7 +7,7 @@ import me.gg.pinit.pinittask.domain.schedule.model.ScheduleType; import me.gg.pinit.pinittask.infrastructure.events.RabbitEventPublisher; import me.gg.pinit.pinittask.interfaces.dto.DateTimeWithZone; -import me.gg.pinit.pinittask.interfaces.dto.TaskRequest; +import me.gg.pinit.pinittask.interfaces.dto.TaskCreateRequest; import me.gg.pinit.pinittask.interfaces.dto.TaskScheduleRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -59,13 +59,12 @@ void setUpMember() { @Test void taskLifecycle_create_retrieve_list_cursor_complete_reopen_delete() throws Exception { - TaskRequest createRequest = new TaskRequest( + TaskCreateRequest createRequest = new TaskCreateRequest( "리포트 작성", "주간 리포트 초안 작성", new DateTimeWithZone(LocalDateTime.of(2024, 4, 1, 18, 0), MEMBER_ZONE), 5, 3, - List.of(), List.of() ); @@ -168,7 +167,6 @@ void createTask_validationErrors_returnDetailedErrors() throws Exception { "dueDate": null, "importance": 0, "difficulty": 4, - "removeDependencies": [], "addDependencies": [] } """;