diff --git "a/docs/004 \354\247\200\353\202\234 Task \354\225\204\354\271\264\354\235\264\353\270\214/past-task-archive-checklist.md" "b/docs/004 \354\247\200\353\202\234 Task \354\225\204\354\271\264\354\235\264\353\270\214/past-task-archive-checklist.md" new file mode 100644 index 0000000..a512447 --- /dev/null +++ "b/docs/004 \354\247\200\353\202\234 Task \354\225\204\354\271\264\354\235\264\353\270\214/past-task-archive-checklist.md" @@ -0,0 +1,53 @@ +# 지난 Task 모아보기(완료 + 마감일 1일 경과) API 체크리스트 + +> 목적: 오늘 00:00 기준 이전(어제까지) 마감일이 지난 완료 Task를 커서 기반으로 모아볼 수 있는 클라이언트용 엔드포인트를 추가한다. + +## 요구 정의 + +- [x] "1일" 기준을 확정한다: **사용자/요청 오프셋의 LocalDate 기준 now-1일 00:00** 을 컷오프로 사용한다. +- [x] 포함 범위를 정의한다: 일정 연결 여부와 무관하게 해당 회원의 모든 완료 Task(마감 필수) 포함. +- [x] 정렬 방향과 기본/최대 페이지 크기를 확정한다: `deadline_date DESC, id DESC`, default 20, max 100, 커서 `yyyy-MM-dd[+HH:mm]|taskId`. +- [x] 응답 필드 확정: `TaskResponseV2` 그대로 사용(마감 날짜+오프셋 노출, 완료 소스는 미노출로 합의). +- [x] 기존 Task 목록 API와의 역할 분리를 명문화한다: `/v2/tasks/completed` 는 마감 1일 경과 + 완료만, 기존 목록은 최신/진행중 포함. + +## 스키마 & 도메인 + +- [x] 도메인에서 마감일(`TemporalConstraint.deadline`)을 손쉽게 `LocalDate`로 얻을 수 있는 헬퍼 확인(기존 `getDeadlineDate()` 재사용). +- [x] 1일 컷오프 계산 시 오프셋을 명시하고 서비스 책임으로 분리(`TaskArchiveService`, 테스트용 Clock 주입). +- [x] DB 인덱스 필요 여부를 검토한다: `(owner_id, completed, deadline_date, task_id)` 복합 인덱스 추가용 DDL 초안 작성( + `task-archive-index.sql`). + +## 레포지토리 & 쿼리 + +- [x] `TaskRepository`에 완료 Task 전용 커서 쿼리를 추가한다: `completed=true`, `deadline_date <= cutoffDate`, 정렬 + `deadline_date DESC, id DESC`. +- [x] 커서 인코딩/디코딩 포맷을 정의한다(`yyyy-MM-dd|{id}` 기본, 오프셋 허용) 및 파싱 실패 예외 메시지를 규격화한다. +- [x] 커서 단위 테스트/통합 테스트로 동일 마감일 + ID 타이브레이킹이 안정적으로 동작하는지 검증한다. + +## 서비스 & 애플리케이션 레이어 + +- [x] 1일 컷오프 계산 방식을 구현한다(요청/회원 오프셋 기준 LocalDate.now-1) 및 Clock 주입으로 테스트 가능. +- [x] `TaskArchiveService`에 아카이브용 커서 페이지 모델을 추가하고 DESC 정렬에 맞는 `hasNext`/`nextCursor`(size+1) 계산 완료. +- [x] 완료→재오픈 시 아카이브 대상에서 제외되도록 completed 플래그 의존(재오픈 시 false 처리 유지). + +## API 계약 & 컨트롤러 + +- [x] V2에 `GET /v2/tasks/completed` 커서 엔드포인트 추가, `size(1~100)`, `cursor`, `offset` 검증 처리. +- [x] 응답 DTO는 `TaskArchiveCursorPageResponseV2` + `TaskResponseV2` 조합으로 확정. +- [x] Swagger/OAS 어노테이션에 커서 예제, 1일 컷오프 설명, 400 응답 반영. +- [x] Member 인증(`@MemberId`) 적용, 별도 레이트리밋/캐싱은 도입하지 않기로 결정. + +## 테스트 + +- [x] 도메인/서비스 단위: 오프셋 기반 컷오프·커서 파싱·size 검증 테스트 추가. +- [x] 리포지토리 통합: 커서 정렬/필터/경계 테스트 추가. +- [x] 서비스 단위: 커서 직렬화/역직렬화, 잘못된 커서 예외, size 상한, cutoff 계산 검증. +- [x] 컨트롤러: 요청 검증(0 size), 커서 페이지 동작, 오프셋 파라미터 적용 통합 테스트. +- [x] 이벤트/로그: 별도 트래킹 미도입으로 N/A 확인. + +## 배포 & 운영 + +- [x] 스키마 변경 없음(인덱스만 추가)으로 확정, 적용 DDL은 `task-archive-index.sql`에 기록. +- [x] 운영/스테이징은 수동 인덱스 적용 전제(`ddl-auto` 유지 시 위험 없도록 별도 적용 계획 필요). +- [x] 모니터링 포인트 제안: archive API latency/에러율, 응답 건수, deadline null 비율 알림. +- [x] 프런트/모바일 공지 사항 정리: 커서 포맷·1일 기준 오프셋·max size 100·응답 필드 기존과 동일(새 엔드포인트) 공유 필요. diff --git "a/docs/004 \354\247\200\353\202\234 Task \354\225\204\354\271\264\354\235\264\353\270\214/\353\247\210\352\260\220 \353\202\240\354\247\234 \352\270\260\354\244\200 \354\236\221\354\227\205 \354\241\260\355\232\214.md" "b/docs/004 \354\247\200\353\202\234 Task \354\225\204\354\271\264\354\235\264\353\270\214/\353\247\210\352\260\220 \353\202\240\354\247\234 \352\270\260\354\244\200 \354\236\221\354\227\205 \354\241\260\355\232\214.md" new file mode 100644 index 0000000..5b0f72d --- /dev/null +++ "b/docs/004 \354\247\200\353\202\234 Task \354\225\204\354\271\264\354\235\264\353\270\214/\353\247\210\352\260\220 \353\202\240\354\247\234 \352\270\260\354\244\200 \354\236\221\354\227\205 \354\241\260\355\232\214.md" @@ -0,0 +1,12 @@ +- 한번에 보유한 모든 작업을 조회하는 API는 비효율적이다. + +- 의존관계 추가를 위해선, 데드라인 날짜 기준으로 작업 조회를 해야 한다. + +- 따라서, 마감 날짜 기준 작업 조회 API가 필요하다. + +- 이 API는, 특정 날짜에 마감일이 설정된 작업들을 조회하는 기능을 제공해야 한다. + +## 구현 메모 + +- GET `/v2/tasks/by-deadline?date=YYYY-MM-DD` (v1 동일 경로)로 제공하며, 회원 ID 헤더를 받아 해당 날짜에 마감이 있는 모든 작업(완료/미완료 포함)을 반환한다. +- 응답 스키마는 기존 작업 조회 응답(TaskResponseV2/TaskResponse)과 동일하며, 의존관계 정보도 포함된다. diff --git "a/docs/004 \354\247\200\353\202\234 Task \354\225\204\354\271\264\354\235\264\353\270\214/\355\230\204\354\236\254 \354\235\274\354\240\225\354\227\224 \353\255\230 \353\204\243\354\226\264\354\225\274 \355\225\230\353\212\224\352\260\200.md" "b/docs/004 \354\247\200\353\202\234 Task \354\225\204\354\271\264\354\235\264\353\270\214/\355\230\204\354\236\254 \354\235\274\354\240\225\354\227\224 \353\255\230 \353\204\243\354\226\264\354\225\274 \355\225\230\353\212\224\352\260\200.md" new file mode 100644 index 0000000..6ca81af --- /dev/null +++ "b/docs/004 \354\247\200\353\202\234 Task \354\225\204\354\271\264\354\235\264\353\270\214/\355\230\204\354\236\254 \354\235\274\354\240\225\354\227\224 \353\255\230 \353\204\243\354\226\264\354\225\274 \355\225\230\353\212\224\352\260\200.md" @@ -0,0 +1,7 @@ +### 현재 완료된 사항 + +- [x] 지난 일정 모아보기 API 구현 완료 + +### 남은 작업 + +- [x] 작업 탭에 기본으로 조회되어야 할 "작업 목록"이 "완료되지 않은 작업"/"마감일이 지나지 않은 완료된 작업"을 마감일 기준 오름차순으로 조회 \ No newline at end of file diff --git "a/docs/\354\231\204\353\243\214\353\220\230\354\247\200 \354\225\212\354\235\200 \354\236\221\354\227\205.md" "b/docs/\354\231\204\353\243\214\353\220\230\354\247\200 \354\225\212\354\235\200 \354\236\221\354\227\205.md" new file mode 100644 index 0000000..ce58161 --- /dev/null +++ "b/docs/\354\231\204\353\243\214\353\220\230\354\247\200 \354\225\212\354\235\200 \354\236\221\354\227\205.md" @@ -0,0 +1,7 @@ +- [ ] 아카이빙 해야 할 작업을 Cassandra DB와 같은 곳으로 이동 (배치 처리) + - 현재 DB에 완료된 작업이 계속 쌓이는 것을 방지 + - 현재 조회하는 아카이브 조회 방식을 그대로 Batch 처리를 위한 조회 메소드로 사용하기 + - 조회된 작업들을 Cassandra DB로 이동 + - 이동된 작업들은 기존 DB에서 삭제 + - 실제 사용자가 보게 될 아카이브 조회 API는 RDB가 아닌 Cassandra DB에서 조회하도록 변경 + - 해당 작업들은 변경 불가 \ No newline at end of file diff --git a/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskArchiveService.java b/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskArchiveService.java new file mode 100644 index 0000000..25774ff --- /dev/null +++ b/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskArchiveService.java @@ -0,0 +1,108 @@ +package me.gg.pinit.pinittask.application.task.service; + +import lombok.RequiredArgsConstructor; +import me.gg.pinit.pinittask.application.member.service.MemberService; +import me.gg.pinit.pinittask.domain.task.model.Task; +import me.gg.pinit.pinittask.domain.task.repository.TaskRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +@RequiredArgsConstructor +public class TaskArchiveService { + + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 100; + private static final String CURSOR_DELIMITER = "|"; + private static final Pattern DATE_WITH_OPTIONAL_OFFSET_PATTERN = Pattern.compile("(?\\d{4}-\\d{2}-\\d{2})(?[+-]\\d{2}:?\\d{2})?"); + + private final TaskRepository taskRepository; + private final MemberService memberService; + private final Clock clock; + + @Transactional(readOnly = true) + public CursorPage getCompletedArchive(Long ownerId, Integer sizeParam, String cursor, ZoneOffset requestOffset) { + int size = resolveSize(sizeParam); + ZoneOffset effectiveOffset = resolveOffset(ownerId, requestOffset); + LocalDate cutoffDate = LocalDate.now(clock.withZone(effectiveOffset)).minusDays(1); + + Cursor decoded = decodeCursor(cursor, cutoffDate); + Pageable pageable = PageRequest.of(0, size + 1); + List tasks = taskRepository.findCompletedArchiveByCursor( + ownerId, + cutoffDate, + decoded.deadline(), + decoded.id(), + pageable + ); + + boolean hasNext = tasks.size() > size; + List content = hasNext ? tasks.subList(0, size) : tasks; + String nextCursor = hasNext ? encodeCursor(content.getLast()) : null; + return new CursorPage(content, nextCursor, hasNext, cutoffDate, effectiveOffset); + } + + private int resolveSize(Integer sizeParam) { + int size = sizeParam == null ? DEFAULT_PAGE_SIZE : sizeParam; + if (size <= 0 || size > MAX_PAGE_SIZE) { + throw new IllegalArgumentException("size는 1 이상 100 이하이어야 합니다."); + } + return size; + } + + private ZoneOffset resolveOffset(Long ownerId, ZoneOffset requestOffset) { + if (requestOffset != null) { + return requestOffset; + } + return memberService.findZoneOffsetOfMember(ownerId); + } + + private String encodeCursor(Task task) { + return task.getTemporalConstraint().getDeadlineDate() + CURSOR_DELIMITER + task.getId(); + } + + private Cursor decodeCursor(String cursor, LocalDate cutoffDate) { + if (cursor == null || cursor.isBlank()) { + return new Cursor(cutoffDate.plusDays(1), Long.MAX_VALUE); + } + String[] parts = cursor.split("\\|"); + if (parts.length != 2) { + throw new IllegalArgumentException("커서는 'yyyy-MM-dd|id' 형식이어야 합니다."); + } + LocalDate deadline = parseDate(parts[0]); + long id = parseId(parts[1]); + return new Cursor(deadline, id); + } + + private LocalDate parseDate(String raw) { + Matcher matcher = DATE_WITH_OPTIONAL_OFFSET_PATTERN.matcher(raw); + if (!matcher.matches()) { + throw new IllegalArgumentException("커서는 'yyyy-MM-dd|id' 형식이어야 합니다."); + } + return LocalDate.parse(matcher.group("date")); + } + + private long parseId(String raw) { + try { + return Long.parseLong(raw); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("커서의 ID가 올바르지 않습니다.", e); + } + } + + public record CursorPage(List tasks, String nextCursor, boolean hasNext, LocalDate cutoffDate, + ZoneOffset offset) { + } + + private record Cursor(LocalDate deadline, long id) { + } +} diff --git a/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskService.java b/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskService.java index 5432b28..09e5015 100644 --- a/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskService.java +++ b/src/main/java/me/gg/pinit/pinittask/application/task/service/TaskService.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import me.gg.pinit.pinittask.application.dependency.service.DependencyService; import me.gg.pinit.pinittask.application.events.DomainEventPublisher; +import me.gg.pinit.pinittask.application.member.service.MemberService; import me.gg.pinit.pinittask.application.schedule.service.ScheduleService; import me.gg.pinit.pinittask.domain.events.DomainEvent; import me.gg.pinit.pinittask.domain.events.DomainEvents; @@ -19,9 +20,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZonedDateTime; +import java.time.*; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.Deque; @@ -38,6 +37,8 @@ public class TaskService { private final DependencyService dependencyService; private final ScheduleService scheduleService; private final DomainEventPublisher domainEventPublisher; + private final MemberService memberService; + private final Clock clock; @Transactional(readOnly = true) public Task getTask(Long ownerId, Long taskId) { @@ -52,7 +53,8 @@ public Page getTasks(Long ownerId, Pageable pageable, boolean readyOnly) { if (readyOnly) { return taskRepository.findAllByOwnerIdAndInboundDependencyCountAndCompletedFalse(ownerId, 0, pageable); } - return taskRepository.findAllByOwnerId(ownerId, pageable); + LocalDate today = resolveToday(ownerId); + return taskRepository.findCurrentByOwnerId(ownerId, today, pageable); } @Deprecated @@ -124,11 +126,13 @@ public void markIncomplete(Long ownerId, Long taskId) { private CursorPage loadTasksByCursor(Long ownerId, int size, String cursor, boolean readyOnly) { Cursor decoded = decodeCursor(cursor); + LocalDate today = resolveToday(ownerId); List tasks = taskRepository.findNextByCursor( ownerId, readyOnly, decoded.deadline(), decoded.id(), + today, PageRequest.of(0, size) ); boolean hasNext = tasks.size() == size; @@ -136,6 +140,16 @@ private CursorPage loadTasksByCursor(Long ownerId, int size, String cursor, bool return new CursorPage(tasks, nextCursor, hasNext); } + private LocalDate resolveToday(Long ownerId) { + ZoneOffset offset = memberService.findZoneOffsetOfMember(ownerId); + return LocalDate.now(clock.withZone(offset)); + } + + @Transactional(readOnly = true) + public List getTasksByDeadline(Long ownerId, LocalDate deadlineDate) { + return taskRepository.findAllByOwnerIdAndDeadlineDate(ownerId, deadlineDate); + } + private void validateOwner(Long ownerId, Task task) { if (!task.getOwnerId().equals(ownerId)) { throw new IllegalArgumentException("Member does not own the task"); @@ -157,6 +171,7 @@ private void syncScheduleOnTaskCompletion(Long ownerId, Task task) { if (!schedule.isCompleted()) { schedule.finish(ZonedDateTime.now(schedule.getDesignatedStartTime().getZone())); } + //TODO 만약 task가 미완료 상태가 되면 일정도 미완료 상태로 변경하는 로직 추가 } private void publishEvents() { diff --git a/src/main/java/me/gg/pinit/pinittask/domain/task/model/Task.java b/src/main/java/me/gg/pinit/pinittask/domain/task/model/Task.java index fe53e5d..c951cbc 100644 --- a/src/main/java/me/gg/pinit/pinittask/domain/task/model/Task.java +++ b/src/main/java/me/gg/pinit/pinittask/domain/task/model/Task.java @@ -15,6 +15,9 @@ import java.time.ZonedDateTime; @Entity +@Table(indexes = { + @Index(name = "idx_task_owner_deadline", columnList = "owner_id, deadline_date, task_id") +}) public class Task { @Id @GeneratedValue(strategy = GenerationType.AUTO) diff --git a/src/main/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepository.java b/src/main/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepository.java index 689849e..3626dd8 100644 --- a/src/main/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepository.java +++ b/src/main/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepository.java @@ -30,6 +30,7 @@ public interface TaskRepository extends JpaRepository { SELECT t FROM Task t WHERE t.ownerId = :ownerId AND (:readyOnly = false OR (t.inboundDependencyCount = 0 AND t.completed = false)) + AND (t.completed = false OR t.temporalConstraint.deadline.date >= :activeDate) AND ( t.temporalConstraint.deadline.date > :cursorDate OR (t.temporalConstraint.deadline.date = :cursorDate AND t.id > :cursorId) @@ -40,5 +41,57 @@ List findNextByCursor(@Param("ownerId") Long ownerId, @Param("readyOnly") boolean readyOnly, @Param("cursorDate") LocalDate cursorDate, @Param("cursorId") Long cursorId, + @Param("activeDate") LocalDate activeDate, Pageable pageable); + + @Query(value = """ + SELECT t FROM Task t + WHERE t.ownerId = :ownerId + AND (t.completed = false OR t.temporalConstraint.deadline.date >= :activeDate) + ORDER BY t.temporalConstraint.deadline.date ASC, t.id ASC + """, + countQuery = """ + SELECT count(t) FROM Task t + WHERE t.ownerId = :ownerId + AND (t.completed = false OR t.temporalConstraint.deadline.date >= :activeDate) + """) + Page findCurrentByOwnerId(@Param("ownerId") Long ownerId, + @Param("activeDate") LocalDate activeDate, + Pageable pageable); + + @Query(""" + SELECT t FROM Task t + WHERE t.ownerId = :ownerId + AND t.temporalConstraint.deadline.date = :deadlineDate + ORDER BY t.id ASC + """) + List findAllByOwnerIdAndDeadlineDate(@Param("ownerId") Long ownerId, + @Param("deadlineDate") LocalDate deadlineDate); + + @Query(""" + SELECT t FROM Task t + WHERE t.ownerId = :ownerId + AND t.completed = true + AND t.temporalConstraint.deadline.date <= :cutoffDate + AND ( + t.temporalConstraint.deadline.date < :cursorDate + OR (t.temporalConstraint.deadline.date = :cursorDate AND t.id < :cursorId) + ) + ORDER BY t.temporalConstraint.deadline.date DESC, t.id DESC + """) + /** + * 커서 기반 완료 아카이브 조회. + * + * @param ownerId 회원 ID (파티션 키) + * @param cutoffDate 포함되는 마감일 상한 (예: now-1@offset) + * @param cursorDate 커서가 가리키는 마감일. 이 날짜보다 과거 데이터만 조회하며, + * 같은 날짜일 때는 cursorId 미만만 조회한다. + * @param cursorId 커서가 가리키는 작업 ID (마감일 동일 시 tie-breaker) + * @param pageable 페이지 정보(서비스에서 size+1로 전달) + */ + List findCompletedArchiveByCursor(@Param("ownerId") Long ownerId, + @Param("cutoffDate") LocalDate cutoffDate, + @Param("cursorDate") LocalDate cursorDate, + @Param("cursorId") Long cursorId, + Pageable pageable); } diff --git a/src/main/java/me/gg/pinit/pinittask/infrastructure/config/ClockConfig.java b/src/main/java/me/gg/pinit/pinittask/infrastructure/config/ClockConfig.java new file mode 100644 index 0000000..7325879 --- /dev/null +++ b/src/main/java/me/gg/pinit/pinittask/infrastructure/config/ClockConfig.java @@ -0,0 +1,15 @@ +package me.gg.pinit.pinittask.infrastructure.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +@Configuration +public class ClockConfig { + + @Bean + public Clock systemClock() { + return Clock.systemUTC(); + } +} diff --git a/src/main/java/me/gg/pinit/pinittask/infrastructure/config/OpenApiConfig.java b/src/main/java/me/gg/pinit/pinittask/infrastructure/config/OpenApiConfig.java index 6a3fed4..a4d727a 100644 --- a/src/main/java/me/gg/pinit/pinittask/infrastructure/config/OpenApiConfig.java +++ b/src/main/java/me/gg/pinit/pinittask/infrastructure/config/OpenApiConfig.java @@ -11,7 +11,7 @@ info = @Info( title = "Pinit Task API", version = "v2", - description = "일정 관리와 의존 관계 기능을 제공하는 API", + description = "일정/작업 관리와 의존 관계 기능을 제공하는 API", contact = @Contact(name = "Pinit Team", email = "support@pinit.local") ), servers = { diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV1.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV1.java index f3b9553..a5b7c01 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV1.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV1.java @@ -24,10 +24,13 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; +import java.util.List; @RestController @RequestMapping("/v1/tasks") @RequiredArgsConstructor @@ -75,7 +78,12 @@ public ResponseEntity updateTask(@Parameter(hidden = true) @Member } @GetMapping - @Operation(summary = "작업 목록 조회", description = "회원의 작업 목록을 조회합니다. page/size로 데드라인 순 페이지네이션, readyOnly로 선행 작업 없는 항목만 필터링합니다.") + @Operation(summary = "작업 목록 조회", description = """ + 회원의 작업 목록을 조회합니다. + 포함 대상: 미완료 작업 전체 + 오늘(회원 UTC 오프셋 기준) 이후 또는 오늘 마감인 완료 작업. + 정렬: 마감일 오름차순, page/size 페이징. + readyOnly=true면 선행 작업 없는 미완료 작업만 필터링합니다. + """) @ApiResponses({ @ApiResponse(responseCode = "200", description = "작업 목록 조회 성공") }) @@ -90,7 +98,11 @@ public Page getTasks(@Parameter(hidden = true) @MemberId Long memb } @GetMapping("/cursor") - @Operation(summary = "작업 목록 커서 조회", description = "마감 날짜(자정 00:00:00) asc, id asc 커서 기반 페이지네이션. cursor는 'YYYY-MM-DDTHH:MM:SS|taskId' 형식이며 시간 부분은 00:00:00으로 고정됩니다. 더 이상 데이터가 없으면 nextCursor=null.") + @Operation(summary = "작업 목록 커서 조회", description = """ + 마감 날짜(자정 00:00:00) asc, id asc 커서 기반 페이지네이션. + 포함 대상: 미완료 작업 전체 + 오늘(회원 UTC 오프셋 기준) 이후 또는 오늘 마감인 완료 작업. + cursor 형식: 'YYYY-MM-DDTHH:MM:SS|taskId' (시간은 00:00:00 고정). 데이터가 없으면 nextCursor=null. + """) @ApiResponses({ @ApiResponse(responseCode = "200", description = "커서 기반 작업 목록 조회 성공", content = @Content(schema = @Schema(implementation = TaskCursorPageResponse.class))), @ApiResponse(responseCode = "400", description = "커서 형식이 올바르지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) @@ -102,6 +114,21 @@ public TaskCursorPageResponse getTasksByCursor(@Parameter(hidden = true) @Member return taskService.getTasksByCursor(memberId, size, cursor, readyOnly); } + @GetMapping("/by-deadline") + @Operation(summary = "마감 날짜 기준 작업 조회", description = """ + 특정 날짜(YYYY-MM-DD)에 마감이 설정된 작업들을 조회합니다. + 완료/미완료 상태와 무관하게 해당 날짜 마감인 모든 작업을 반환합니다. + """) + public List getTasksByDeadline(@Parameter(hidden = true) @MemberId Long memberId, + @Parameter(description = "마감 날짜", example = "2025-02-01") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { + List tasks = taskService.getTasksByDeadline(memberId, date); + var dependencyInfoMap = dependencyService.getDependencyInfoForTasks(memberId, tasks.stream().map(Task::getId).toList()); + return tasks.stream() + .map(task -> TaskResponse.from(task, dependencyInfoMap.get(task.getId()))) + .toList(); + } + @GetMapping("/{taskId}") @Operation(summary = "작업 단건 조회", description = "특정 작업의 상세 정보를 조회합니다.") @ApiResponses({ diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV2.java index c83994b..a335a0e 100644 --- a/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV2.java +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/TaskControllerV2.java @@ -13,6 +13,7 @@ import me.gg.pinit.pinittask.application.dependency.service.DependencyService; import me.gg.pinit.pinittask.application.schedule.service.ScheduleService; import me.gg.pinit.pinittask.application.task.service.TaskAdjustmentService; +import me.gg.pinit.pinittask.application.task.service.TaskArchiveService; import me.gg.pinit.pinittask.application.task.service.TaskService; import me.gg.pinit.pinittask.domain.schedule.model.Schedule; import me.gg.pinit.pinittask.domain.task.model.Task; @@ -24,10 +25,13 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; +import java.time.ZoneOffset; import java.util.List; @RestController @@ -43,6 +47,7 @@ public class TaskControllerV2 { private final DateTimeUtils dateTimeUtils; private final DependencyService dependencyService; + private final TaskArchiveService taskArchiveService; private final TaskAdjustmentService taskAdjustmentService; private final TaskService taskService; private final ScheduleService scheduleService; @@ -76,7 +81,12 @@ public ResponseEntity updateTask(@Parameter(hidden = true) @Memb } @GetMapping - @Operation(summary = "작업 목록 조회", description = "회원의 작업 목록을 조회합니다. page/size로 마감 날짜 오름차순 페이지네이션, readyOnly로 선행 작업 없는 항목만 필터링합니다.") + @Operation(summary = "작업 목록 조회", description = """ + 회원의 작업 목록을 조회합니다. + 포함 대상: 미완료 작업 전체 + 오늘(회원 UTC 오프셋 기준) 이후 또는 오늘 마감인 완료 작업. + 정렬: 마감일 오름차순, page/size 페이징. + readyOnly=true면 선행 작업 없는 미완료 작업만 필터링합니다. + """) @ApiResponses({ @ApiResponse(responseCode = "200", description = "작업 목록 조회 성공") }) @@ -91,7 +101,11 @@ public Page getTasks(@Parameter(hidden = true) @MemberId Long me } @GetMapping("/cursor") - @Operation(summary = "작업 목록 커서 조회", description = "마감 날짜(00:00:00) asc, id asc 커서 기반 페이지네이션. cursor는 'YYYY-MM-DDTHH:MM:SS|taskId' 형식(시간은 항상 00:00:00)입니다.") + @Operation(summary = "작업 목록 커서 조회", description = """ + 마감 날짜(00:00:00) asc, id asc 커서 기반 페이지네이션. + 포함 대상: 미완료 작업 전체 + 오늘(회원 UTC 오프셋 기준) 이후 또는 오늘 마감인 완료 작업. + cursor 형식: 'YYYY-MM-DDTHH:MM:SS|taskId' (시간은 항상 00:00:00). + """) @ApiResponses({ @ApiResponse(responseCode = "200", description = "커서 기반 작업 목록 조회 성공", content = @Content(schema = @Schema(implementation = TaskCursorPageResponseV2.class))), @ApiResponse(responseCode = "400", description = "커서 형식이 올바르지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) @@ -108,6 +122,48 @@ public TaskCursorPageResponseV2 getTasksByCursor(@Parameter(hidden = true) @Memb return TaskCursorPageResponseV2.of(data, page.nextCursor(), page.hasNext()); } + @GetMapping("/by-deadline") + @Operation(summary = "마감 날짜 기준 작업 조회", description = """ + 특정 날짜(YYYY-MM-DD)에 마감이 설정된 작업들을 조회합니다. + 완료/미완료 상태와 무관하게 해당 날짜 마감인 모든 작업을 반환합니다. + """) + public List getTasksByDeadline(@Parameter(hidden = true) @MemberId Long memberId, + @Parameter(description = "마감 날짜", example = "2025-02-01") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { + List tasks = taskService.getTasksByDeadline(memberId, date); + var dependencyInfoMap = dependencyService.getDependencyInfoForTasks(memberId, tasks.stream().map(Task::getId).toList()); + return tasks.stream() + .map(task -> TaskResponseV2.from(task, dependencyInfoMap.get(task.getId()))) + .toList(); + } + + @GetMapping("/completed") + @Operation(summary = "완료 + 마감일이 지난 작업 목록(커서)", + description = """ + 오늘 00:00 기준 이전(어제까지) 마감일을 가진 완료 작업을 커서 기반으로 내려줍니다. + 정렬: deadline DESC, id DESC. + 커서 포맷: `yyyy-MM-dd|taskId` (예: `2025-01-07|42`). `offset`을 넣으면 cutoff 기준 시각이 해당 오프셋으로 계산됩니다. + 첫 페이지 호출 시 cursor를 비우면 자동으로 cutoff 다음 날(`cutoff+1|Long.MAX_VALUE`)로 시작합니다. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "지난 작업 목록 조회 성공", content = @Content(schema = @Schema(implementation = TaskArchiveCursorPageResponseV2.class))), + @ApiResponse(responseCode = "400", description = "커서 또는 size가 올바르지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public TaskArchiveCursorPageResponseV2 getCompletedTasksArchive(@Parameter(hidden = true) @MemberId Long memberId, + @Parameter(description = "조회 크기 (1~100)", example = "20") + @RequestParam(defaultValue = "20") Integer size, + @Parameter(description = "커서 `yyyy-MM-dd|taskId` (예: 2025-01-07|42)", example = "2025-01-07|42") + @RequestParam(required = false) String cursor, + @Parameter(description = "컷오프 계산에 사용할 UTC 오프셋. 없으면 회원 설정값 사용", example = "+09:00") + @RequestParam(required = false) ZoneOffset offset) { + TaskArchiveService.CursorPage page = taskArchiveService.getCompletedArchive(memberId, size, cursor, offset); + var dependencyInfoMap = dependencyService.getDependencyInfoForTasks(memberId, page.tasks().stream().map(Task::getId).toList()); + List data = page.tasks().stream() + .map(task -> TaskResponseV2.from(task, dependencyInfoMap.get(task.getId()))) + .toList(); + return TaskArchiveCursorPageResponseV2.of(data, page.nextCursor(), page.hasNext()); + } + @GetMapping("/{taskId}") @Operation(summary = "작업 단건 조회", description = "특정 작업의 상세 정보를 조회합니다.") @ApiResponses({ diff --git a/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskArchiveCursorPageResponseV2.java b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskArchiveCursorPageResponseV2.java new file mode 100644 index 0000000..748fd21 --- /dev/null +++ b/src/main/java/me/gg/pinit/pinittask/interfaces/task/dto/TaskArchiveCursorPageResponseV2.java @@ -0,0 +1,18 @@ +package me.gg.pinit.pinittask.interfaces.task.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +public record TaskArchiveCursorPageResponseV2( + @Schema(description = "지난 작업 목록") + List data, + @Schema(description = "다음 페이지 요청 시 사용할 커서. 더 이상 데이터가 없으면 null") + String nextCursor, + @Schema(description = "다음 페이지 존재 여부") + boolean hasNext +) { + public static TaskArchiveCursorPageResponseV2 of(List data, String nextCursor, boolean hasNext) { + return new TaskArchiveCursorPageResponseV2(data, nextCursor, hasNext); + } +} diff --git a/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskArchiveServiceTest.java b/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskArchiveServiceTest.java new file mode 100644 index 0000000..6f4da82 --- /dev/null +++ b/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskArchiveServiceTest.java @@ -0,0 +1,97 @@ +package me.gg.pinit.pinittask.application.task.service; + +import me.gg.pinit.pinittask.application.member.service.MemberService; +import me.gg.pinit.pinittask.domain.task.model.Task; +import me.gg.pinit.pinittask.domain.task.repository.TaskRepository; +import me.gg.pinit.pinittask.domain.task.vo.ImportanceConstraint; +import me.gg.pinit.pinittask.domain.task.vo.TemporalConstraint; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.*; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TaskArchiveServiceTest { + + @Mock + TaskRepository taskRepository; + @Mock + MemberService memberService; + + Clock fixedClock; + TaskArchiveService service; + + @BeforeEach + void setUp() { + fixedClock = Clock.fixed(Instant.parse("2025-01-10T00:00:00Z"), ZoneOffset.UTC); + service = new TaskArchiveService(taskRepository, memberService, fixedClock); + } + + @Test + void getCompletedArchive_buildsCutoffAndNextCursor() { + ZoneOffset offset = ZoneOffset.of("+09:00"); + when(memberService.findZoneOffsetOfMember(1L)).thenReturn(offset); + + Task t1 = new Task(1L, "a", "a", new TemporalConstraint(ZonedDateTime.of(2025, 1, 9, 0, 0, 0, 0, offset), Duration.ZERO), new ImportanceConstraint(1, 1)); + Task t2 = new Task(1L, "b", "b", new TemporalConstraint(ZonedDateTime.of(2025, 1, 8, 0, 0, 0, 0, offset), Duration.ZERO), new ImportanceConstraint(1, 1)); + Task t3 = new Task(1L, "c", "c", new TemporalConstraint(ZonedDateTime.of(2025, 1, 7, 0, 0, 0, 0, offset), Duration.ZERO), new ImportanceConstraint(1, 1)); + ReflectionTestUtils.setField(t1, "id", 10L); + ReflectionTestUtils.setField(t2, "id", 9L); + ReflectionTestUtils.setField(t3, "id", 8L); + + when(taskRepository.findCompletedArchiveByCursor(anyLong(), any(), any(), anyLong(), any(Pageable.class))) + .thenReturn(List.of(t1, t2, t3)); + + TaskArchiveService.CursorPage page = service.getCompletedArchive(1L, 2, null, null); + + assertThat(page.hasNext()).isTrue(); + assertThat(page.nextCursor()).isEqualTo("2025-01-08|9"); + assertThat(page.cutoffDate()).isEqualTo(LocalDate.of(2025, 1, 9)); + + ArgumentCaptor cutoffCaptor = ArgumentCaptor.forClass(LocalDate.class); + ArgumentCaptor cursorDateCaptor = ArgumentCaptor.forClass(LocalDate.class); + ArgumentCaptor cursorIdCaptor = ArgumentCaptor.forClass(Long.class); + verify(taskRepository).findCompletedArchiveByCursor(eq(1L), cutoffCaptor.capture(), cursorDateCaptor.capture(), cursorIdCaptor.capture(), any(Pageable.class)); + assertThat(cutoffCaptor.getValue()).isEqualTo(LocalDate.of(2025, 1, 9)); + assertThat(cursorDateCaptor.getValue()).isEqualTo(LocalDate.of(2025, 1, 10)); + assertThat(cursorIdCaptor.getValue()).isEqualTo(Long.MAX_VALUE); + } + + @Test + void getCompletedArchive_prefersRequestOffset() { + ZoneOffset requestOffset = ZoneOffset.of("+02:00"); + when(taskRepository.findCompletedArchiveByCursor(anyLong(), any(), any(), anyLong(), any(Pageable.class))) + .thenReturn(List.of()); + TaskArchiveService.CursorPage page = service.getCompletedArchive(2L, 1, null, requestOffset); + + assertThat(page.cutoffDate()).isEqualTo(LocalDate.of(2025, 1, 9)); + verifyNoInteractions(memberService); + } + + @Test + void getCompletedArchive_rejectsInvalidSize() { + assertThatThrownBy(() -> service.getCompletedArchive(1L, 0, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("size는 1 이상 100 이하이어야 합니다."); + } + + @Test + void getCompletedArchive_rejectsBadCursor() { + when(memberService.findZoneOffsetOfMember(1L)).thenReturn(ZoneOffset.UTC); + assertThatThrownBy(() -> service.getCompletedArchive(1L, 20, "bad-cursor", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("커서는 'yyyy-MM-dd|id' 형식이어야 합니다."); + } +} diff --git a/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskServiceTest.java b/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskServiceTest.java index aef7d44..ca095d1 100644 --- a/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskServiceTest.java +++ b/src/test/java/me/gg/pinit/pinittask/application/task/service/TaskServiceTest.java @@ -2,6 +2,7 @@ import me.gg.pinit.pinittask.application.dependency.service.DependencyService; import me.gg.pinit.pinittask.application.events.DomainEventPublisher; +import me.gg.pinit.pinittask.application.member.service.MemberService; import me.gg.pinit.pinittask.application.schedule.service.ScheduleService; import me.gg.pinit.pinittask.application.schedule.service.ScheduleStateChangeService; import me.gg.pinit.pinittask.domain.events.DomainEvent; @@ -29,9 +30,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.test.util.ReflectionTestUtils; -import java.time.Duration; -import java.time.LocalDate; -import java.time.ZonedDateTime; +import java.time.*; import java.util.List; import java.util.Optional; @@ -52,6 +51,10 @@ class TaskServiceTest { ScheduleStateChangeService scheduleStateChangeService; @Mock DomainEventPublisher domainEventPublisher; + @Mock + MemberService memberService; + @Mock + Clock clock; @InjectMocks TaskService taskService; @@ -185,44 +188,56 @@ void getTasks_readyOnlyUsesInboundFilter() { Assertions.assertThat(result.getContent()).isEmpty(); verify(taskRepository).findAllByOwnerIdAndInboundDependencyCountAndCompletedFalse(ownerId, 0, pageable); - verify(taskRepository, never()).findAllByOwnerId(ownerId, pageable); + verify(taskRepository, never()).findCurrentByOwnerId(anyLong(), any(LocalDate.class), any()); } @Test void getTasks_returnsAllWhenNotReadyOnly() { Long ownerId = 10L; PageRequest pageable = PageRequest.of(1, 3); - when(taskRepository.findAllByOwnerId(ownerId, pageable)).thenReturn(new PageImpl<>(List.of())); + LocalDate today = LocalDate.of(2025, 1, 10); + ZoneOffset offset = ZoneOffset.UTC; + when(memberService.findZoneOffsetOfMember(ownerId)).thenReturn(offset); + when(clock.withZone(offset)).thenReturn(Clock.fixed(today.atStartOfDay(offset).toInstant(), offset)); + when(taskRepository.findCurrentByOwnerId(ownerId, today, pageable)).thenReturn(new PageImpl<>(List.of())); taskService.getTasks(ownerId, pageable, false); - verify(taskRepository).findAllByOwnerId(ownerId, pageable); + verify(taskRepository).findCurrentByOwnerId(ownerId, today, pageable); verify(taskRepository, never()).findAllByOwnerIdAndInboundDependencyCountAndCompletedFalse(anyLong(), anyInt(), any()); } @Test void getTasksByCursor_returnsNextCursorWhenPageFull() { Long ownerId = 50L; + LocalDate today = LocalDate.of(2025, 1, 10); + ZoneOffset offset = ZoneOffset.UTC; Task t1 = buildTask(ownerId); ReflectionTestUtils.setField(t1, "id", 1L); Task t2 = buildTask(ownerId); ReflectionTestUtils.setField(t2, "id", 2L); - when(taskRepository.findNextByCursor(eq(ownerId), eq(true), any(LocalDate.class), anyLong(), any())) + when(memberService.findZoneOffsetOfMember(ownerId)).thenReturn(offset); + when(clock.withZone(offset)).thenReturn(Clock.fixed(today.atStartOfDay(offset).toInstant(), offset)); + when(taskRepository.findNextByCursor(eq(ownerId), eq(true), any(LocalDate.class), anyLong(), eq(today), any())) .thenReturn(List.of(t1, t2)); TaskCursorPageResponse resp = taskService.getTasksByCursor(ownerId, 2, null, true); Assertions.assertThat(resp.hasNext()).isTrue(); Assertions.assertThat(resp.nextCursor()).contains("|2"); - verify(taskRepository).findNextByCursor(eq(ownerId), eq(true), any(), any(), any()); + verify(taskRepository).findNextByCursor(eq(ownerId), eq(true), any(), any(), eq(today), any()); } @Test void getTasksByCursor_noNextWhenSmallerThanSize() { Long ownerId = 51L; + LocalDate today = LocalDate.of(2025, 1, 10); + ZoneOffset offset = ZoneOffset.UTC; Task t1 = buildTask(ownerId); ReflectionTestUtils.setField(t1, "id", 5L); - when(taskRepository.findNextByCursor(eq(ownerId), eq(false), any(LocalDate.class), anyLong(), any())) + when(memberService.findZoneOffsetOfMember(ownerId)).thenReturn(offset); + when(clock.withZone(offset)).thenReturn(Clock.fixed(today.atStartOfDay(offset).toInstant(), offset)); + when(taskRepository.findNextByCursor(eq(ownerId), eq(false), any(LocalDate.class), anyLong(), eq(today), any())) .thenReturn(List.of(t1)); TaskCursorPageResponse resp = taskService.getTasksByCursor(ownerId, 2, null, false); @@ -251,6 +266,19 @@ void markIncomplete_setsFlagAndPublishesEvent() { Assertions.assertThat(event.ownerId()).isEqualTo(ownerId); } + @Test + void getTasksByDeadline_delegatesToRepository() { + Long ownerId = 15L; + LocalDate date = LocalDate.of(2025, 2, 1); + Task t1 = buildTask(ownerId); + when(taskRepository.findAllByOwnerIdAndDeadlineDate(ownerId, date)).thenReturn(List.of(t1)); + + List result = taskService.getTasksByDeadline(ownerId, date); + + Assertions.assertThat(result).containsExactly(t1); + verify(taskRepository).findAllByOwnerIdAndDeadlineDate(ownerId, date); + } + private Task buildTask(Long ownerId) { return new Task( ownerId, diff --git a/src/test/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepositoryTest.java b/src/test/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepositoryTest.java index 9b034e9..e58b546 100644 --- a/src/test/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepositoryTest.java +++ b/src/test/java/me/gg/pinit/pinittask/domain/task/repository/TaskRepositoryTest.java @@ -40,12 +40,167 @@ void findNextByCursor_ordersByDateThenId() { taskRepository.saveAll(List.of(t1, t2, t3)); - List firstPage = taskRepository.findNextByCursor(1L, false, LocalDate.of(2024, 1, 1), 0L, PageRequest.of(0, 10)); + List firstPage = taskRepository.findNextByCursor( + 1L, + false, + LocalDate.of(2024, 1, 1), + 0L, + LocalDate.of(2024, 1, 1), + PageRequest.of(0, 10)); assertThat(firstPage).extracting(Task::getId) .containsExactly(t1.getId(), t2.getId(), t3.getId()); - List afterFirst = taskRepository.findNextByCursor(1L, false, t1.getTemporalConstraint().getDeadlineDate(), t1.getId(), PageRequest.of(0, 10)); + List afterFirst = taskRepository.findNextByCursor( + 1L, + false, + t1.getTemporalConstraint().getDeadlineDate(), + t1.getId(), + LocalDate.of(2024, 1, 1), + PageRequest.of(0, 10)); assertThat(afterFirst).extracting(Task::getId) .containsExactly(t2.getId(), t3.getId()); } + + @Test + void findCurrentByOwnerId_filtersOverdueCompletedTasks() { + LocalDate today = LocalDate.of(2025, 1, 10); + Task completedOverdue = completedTask(1L, ZonedDateTime.of(2025, 1, 9, 0, 0, 0, 0, ZoneOffset.UTC)); + Task completedToday = completedTask(1L, ZonedDateTime.of(2025, 1, 10, 0, 0, 0, 0, ZoneOffset.UTC)); + Task completedFuture = completedTask(1L, ZonedDateTime.of(2025, 1, 11, 0, 0, 0, 0, ZoneOffset.UTC)); + Task incompletePast = new Task(1L, "todo", "desc", + new TemporalConstraint(ZonedDateTime.of(2025, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC), Duration.ZERO), + new ImportanceConstraint(1, 1)); + Task otherOwner = completedTask(2L, ZonedDateTime.of(2025, 1, 11, 0, 0, 0, 0, ZoneOffset.UTC)); + + taskRepository.saveAll(List.of(completedOverdue, completedToday, completedFuture, incompletePast, otherOwner)); + + var page = taskRepository.findCurrentByOwnerId(1L, today, PageRequest.of(0, 10)); + + assertThat(page.getContent()).extracting(Task::getId) + .containsExactly(incompletePast.getId(), completedToday.getId(), completedFuture.getId()); + assertThat(page.getContent()).extracting(task -> task.getTemporalConstraint().getDeadlineDate()) + .containsExactly(LocalDate.of(2025, 1, 1), LocalDate.of(2025, 1, 10), LocalDate.of(2025, 1, 11)); + } + + @Test + void findCompletedArchiveByCursor_filtersCompletedAndCutoffAndOrdersDesc() { + Task tAfterCutoff = completedTask(1L, ZonedDateTime.of(2025, 1, 10, 0, 0, 0, 0, ZoneOffset.UTC)); + Task t1 = completedTask(1L, ZonedDateTime.of(2025, 1, 7, 0, 0, 0, 0, ZoneOffset.UTC)); + Task t2 = completedTask(1L, ZonedDateTime.of(2025, 1, 6, 0, 0, 0, 0, ZoneOffset.UTC)); + Task t3 = new Task(1L, "uncompleted", "no", new TemporalConstraint(ZonedDateTime.of(2025, 1, 4, 0, 0, 0, 0, ZoneOffset.UTC), Duration.ZERO), new ImportanceConstraint(1, 1)); + Task tOtherOwner = completedTask(2L, ZonedDateTime.of(2025, 1, 6, 0, 0, 0, 0, ZoneOffset.UTC)); + + taskRepository.saveAll(List.of(tAfterCutoff, t1, t2, t3, tOtherOwner)); + + List result = taskRepository.findCompletedArchiveByCursor( + 1L, + LocalDate.of(2025, 1, 9), + LocalDate.of(2025, 1, 10), + Long.MAX_VALUE, + PageRequest.of(0, 10) + ); + + assertThat(result).extracting(Task::getId) + .containsExactly(t1.getId(), t2.getId()); + } + + @Test + void findCompletedArchiveByCursor_respectsCursorForSameDeadline() { + Task t1 = completedTask(1L, ZonedDateTime.of(2025, 2, 1, 0, 0, 0, 0, ZoneOffset.UTC)); + Task t2 = completedTask(1L, ZonedDateTime.of(2025, 2, 1, 0, 0, 0, 0, ZoneOffset.UTC)); + taskRepository.saveAll(List.of(t1, t2)); + + List firstPage = taskRepository.findCompletedArchiveByCursor( + 1L, + LocalDate.of(2025, 2, 2), + LocalDate.of(2025, 2, 2), + Long.MAX_VALUE, + PageRequest.of(0, 1) + ); + assertThat(firstPage).hasSize(1); + + Task last = firstPage.getLast(); + List secondPage = taskRepository.findCompletedArchiveByCursor( + 1L, + LocalDate.of(2025, 2, 2), + last.getTemporalConstraint().getDeadlineDate(), + last.getId(), + PageRequest.of(0, 1) + ); + + assertThat(secondPage).hasSize(1); + assertThat(secondPage.getFirst().getId()).isNotEqualTo(last.getId()); + } + + @Test + void findCompletedArchiveByCursor_includesCutoff_excludesAfterCutoff() { + LocalDate cutoff = LocalDate.of(2025, 1, 9); + Task afterCutoff = completedTask(1L, ZonedDateTime.of(2025, 1, 10, 0, 0, 0, 0, ZoneOffset.UTC)); // should be excluded + Task cutoffTask = completedTask(1L, ZonedDateTime.of(2025, 1, 9, 0, 0, 0, 0, ZoneOffset.UTC)); // should be included + Task beforeCutoff = completedTask(1L, ZonedDateTime.of(2025, 1, 8, 0, 0, 0, 0, ZoneOffset.UTC)); // should be included + taskRepository.saveAll(List.of(afterCutoff, cutoffTask, beforeCutoff)); + + List result = taskRepository.findCompletedArchiveByCursor( + 1L, + cutoff, + cutoff.plusDays(1), + Long.MAX_VALUE, + PageRequest.of(0, 10) + ); + + assertThat(result).extracting(Task::getId) + .containsExactly(cutoffTask.getId(), beforeCutoff.getId()); + } + + @Test + void findCompletedArchiveByCursor_skipsAlreadySeenWhenCursorMatchesDateAndId() { + LocalDate cutoff = LocalDate.of(2025, 3, 1); + Task first = completedTask(1L, ZonedDateTime.of(2025, 3, 1, 0, 0, 0, 0, ZoneOffset.UTC)); + Task second = completedTask(1L, ZonedDateTime.of(2025, 3, 1, 0, 0, 0, 0, ZoneOffset.UTC)); + taskRepository.saveAll(List.of(first, second)); + + // Order is deadline DESC, id DESC, so larger id comes first + Long maxId = Math.max(first.getId(), second.getId()); + Long minId = Math.min(first.getId(), second.getId()); + + List pageAfterCursor = taskRepository.findCompletedArchiveByCursor( + 1L, + cutoff, + cutoff, + maxId, + PageRequest.of(0, 10) + ); + + assertThat(pageAfterCursor).extracting(Task::getId) + .containsExactly(minId); + } + + @Test + void findAllByOwnerIdAndDeadlineDate_returnsOnlyMatchingDate() { + Task target1 = new Task(1L, "A", "A-desc", + new TemporalConstraint(ZonedDateTime.of(2025, 2, 1, 0, 0, 0, 0, ZoneOffset.UTC), Duration.ZERO), + new ImportanceConstraint(1, 1)); + Task target2 = new Task(1L, "B", "B-desc", + new TemporalConstraint(ZonedDateTime.of(2025, 2, 1, 12, 0, 0, 0, ZoneOffset.UTC), Duration.ZERO), + new ImportanceConstraint(2, 2)); + Task otherDate = new Task(1L, "C", "C-desc", + new TemporalConstraint(ZonedDateTime.of(2025, 2, 2, 0, 0, 0, 0, ZoneOffset.UTC), Duration.ZERO), + new ImportanceConstraint(3, 3)); + Task otherOwner = new Task(2L, "D", "D-desc", + new TemporalConstraint(ZonedDateTime.of(2025, 2, 1, 0, 0, 0, 0, ZoneOffset.UTC), Duration.ZERO), + new ImportanceConstraint(5, 5)); + + taskRepository.saveAll(List.of(target1, target2, otherDate, otherOwner)); + + List result = taskRepository.findAllByOwnerIdAndDeadlineDate(1L, LocalDate.of(2025, 2, 1)); + + assertThat(result).extracting(Task::getId) + .containsExactly(target1.getId(), target2.getId()); + } + + private Task completedTask(Long ownerId, ZonedDateTime deadline) { + Task task = new Task(ownerId, "done", "desc", new TemporalConstraint(deadline, Duration.ZERO), new ImportanceConstraint(1, 1)); + task.markCompleted(); + return task; + } } diff --git a/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskArchiveControllerV2IntegrationTest.java b/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskArchiveControllerV2IntegrationTest.java new file mode 100644 index 0000000..8cca8f6 --- /dev/null +++ b/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskArchiveControllerV2IntegrationTest.java @@ -0,0 +1,133 @@ +package me.gg.pinit.pinittask.interfaces.web; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import me.gg.pinit.pinittask.domain.member.model.Member; +import me.gg.pinit.pinittask.domain.member.repository.MemberRepository; +import me.gg.pinit.pinittask.domain.task.model.Task; +import me.gg.pinit.pinittask.domain.task.repository.TaskRepository; +import me.gg.pinit.pinittask.domain.task.vo.ImportanceConstraint; +import me.gg.pinit.pinittask.domain.task.vo.TemporalConstraint; +import me.gg.pinit.pinittask.infrastructure.events.RabbitEventPublisher; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.*; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TaskArchiveControllerV2IntegrationTest.FixedClockConfig.class) +@Transactional +class TaskArchiveControllerV2IntegrationTest { + + private static final long MEMBER_ID = 44L; + private static final ZoneOffset MEMBER_OFFSET = ZoneOffset.of("+09:00"); + + @Autowired + MockMvc mockMvc; + @Autowired + ObjectMapper objectMapper; + @Autowired + TaskRepository taskRepository; + @Autowired + MemberRepository memberRepository; + + @MockitoBean + RabbitEventPublisher rabbitEventPublisher; + + @BeforeEach + void setUpMember() { + if (!memberRepository.existsById(MEMBER_ID)) { + memberRepository.save(new Member(MEMBER_ID, "archive-user", MEMBER_OFFSET)); + } + } + + @Test + void completedArchive_paginatesWithCursorAndRespectsCutoff() throws Exception { + Task excluded = completedTask(LocalDate.of(2025, 1, 10)); // cutoff+1 -> 제외 + Task first = completedTask(LocalDate.of(2025, 1, 9)); // cutoff 포함 + Task second = completedTask(LocalDate.of(2025, 1, 8)); + taskRepository.saveAll(List.of(excluded, first, second)); + + var firstResponse = mockMvc.perform(get("/v2/tasks/completed") + .header("X-Member-Id", MEMBER_ID) + .param("size", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(1))) + .andExpect(jsonPath("$.data[0].dueDate.date").value("2025-01-09")) + .andExpect(jsonPath("$.hasNext").value(true)) + .andReturn(); + + JsonNode firstNode = objectMapper.readTree(firstResponse.getResponse().getContentAsString()); + String cursor = firstNode.get("nextCursor").asText(); + assertThat(cursor).isNotBlank(); + + mockMvc.perform(get("/v2/tasks/completed") + .header("X-Member-Id", MEMBER_ID) + .param("size", "1") + .param("cursor", cursor)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(1))) + .andExpect(jsonPath("$.data[0].dueDate.date").value("2025-01-08")) + .andExpect(jsonPath("$.hasNext").value(false)); + } + + @Test + void completedArchive_usesRequestOffsetForCutoff() throws Exception { + Task include = completedTask(LocalDate.of(2025, 1, 8)); + Task exclude = completedTask(LocalDate.of(2025, 1, 9)); // cutoff with -05:00 is 2025-01-08 + taskRepository.saveAll(List.of(include, exclude)); + + mockMvc.perform(get("/v2/tasks/completed") + .header("X-Member-Id", MEMBER_ID) + .param("offset", "-05:00") + .param("size", "5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(1))) + .andExpect(jsonPath("$.data[0].dueDate.date").value("2025-01-08")) + .andExpect(jsonPath("$.hasNext").value(false)); + } + + @Test + void completedArchive_rejectsInvalidSize() throws Exception { + mockMvc.perform(get("/v2/tasks/completed") + .header("X-Member-Id", MEMBER_ID) + .param("size", "0")) + .andExpect(status().isBadRequest()); + } + + private Task completedTask(LocalDate deadlineDate) { + ZonedDateTime deadline = deadlineDate.atStartOfDay(MEMBER_OFFSET); + Task task = new Task(MEMBER_ID, "archive", "desc", new TemporalConstraint(deadline, Duration.ZERO), new ImportanceConstraint(1, 1)); + task.markCompleted(); + return task; + } + + @TestConfiguration + static class FixedClockConfig { + @Bean + @Primary + Clock fixedClock() { + return Clock.fixed(Instant.parse("2025-01-10T00:00:00Z"), ZoneOffset.UTC); + } + } +} diff --git a/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV2IntegrationTest.java b/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV2IntegrationTest.java index 9a204d9..ffb9318 100644 --- a/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV2IntegrationTest.java +++ b/src/test/java/me/gg/pinit/pinittask/interfaces/web/TaskControllerV2IntegrationTest.java @@ -18,6 +18,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.time.ZoneOffset; import java.util.List; @@ -100,4 +101,49 @@ void createAndFetchTasksWithDateOffsetCursor() throws Exception { assertThat(cursorNode.get("data").get(0).get("dueDate").get("date").asText()).isEqualTo("2024-04-01"); assertThat(cursorNode.get("data").get(0).get("dueDate").get("offset").asText()).isEqualTo("+09:00"); } + + @Test + void fetchTasksByDeadlineReturnsOnlyMatchingDate() throws Exception { + // create two tasks with same deadline and one with different deadline + TaskCreateRequestV2 d1Req = new TaskCreateRequestV2( + "D1-1", + "same date 1", + new DateWithOffset(LocalDate.of(2025, 2, 1), OFFSET), + 3, + 2, + List.of() + ); + TaskCreateRequestV2 d1Req2 = new TaskCreateRequestV2( + "D1-2", + "same date 2", + new DateWithOffset(LocalDate.of(2025, 2, 1), OFFSET), + 2, + 1, + List.of() + ); + TaskCreateRequestV2 otherReq = new TaskCreateRequestV2( + "Other", + "other date", + new DateWithOffset(LocalDate.of(2025, 2, 2), OFFSET), + 1, + 1, + List.of() + ); + + for (TaskCreateRequestV2 req : List.of(d1Req, d1Req2, otherReq)) { + mockMvc.perform(post("/v2/tasks") + .header("X-Member-Id", MEMBER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isCreated()); + } + + mockMvc.perform(get("/v2/tasks/by-deadline") + .header("X-Member-Id", MEMBER_ID) + .param("date", "2025-02-01")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].dueDate.date").value("2025-02-01")) + .andExpect(jsonPath("$[1].dueDate.date").value("2025-02-01")); + } }