diff --git a/src/main/java/com/tryna/domain/action/controller/ActionItemController.java b/src/main/java/com/tryna/domain/action/controller/ActionItemController.java index 560e302..a39badb 100644 --- a/src/main/java/com/tryna/domain/action/controller/ActionItemController.java +++ b/src/main/java/com/tryna/domain/action/controller/ActionItemController.java @@ -181,4 +181,35 @@ public ResponseEntity> getTimedActionItems( ) ); } -} \ No newline at end of file + + /** + * F104-2: 월간 캘린더 내 시간형 실행 항목 조회 + */ + @GetMapping("/calendar/action-items/timed/monthly") + @Override + public ResponseEntity> getMonthlyTimedActionItems( + @RequestParam("year") Integer year, + @RequestParam("month") Integer month + ) { + // 1. SecurityContext에서 현재 사용자 ID 추출 + Long userId = extractUserIdFromSecurityContext(); + + // 2. 인증 정보가 없는 경우 인증 예외 처리 + if (userId == null) { + throw new BusinessException(AuthErrorCode.AUTH_401); + } + + // 3. 조회 연월의 시간형 실행 항목을 날짜별로 조회 + MonthlyTimedActionItemResponse response = + actionItemService.getMonthlyTimedActionItems(userId, year, month); + + // 4. 공통 응답 형식으로 반환 + return ResponseEntity.ok( + ApiResponse.success( + "F104_ACTION_ITEM_200", + "월간 시간형 실행 항목 조회에 성공했습니다.", + response + ) + ); + } +} diff --git a/src/main/java/com/tryna/domain/action/controller/docs/ActionItemControllerDocs.java b/src/main/java/com/tryna/domain/action/controller/docs/ActionItemControllerDocs.java index 5f9a2ad..b4159c6 100644 --- a/src/main/java/com/tryna/domain/action/controller/docs/ActionItemControllerDocs.java +++ b/src/main/java/com/tryna/domain/action/controller/docs/ActionItemControllerDocs.java @@ -92,4 +92,33 @@ ResponseEntity> getTimedActionItems( ) @RequestParam("date") String date ); + + @Operation( + summary = "F104-2 월간 캘린더 내 시간형 실행 항목 조회", + description = """ + 조회 연월에 displayDate가 포함되는 시간형 실행 항목을 반환합니다. + 부모 일정 날짜와 관계없이 계산된 displayDate를 기준으로 월 포함 여부를 판단하며, + 현재 사용자의 CONFIRMED 또는 NEEDS_CONFIRMATION 일정에 연결된 TIMED_ACTION만 포함합니다. + 삭제된 항목과 삭제된 반복 회차는 제외하고, 반복 항목은 offsetDays와 회차별 상태를 반영합니다. + 각 항목에는 부모 일정의 labelId와 상세 이동에 사용할 parentOccurrenceDate가 포함됩니다. + 회원과 비회원 모두 유효한 Bearer 토큰으로 호출할 수 있습니다. + """, + operationId = "getMonthlyTimedActionItems" + ) + @SecurityRequirement(name = "bearerAuth") + ResponseEntity> getMonthlyTimedActionItems( + @Parameter( + description = "조회할 연도(1970~2100)", + required = true, + example = "2026" + ) + @RequestParam("year") Integer year, + + @Parameter( + description = "조회할 월(1~12)", + required = true, + example = "8" + ) + @RequestParam("month") Integer month + ); } diff --git a/src/main/java/com/tryna/domain/action/dto/MonthlyTimedActionItemResponse.java b/src/main/java/com/tryna/domain/action/dto/MonthlyTimedActionItemResponse.java new file mode 100644 index 0000000..6d31238 --- /dev/null +++ b/src/main/java/com/tryna/domain/action/dto/MonthlyTimedActionItemResponse.java @@ -0,0 +1,85 @@ +package com.tryna.domain.action.dto; + +import com.tryna.domain.action.enums.ActionItemStatus; +import com.tryna.domain.action.enums.CreatedBy; +import com.tryna.domain.action.enums.ItemType; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "F104-2 월간 캘린더 내 시간형 실행 항목 조회 응답 DTO") +public record MonthlyTimedActionItemResponse( + + @Schema(description = "조회 연도", example = "2026") + Integer year, + + @Schema(description = "조회 월", example = "8") + Integer month, + + @Schema(description = "조회 월에 표시할 시간형 실행 항목 목록") + List items + +) { + @Schema(description = "월간 캘린더에 표시할 시간형 실행 항목") + public record Item( + + @Schema(description = "준비/실행 항목 ID", example = "10") + Long actionItemId, + + @Schema(description = "연결된 일정 ID", example = "1") + Long parentEventId, + + @Schema(description = "항목이 속한 부모 일정 회차 날짜", example = "2026-09-02") + LocalDate parentOccurrenceDate, + + @Schema(description = "연결된 일정 제목", example = "엄마 생신") + String parentEventTitle, + + @Schema(description = "현재 사용자의 부모 일정 라벨 ID", example = "110") + Long labelId, + + @Schema(description = "준비/실행 항목 제목", example = "꽃다발 준비하기") + String title, + + @Schema(description = "항목 유형", example = "TIMED_ACTION") + ItemType itemType, + + @Schema(description = "캘린더 표시 날짜", example = "2026-08-30") + LocalDate displayDate, + + @Schema(description = "캘린더 표시 시간", example = "2026-08-30T09:00:00") + LocalDateTime displayTime, + + @Schema(description = "항목 상태", example = "PENDING") + ActionItemStatus actionItemStatus, + + @Schema(description = "항목 생성 주체", example = "SYSTEM") + CreatedBy createdBy, + + @Schema(description = "완료 처리 일시. 미완료 상태이면 null", example = "2026-08-30T18:30:12") + LocalDateTime completedAt + + ) { + public static Item from( + TimedActionItemResponse.Item item, + LocalDate parentOccurrenceDate, + Long labelId + ) { + return new Item( + item.actionItemId(), + item.parentEventId(), + parentOccurrenceDate, + item.parentEventTitle(), + labelId, + item.title(), + item.itemType(), + item.displayDate(), + item.displayTime(), + item.actionItemStatus(), + item.createdBy(), + item.completedAt() + ); + } + } +} diff --git a/src/main/java/com/tryna/domain/action/repository/ActionItemsRepository.java b/src/main/java/com/tryna/domain/action/repository/ActionItemsRepository.java index 982e762..83f191a 100644 --- a/src/main/java/com/tryna/domain/action/repository/ActionItemsRepository.java +++ b/src/main/java/com/tryna/domain/action/repository/ActionItemsRepository.java @@ -172,6 +172,39 @@ List findCalendarActionItemsByDate( @Param("itemType") ItemType itemType ); + /** + * 현재 사용자의 일정에 연결된 시간형 실행 항목을 표시 날짜 범위로 조회합니다. + * + * 반복 일정의 offset 기반 항목은 서비스에서 월간 회차를 동적으로 계산하므로 + * 이 쿼리에서는 원본 displayDate를 직접 사용하는 항목만 조회합니다. + * + * @param userId 현재 사용자 ID + * @param startDate 조회 시작일 + * @param endDate 조회 종료일 + * @param itemType 항목 유형 + * @return 조회 범위에 직접 표시할 시간형 실행 항목 목록 + */ + @Query(""" + SELECT a + FROM ActionItems a + JOIN FETCH a.parentEvent e + JOIN UserEvents ue ON ue.event = e + WHERE ue.user.userId = :userId + AND a.displayDate BETWEEN :startDate AND :endDate + AND a.itemType = :itemType + AND e.eventStatus IN :eventStatuses + AND (e.isRecurring = false OR a.offsetDays IS NULL) + AND a.deletedAt IS NULL + ORDER BY a.displayDate ASC, a.displayDatetime ASC, a.actionItemId ASC + """) + List findCalendarActionItemsByDateRange( + @Param("userId") Long userId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("itemType") ItemType itemType, + @Param("eventStatuses") Collection eventStatuses + ); + /** * 반복 일정에 연결된 시간형 실행 항목 후보를 조회합니다. * diff --git a/src/main/java/com/tryna/domain/action/service/ActionItemService.java b/src/main/java/com/tryna/domain/action/service/ActionItemService.java index b5bfcdb..4f3cc46 100644 --- a/src/main/java/com/tryna/domain/action/service/ActionItemService.java +++ b/src/main/java/com/tryna/domain/action/service/ActionItemService.java @@ -5,6 +5,7 @@ import com.tryna.domain.action.dto.ActionItemStatusUpdateRequest; import com.tryna.domain.action.dto.ActionItemStatusUpdateResponse; import com.tryna.domain.action.dto.EventActionItemResponse; +import com.tryna.domain.action.dto.MonthlyTimedActionItemResponse; import com.tryna.domain.action.dto.TimedActionItemResponse; import com.tryna.domain.action.entity.ActionItemOccurrenceStates; import com.tryna.domain.action.entity.ActionItems; @@ -32,6 +33,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.YearMonth; import java.time.format.DateTimeParseException; import java.time.temporal.ChronoUnit; import java.util.ArrayList; @@ -48,6 +50,9 @@ @Transactional(readOnly = true) public class ActionItemService { + private static final int MIN_YEAR = 1970; + private static final int MAX_YEAR = 2100; + private final ActionItemsRepository actionItemsRepository; private final ActionItemOccurrenceStatesRepository actionItemOccurrenceStatesRepository; private final RecommendationFeedbacksRepository recommendationFeedbacksRepository; @@ -490,6 +495,162 @@ public TimedActionItemResponse getTimedActionItems( return TimedActionItemResponse.of(date, sortedItems); } + /** + * 월간 캘린더에 표시할 시간형 실행 항목을 날짜별로 조회합니다. + * 기존 일별 F104와 동일한 반복 회차 및 회차별 상태 계산 규칙을 사용합니다. + */ + public MonthlyTimedActionItemResponse getMonthlyTimedActionItems( + Long userId, + Integer year, + Integer month + ) { + // 1. 조회 연월을 검증하고 월의 시작일과 종료일을 계산 + YearMonth yearMonth = parseYearMonth(year, month); + LocalDate startDate = yearMonth.atDay(1); + LocalDate endDate = yearMonth.atEndOfMonth(); + + // 2. displayDate가 조회 월에 직접 포함되는 시간형 항목을 한 번에 조회 + List candidates = new ArrayList<>(); + List directActionItems = actionItemsRepository + .findCalendarActionItemsByDateRange( + userId, + startDate, + endDate, + ItemType.TIMED_ACTION, + visibleActionItemEventStatuses() + ); + + candidates.addAll(directActionItems.stream() + .filter(actionItem -> actionItem.getActionItemStatus() != ActionItemStatus.DELETED) + .map(actionItem -> new MonthlyActionItemCandidate( + TimedActionItemResponse.Item.from(actionItem), + resolveParentOccurrenceDate( + actionItem, + actionItem.getOccurrenceDate(), + actionItem.getDisplayDate() + ) + )) + .toList()); + + // 3. 반복 원본은 한 번만 조회한 뒤 월의 각 날짜에 표시할 회차를 메모리에서 계산 + List recurringCandidates = actionItemsRepository + .findRecurringTimedActionItemsByUserId( + userId, + ItemType.TIMED_ACTION, + visibleActionItemEventStatuses() + ) + .stream() + .filter(actionItem -> actionItem.getActionItemStatus() != ActionItemStatus.DELETED) + .toList(); + + List monthlyRecurringOccurrences = startDate + .datesUntil(endDate.plusDays(1)) + .flatMap(displayDate -> recurringCandidates.stream() + .map(actionItem -> toRecurringActionItemOccurrence(actionItem, displayDate)) + .filter(Objects::nonNull) + .map(occurrence -> new MonthlyRecurringActionItemOccurrence( + displayDate, + occurrence + ))) + .toList(); + + // 4. 발생한 회차의 완료 상태를 일괄 조회하여 각 월간 항목에 반영 + List recurringOccurrences = monthlyRecurringOccurrences.stream() + .map(MonthlyRecurringActionItemOccurrence::occurrence) + .toList(); + Map statesByKey = findStatesByKey(recurringOccurrences); + + candidates.addAll(monthlyRecurringOccurrences.stream() + .filter(monthlyOccurrence -> { + ActionItemOccurrenceStates state = statesByKey.get(new StateKey( + monthlyOccurrence.occurrence().actionItem().getActionItemId(), + monthlyOccurrence.occurrence().occurrenceDate() + )); + return state == null || state.getActionItemStatus() != ActionItemStatus.DELETED; + }) + .map(monthlyOccurrence -> { + ActionItemOccurrenceStates state = statesByKey.get(new StateKey( + monthlyOccurrence.occurrence().actionItem().getActionItemId(), + monthlyOccurrence.occurrence().occurrenceDate() + )); + TimedActionItemResponse.Item item = TimedActionItemResponse.Item.fromOccurrence( + monthlyOccurrence.occurrence().actionItem(), + monthlyOccurrence.occurrence().occurrenceDate(), + monthlyOccurrence.displayDate(), + monthlyOccurrence.occurrence().displayTime(), + state + ); + return new MonthlyActionItemCandidate( + item, + monthlyOccurrence.occurrence().occurrenceDate() + ); + }) + .toList()); + + // 5. 부모 일정 라벨을 일괄 조회한 뒤 월간 응답 항목으로 변환 + List parentEventIds = candidates.stream() + .map(candidate -> candidate.item().parentEventId()) + .distinct() + .toList(); + Map labelIdsByEventId = findLabelIdsByEventId(userId, parentEventIds); + + List sortedItems = candidates.stream() + .map(candidate -> MonthlyTimedActionItemResponse.Item.from( + candidate.item(), + candidate.parentOccurrenceDate(), + labelIdsByEventId.get(candidate.item().parentEventId()) + )) + .sorted(Comparator + .comparing(MonthlyTimedActionItemResponse.Item::displayDate) + .thenComparing( + MonthlyTimedActionItemResponse.Item::displayTime, + Comparator.nullsLast(Comparator.naturalOrder()) + ) + .thenComparing(MonthlyTimedActionItemResponse.Item::actionItemId)) + .toList(); + + return new MonthlyTimedActionItemResponse( + yearMonth.getYear(), + yearMonth.getMonthValue(), + sortedItems + ); + } + + private Map findLabelIdsByEventId(Long userId, List eventIds) { + if (eventIds.isEmpty()) { + return Map.of(); + } + + return userEventsRepository.findLabelIdsByUserIdAndEventIds(userId, eventIds) + .stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> (Long) row[1] + )); + } + + private LocalDate resolveParentOccurrenceDate( + ActionItems actionItem, + LocalDate occurrenceDate, + LocalDate displayDate + ) { + if (occurrenceDate != null) { + return occurrenceDate; + } + + LocalDate parentStartDate = actionItem.getParentEvent().getStartDate(); + return parentStartDate != null ? parentStartDate : displayDate; + } + + private YearMonth parseYearMonth(Integer year, Integer month) { + if (year == null || year < MIN_YEAR || year > MAX_YEAR + || month == null || month < 1 || month > 12) { + throw new BusinessException(ActionErrorCode.F104_ACTION_ITEM_400); + } + + return YearMonth.of(year, month); + } + private List visibleActionItemEventStatuses() { return List.of(EventStatus.CONFIRMED, EventStatus.NEEDS_CONFIRMATION); } @@ -591,6 +752,18 @@ private record RecurringActionItemOccurrence( ) { } + private record MonthlyRecurringActionItemOccurrence( + LocalDate displayDate, + RecurringActionItemOccurrence occurrence + ) { + } + + private record MonthlyActionItemCandidate( + TimedActionItemResponse.Item item, + LocalDate parentOccurrenceDate + ) { + } + private record StateKey( Long actionItemId, LocalDate occurrenceDate diff --git a/src/test/java/com/tryna/domain/action/service/ActionItemServiceTest.java b/src/test/java/com/tryna/domain/action/service/ActionItemServiceTest.java index ac1d030..92fdd3c 100644 --- a/src/test/java/com/tryna/domain/action/service/ActionItemServiceTest.java +++ b/src/test/java/com/tryna/domain/action/service/ActionItemServiceTest.java @@ -10,13 +10,16 @@ import com.tryna.domain.action.dto.ActionItemStatusUpdateRequest; import com.tryna.domain.action.dto.ActionItemStatusUpdateResponse; import com.tryna.domain.action.dto.EventActionItemResponse; +import com.tryna.domain.action.dto.MonthlyTimedActionItemResponse; import com.tryna.domain.action.entity.ActionItemOccurrenceStates; import com.tryna.domain.action.entity.ActionItems; import com.tryna.domain.action.enums.ActionItemStatus; +import com.tryna.domain.action.enums.CreatedBy; import com.tryna.domain.action.enums.ItemType; import com.tryna.domain.action.repository.ActionItemOccurrenceStatesRepository; import com.tryna.domain.action.repository.ActionItemsRepository; import com.tryna.domain.event.entity.Events; +import com.tryna.domain.event.enums.EventStatus; import com.tryna.domain.event.enums.RecurrenceDayOfWeek; import com.tryna.domain.event.enums.RecurrenceType; import com.tryna.domain.event.repository.EventsRepository; @@ -27,6 +30,7 @@ import com.tryna.global.exception.ActionErrorCode; import com.tryna.global.exception.BusinessException; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -226,6 +230,163 @@ void recurringMultiDayStatusUpdateRejectsDateOutsideOccurrence() { ); } + @Test + void monthlyTimedActionItemsIncludeDirectItemsOnDisplayDate() { + Long userId = 1L; + ActionItems actionItem = mock(ActionItems.class); + ActionItems deletedActionItem = mock(ActionItems.class); + Events event = mock(Events.class); + LocalDate displayDate = LocalDate.of(2026, 8, 30); + LocalDate parentStartDate = LocalDate.of(2026, 9, 2); + + when(actionItem.getActionItemId()).thenReturn(200L); + when(actionItem.getParentEvent()).thenReturn(event); + when(actionItem.getTitle()).thenReturn("선물 구매하기"); + when(actionItem.getItemType()).thenReturn(ItemType.TIMED_ACTION); + when(actionItem.getDisplayDate()).thenReturn(displayDate); + when(actionItem.getDisplayDatetime()).thenReturn(displayDate.atTime(18, 0)); + when(actionItem.getActionItemStatus()).thenReturn(ActionItemStatus.PENDING); + when(actionItem.getCreatedBy()).thenReturn(CreatedBy.USER); + when(deletedActionItem.getActionItemStatus()).thenReturn(ActionItemStatus.DELETED); + when(event.getEventId()).thenReturn(10L); + when(event.getTitle()).thenReturn("지민이 생일"); + when(event.getStartDate()).thenReturn(parentStartDate); + when(actionItemsRepository.findCalendarActionItemsByDateRange( + userId, + LocalDate.of(2026, 8, 1), + LocalDate.of(2026, 8, 31), + ItemType.TIMED_ACTION, + List.of(EventStatus.CONFIRMED, EventStatus.NEEDS_CONFIRMATION) + )).thenReturn(List.of(actionItem, deletedActionItem)); + when(actionItemsRepository.findRecurringTimedActionItemsByUserId( + userId, + ItemType.TIMED_ACTION, + List.of(EventStatus.CONFIRMED, EventStatus.NEEDS_CONFIRMATION) + )).thenReturn(List.of()); + when(userEventsRepository.findLabelIdsByUserIdAndEventIds(userId, List.of(10L))) + .thenReturn(List.of(new Object[]{10L, 110L})); + + MonthlyTimedActionItemResponse response = + actionItemService.getMonthlyTimedActionItems(userId, 2026, 8); + + assertThat(response.year()).isEqualTo(2026); + assertThat(response.month()).isEqualTo(8); + assertThat(response.items()).hasSize(1); + assertThat(response.items().getFirst().title()).isEqualTo("선물 구매하기"); + assertThat(response.items().getFirst().displayDate()).isEqualTo(displayDate); + assertThat(response.items().getFirst().parentOccurrenceDate()).isEqualTo(parentStartDate); + assertThat(response.items().getFirst().labelId()).isEqualTo(110L); + } + + @Test + void monthlyTimedActionItemsCalculateRecurringOccurrencesAndApplyOccurrenceState() { + Long userId = 1L; + Long actionItemId = 300L; + Events event = mock(Events.class); + ActionItems actionItem = mock(ActionItems.class); + ActionItemOccurrenceStates state = mock(ActionItemOccurrenceStates.class); + ActionItemOccurrenceStates deletedState = mock(ActionItemOccurrenceStates.class); + LocalDate completedOccurrenceDate = LocalDate.of(2026, 8, 10); + LocalDate deletedOccurrenceDate = LocalDate.of(2026, 8, 17); + + when(event.getEventId()).thenReturn(20L); + when(event.getTitle()).thenReturn("매주 운동"); + when(event.getIsRecurring()).thenReturn(true); + when(event.getStartDate()).thenReturn(LocalDate.of(2026, 8, 3)); + when(event.getRecurrenceType()).thenReturn(RecurrenceType.WEEKLY); + when(event.getRecurrenceInterval()).thenReturn(1); + when(event.getRecurrenceDayOfWeek()).thenReturn(RecurrenceDayOfWeek.MON); + when(actionItem.getActionItemId()).thenReturn(actionItemId); + when(actionItem.getParentEvent()).thenReturn(event); + when(actionItem.getTitle()).thenReturn("운동복 준비하기"); + when(actionItem.getItemType()).thenReturn(ItemType.TIMED_ACTION); + when(actionItem.getOffsetDays()).thenReturn(-1); + when(actionItem.getDisplayDatetime()).thenReturn(LocalDateTime.of(2026, 8, 2, 8, 0)); + when(actionItem.getCreatedBy()).thenReturn(CreatedBy.SYSTEM); + when(actionItemsRepository.findCalendarActionItemsByDateRange( + userId, + LocalDate.of(2026, 8, 1), + LocalDate.of(2026, 8, 31), + ItemType.TIMED_ACTION, + List.of(EventStatus.CONFIRMED, EventStatus.NEEDS_CONFIRMATION) + )).thenReturn(List.of()); + when(actionItemsRepository.findRecurringTimedActionItemsByUserId( + userId, + ItemType.TIMED_ACTION, + List.of(EventStatus.CONFIRMED, EventStatus.NEEDS_CONFIRMATION) + )).thenReturn(List.of(actionItem)); + when(actionItemOccurrenceStatesRepository + .findByActionItem_ActionItemIdInAndOccurrenceDateIn( + List.of(actionItemId), + List.of( + LocalDate.of(2026, 8, 3), + LocalDate.of(2026, 8, 10), + LocalDate.of(2026, 8, 17), + LocalDate.of(2026, 8, 24), + LocalDate.of(2026, 8, 31) + ) + )).thenReturn(List.of(state, deletedState)); + when(state.getActionItem()).thenReturn(actionItem); + when(state.getOccurrenceDate()).thenReturn(completedOccurrenceDate); + when(state.getActionItemStatus()).thenReturn(ActionItemStatus.COMPLETED); + when(deletedState.getActionItem()).thenReturn(actionItem); + when(deletedState.getOccurrenceDate()).thenReturn(deletedOccurrenceDate); + when(deletedState.getActionItemStatus()).thenReturn(ActionItemStatus.DELETED); + when(userEventsRepository.findLabelIdsByUserIdAndEventIds(userId, List.of(20L))) + .thenReturn(List.of(new Object[]{20L, 120L})); + + MonthlyTimedActionItemResponse response = + actionItemService.getMonthlyTimedActionItems(userId, 2026, 8); + + assertThat(response.items()).hasSize(4); + assertThat(response.items()) + .noneMatch(item -> item.parentOccurrenceDate().equals(deletedOccurrenceDate)); + assertThat(response.items()) + .filteredOn(item -> item.parentOccurrenceDate().equals(completedOccurrenceDate)) + .singleElement() + .satisfies(item -> { + assertThat(item.displayDate()).isEqualTo(LocalDate.of(2026, 8, 9)); + assertThat(item.actionItemStatus()).isEqualTo(ActionItemStatus.COMPLETED); + assertThat(item.labelId()).isEqualTo(120L); + }); + assertThat(response.items()) + .filteredOn(item -> item.parentOccurrenceDate().equals(LocalDate.of(2026, 8, 24))) + .singleElement() + .satisfies(item -> assertThat(item.actionItemStatus()).isEqualTo(ActionItemStatus.PENDING)); + } + + @Test + void monthlyTimedActionItemsReturnEmptyItemsWhenNoMatches() { + Long userId = 1L; + + when(actionItemsRepository.findCalendarActionItemsByDateRange( + userId, + LocalDate.of(2026, 8, 1), + LocalDate.of(2026, 8, 31), + ItemType.TIMED_ACTION, + List.of(EventStatus.CONFIRMED, EventStatus.NEEDS_CONFIRMATION) + )).thenReturn(List.of()); + when(actionItemsRepository.findRecurringTimedActionItemsByUserId( + userId, + ItemType.TIMED_ACTION, + List.of(EventStatus.CONFIRMED, EventStatus.NEEDS_CONFIRMATION) + )).thenReturn(List.of()); + + MonthlyTimedActionItemResponse response = + actionItemService.getMonthlyTimedActionItems(userId, 2026, 8); + + assertThat(response.items()).isEmpty(); + verify(userEventsRepository, never()).findLabelIdsByUserIdAndEventIds(userId, List.of()); + } + + @Test + void monthlyTimedActionItemsRejectInvalidYearMonth() { + assertThatThrownBy(() -> actionItemService.getMonthlyTimedActionItems(1L, 2026, 13)) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ActionErrorCode.F104_ACTION_ITEM_400) + ); + } + private Events recurringTwoDayWeeklyEvent() { Events event = mock(Events.class); when(event.getIsRecurring()).thenReturn(true);