diff --git a/src/main/java/com/dodo/backend/admin/controller/AdminController.java b/src/main/java/com/dodo/backend/admin/controller/AdminController.java index 8339520..e277e83 100644 --- a/src/main/java/com/dodo/backend/admin/controller/AdminController.java +++ b/src/main/java/com/dodo/backend/admin/controller/AdminController.java @@ -17,6 +17,9 @@ import com.dodo.backend.common.exception.ErrorResponse; import com.dodo.backend.notification.dto.request.NotificationRequest.NotificationScheduleCreateRequest; import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationScheduleCreateResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationScheduleListResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationSimpleResponse; +import com.dodo.backend.notification.entity.NotificationScheduleStatus; import com.dodo.backend.notification.service.NotificationScheduleService; import com.dodo.backend.report.entity.ReportStatus; import com.dodo.backend.user.entity.UserStatus; @@ -460,6 +463,13 @@ public ResponseEntity createAnnouncement( .body(adminService.createAnnouncement(adminId, request)); } + /** + * 알림 스케줄을 등록합니다. + * + * @param request 알림 스케줄 등록 요청 + * @param userDetails 인증 관리자 정보 + * @return 알림 스케줄 등록 결과 + */ @Operation(summary = "알림 스케줄 등록", description = "관리자가 지정 시간에 발송될 알림 스케줄을 등록합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "알림 스케줄 등록 성공", @@ -491,6 +501,55 @@ public ResponseEntity createNotificationSche return ResponseEntity.ok(notificationScheduleService.createSchedule(adminId, request)); } + /** + * 알림 스케줄 목록을 조회합니다. + * + * @param page 조회할 페이지 번호 + * @param size 페이지당 알림 스케줄 수 + * @param status 알림 스케줄 상태 필터 + * @param userDetails 인증 관리자 정보 + * @return 알림 스케줄 목록 조회 결과 + */ + @Operation(summary = "알림 스케줄 목록 조회", description = "관리자가 등록된 알림 스케줄 목록을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "알림 스케줄 목록 조회 성공", + content = @Content(schema = @Schema(implementation = NotificationScheduleListResponse.class))) + }) + @GetMapping("/notification-schedules") + public ResponseEntity getNotificationSchedules( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) NotificationScheduleStatus status, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID adminId = UUID.fromString(userDetails.getUsername()); + log.info("관리자 알림 스케줄 목록 조회 요청 - AdminId: {}, Page: {}, Size: {}, Status: {}", + adminId, page, size, status); + return ResponseEntity.ok(notificationScheduleService.getSchedules(adminId, page, size, status)); + } + + /** + * 알림 스케줄을 취소합니다. + * + * @param scheduleId 취소할 알림 스케줄 ID + * @param userDetails 인증 관리자 정보 + * @return 알림 스케줄 취소 성공 메시지 + */ + @Operation(summary = "알림 스케줄 취소", description = "관리자가 등록된 알림 스케줄을 취소합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "알림 스케줄 취소 성공", + content = @Content(schema = @Schema(implementation = NotificationSimpleResponse.class))) + }) + @DeleteMapping("/notification-schedules/{scheduleId}") + public ResponseEntity cancelNotificationSchedule( + @PathVariable Long scheduleId, + @AuthenticationPrincipal UserDetails userDetails + ) { + UUID adminId = UUID.fromString(userDetails.getUsername()); + log.info("관리자 알림 스케줄 취소 요청 - AdminId: {}, ScheduleId: {}", adminId, scheduleId); + return ResponseEntity.ok(notificationScheduleService.cancelSchedule(adminId, scheduleId)); + } + /** * 공지를 삭제합니다. * diff --git a/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java b/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java index 48b378b..c3fac63 100644 --- a/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java +++ b/src/main/java/com/dodo/backend/comment/service/CommentServiceImpl.java @@ -16,6 +16,7 @@ import com.dodo.backend.comment.exception.CommentException; import com.dodo.backend.comment.mapper.CommentMapper; import com.dodo.backend.comment.repository.CommentRepository; +import com.dodo.backend.notification.service.NotificationService; import com.dodo.backend.user.entity.User; import com.dodo.backend.user.service.UserService; import lombok.RequiredArgsConstructor; @@ -45,6 +46,7 @@ public class CommentServiceImpl implements CommentService { private final CommentMapper commentMapper; private final BoardService boardService; private final UserService userService; + private final NotificationService notificationService; /** * 댓글을 작성합니다. @@ -72,6 +74,7 @@ public CommentCreateResponse createComment(UUID userId, CommentCreateRequest req Comment comment = request.toEntity(board, user, parentComment); Comment savedComment = commentRepository.save(comment); + notificationService.notifyCommentCreated(savedComment); return CommentCreateResponse.toDto(savedComment, "댓글이 성공적으로 작성되었습니다."); } diff --git a/src/main/java/com/dodo/backend/notification/controller/NotificationController.java b/src/main/java/com/dodo/backend/notification/controller/NotificationController.java index de85994..0584c77 100644 --- a/src/main/java/com/dodo/backend/notification/controller/NotificationController.java +++ b/src/main/java/com/dodo/backend/notification/controller/NotificationController.java @@ -71,7 +71,7 @@ public class NotificationController { }) @GetMapping public ResponseEntity getNotifications( - @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) Boolean isRead, @RequestParam(required = false) String type, @@ -131,12 +131,12 @@ public ResponseEntity updateReadStatus( * * @param notificationId 삭제할 알림 ID * @param userDetails 인증 사용자 정보 - * @return 알림 삭제 성공 메시지 + * @return 응답 본문이 없는 204 응답 */ @Operation(summary = "알림 삭제", description = "특정 알림을 삭제합니다.") @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "알림이 성공적으로 삭제되었습니다.", - content = @Content(schema = @Schema(implementation = NotificationSimpleResponse.class))), + @ApiResponse(responseCode = "204", description = "알림이 성공적으로 삭제되었습니다.", + content = @Content), @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), @@ -159,14 +159,14 @@ public ResponseEntity updateReadStatus( examples = @ExampleObject(name = "서버 내부 오류가 발생했습니다.", value = "{\"status\": 500, \"message\": \"서버 내부 오류가 발생했습니다.\"}"))) }) @DeleteMapping("/{notificationId}") - public ResponseEntity deleteNotification( + public ResponseEntity deleteNotification( @PathVariable Long notificationId, @AuthenticationPrincipal UserDetails userDetails ) { UUID userId = UUID.fromString(userDetails.getUsername()); log.info("알림 삭제 요청 - UserId: {}, NotificationId: {}", userId, notificationId); notificationService.deleteNotification(userId, notificationId); - return ResponseEntity.ok(NotificationSimpleResponse.toDto("알림이 성공적으로 삭제되었습니다.")); + return ResponseEntity.noContent().build(); } /** @@ -237,12 +237,12 @@ public ResponseEntity readAll( * 모든 알림을 삭제합니다. * * @param userDetails 인증 사용자 정보 - * @return 전체 알림 삭제 성공 메시지 + * @return 응답 본문이 없는 204 응답 */ @Operation(summary = "모든 알림 삭제", description = "로그인 사용자의 모든 알림을 삭제합니다.") @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "모든 알림이 성공적으로 삭제되었습니다.", - content = @Content(schema = @Schema(implementation = NotificationSimpleResponse.class))), + @ApiResponse(responseCode = "204", description = "모든 알림이 성공적으로 삭제되었습니다.", + content = @Content), @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), @@ -257,12 +257,12 @@ public ResponseEntity readAll( examples = @ExampleObject(name = "서버 내부 오류가 발생했습니다.", value = "{\"status\": 500, \"message\": \"서버 내부 오류가 발생했습니다.\"}"))) }) @DeleteMapping("/all") - public ResponseEntity deleteAll( + public ResponseEntity deleteAll( @AuthenticationPrincipal UserDetails userDetails ) { UUID userId = UUID.fromString(userDetails.getUsername()); log.info("모든 알림 삭제 요청 - UserId: {}", userId); notificationService.deleteAll(userId); - return ResponseEntity.ok(NotificationSimpleResponse.toDto("모든 알림이 성공적으로 삭제되었습니다.")); + return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/com/dodo/backend/notification/dto/response/NotificationResponse.java b/src/main/java/com/dodo/backend/notification/dto/response/NotificationResponse.java index 9fc4ba1..1ba4178 100644 --- a/src/main/java/com/dodo/backend/notification/dto/response/NotificationResponse.java +++ b/src/main/java/com/dodo/backend/notification/dto/response/NotificationResponse.java @@ -2,7 +2,9 @@ import com.dodo.backend.notification.entity.Notification; import com.dodo.backend.notification.entity.NotificationSchedule; +import com.dodo.backend.notification.entity.NotificationScheduleRepeatType; import com.dodo.backend.notification.entity.NotificationScheduleStatus; +import com.dodo.backend.notification.entity.NotificationScheduleTargetType; import com.dodo.backend.notification.entity.NotificationType; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; @@ -11,7 +13,9 @@ import org.springframework.data.domain.Page; import java.time.LocalDateTime; +import java.util.Arrays; import java.util.List; +import java.util.UUID; /** * 알림 API에서 사용하는 응답 DTO를 모아 둔 클래스입니다. @@ -80,7 +84,7 @@ public static class NotificationItemResponse { private String notificationBody; @Schema(description = "알림 유형", example = "SYSTEM") private NotificationType notificationType; - @Schema(description = "알림과 연결된 리소스 ID. 연결 대상이 없으면 null입니다.", example = "31", nullable = true) + @Schema(description = "알림과 연결된 리소스 ID. COMMENT/게시글 REACTION은 boardId, 활동기록 REACTION은 historyId입니다.", example = "31", nullable = true) private Long relatedId; @Schema(description = "알림 읽음 여부", example = "false") private Boolean isRead; @@ -132,6 +136,9 @@ public static UnreadNotificationCountResponse toDto(long unreadCount) { } } + /** + * 알림 스케줄 등록 응답 DTO입니다. + */ @Getter @Builder @AllArgsConstructor @@ -155,4 +162,77 @@ public static NotificationScheduleCreateResponse toDto(NotificationSchedule sche .build(); } } + + /** + * 알림 스케줄 목록 아이템 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "알림 스케줄 목록 아이템") + public static class NotificationScheduleItemResponse { + @Schema(description = "알림 스케줄 ID", example = "10") + private Long scheduleId; + @Schema(description = "알림 제목", example = "공지 알림") + private String notificationTitle; + @Schema(description = "알림 본문", example = "새로운 공지가 등록되었습니다.") + private String notificationBody; + @Schema(description = "알림 유형", example = "SYSTEM") + private NotificationType notificationType; + @Schema(description = "발송 대상 유형", example = "ALL") + private NotificationScheduleTargetType targetType; + @Schema(description = "targetType이 USERS일 때 발송 대상 사용자 ID 목록") + private List targetUserIds; + @Schema(description = "반복 유형", example = "NONE") + private NotificationScheduleRepeatType repeatType; + @Schema(description = "알림 스케줄 처리 상태", example = "PENDING") + private NotificationScheduleStatus scheduleStatus; + @Schema(description = "알림 발송 예정 일시", example = "2026-09-20T09:00:00") + private LocalDateTime scheduledAt; + @Schema(description = "알림 스케줄 생성 일시", example = "2026-09-14T15:30:00") + private LocalDateTime createdAt; + @Schema(description = "알림 스케줄 마지막 실행 일시", example = "2026-09-20T09:00:00", nullable = true) + private LocalDateTime executedAt; + + public static NotificationScheduleItemResponse toDto(NotificationSchedule schedule) { + return NotificationScheduleItemResponse.builder() + .scheduleId(schedule.getNotificationScheduleId()) + .notificationTitle(schedule.getNotificationTitle()) + .notificationBody(schedule.getNotificationBody()) + .notificationType(schedule.getNotificationType()) + .targetType(schedule.getTargetType()) + .targetUserIds(parseTargetUserIds(schedule.getTargetUserIds())) + .repeatType(schedule.getRepeatType()) + .scheduleStatus(schedule.getScheduleStatus()) + .scheduledAt(schedule.getScheduledAt()) + .createdAt(schedule.getCreatedAt()) + .executedAt(schedule.getExecutedAt()) + .build(); + } + + private static List parseTargetUserIds(String targetUserIds) { + if (targetUserIds == null || targetUserIds.isBlank()) { + return List.of(); + } + return Arrays.stream(targetUserIds.split(",")) + .map(String::trim) + .filter(value -> !value.isBlank()) + .map(UUID::fromString) + .toList(); + } + } + + /** + * 알림 스케줄 목록 조회 응답 DTO입니다. + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "알림 스케줄 목록 조회 응답") + public static class NotificationScheduleListResponse { + @Schema(description = "알림 스케줄 목록의 페이지 정보") + private PageInfoResponse pageInfo; + @Schema(description = "조회된 알림 스케줄 목록") + private List data; + } } diff --git a/src/main/java/com/dodo/backend/notification/entity/NotificationSchedule.java b/src/main/java/com/dodo/backend/notification/entity/NotificationSchedule.java index 627afbf..645888c 100644 --- a/src/main/java/com/dodo/backend/notification/entity/NotificationSchedule.java +++ b/src/main/java/com/dodo/backend/notification/entity/NotificationSchedule.java @@ -100,6 +100,11 @@ public void reschedule(LocalDateTime nextScheduledAt, LocalDateTime executedAt) clearProcessing(); } + public void cancel() { + this.scheduleStatus = NotificationScheduleStatus.CANCELED; + clearProcessing(); + } + private void clearProcessing() { this.processingToken = null; this.processingStartedAt = null; diff --git a/src/main/java/com/dodo/backend/notification/exception/NotificationErrorCode.java b/src/main/java/com/dodo/backend/notification/exception/NotificationErrorCode.java index 62aec3b..0446032 100644 --- a/src/main/java/com/dodo/backend/notification/exception/NotificationErrorCode.java +++ b/src/main/java/com/dodo/backend/notification/exception/NotificationErrorCode.java @@ -22,6 +22,11 @@ public enum NotificationErrorCode implements BaseErrorCode { */ NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 ID의 알림을 찾을 수 없습니다."), + /** + * 알림 스케줄을 찾을 수 없는 경우 사용합니다. + */ + NOTIFICATION_SCHEDULE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 ID의 알림 스케줄을 찾을 수 없습니다."), + /** * 알림 수정 권한이 없는 경우 사용합니다. */ diff --git a/src/main/java/com/dodo/backend/notification/repository/NotificationScheduleRepository.java b/src/main/java/com/dodo/backend/notification/repository/NotificationScheduleRepository.java index 57ade11..f836705 100644 --- a/src/main/java/com/dodo/backend/notification/repository/NotificationScheduleRepository.java +++ b/src/main/java/com/dodo/backend/notification/repository/NotificationScheduleRepository.java @@ -2,6 +2,7 @@ import com.dodo.backend.notification.entity.NotificationSchedule; import com.dodo.backend.notification.entity.NotificationScheduleStatus; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -16,6 +17,8 @@ @Repository public interface NotificationScheduleRepository extends JpaRepository { + Page findAllByScheduleStatus(NotificationScheduleStatus scheduleStatus, Pageable pageable); + @Query(""" select ns.notificationScheduleId from NotificationSchedule ns diff --git a/src/main/java/com/dodo/backend/notification/service/NotificationScheduleService.java b/src/main/java/com/dodo/backend/notification/service/NotificationScheduleService.java index a7c6823..108a1b5 100644 --- a/src/main/java/com/dodo/backend/notification/service/NotificationScheduleService.java +++ b/src/main/java/com/dodo/backend/notification/service/NotificationScheduleService.java @@ -2,10 +2,43 @@ import com.dodo.backend.notification.dto.request.NotificationRequest.NotificationScheduleCreateRequest; import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationScheduleCreateResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationScheduleListResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationSimpleResponse; +import com.dodo.backend.notification.entity.NotificationScheduleStatus; import java.util.UUID; +/** + * 알림 스케줄 비즈니스 로직을 정의하는 서비스 인터페이스입니다. + */ public interface NotificationScheduleService { + /** + * 알림 스케줄을 생성합니다. + * + * @param adminId 요청 관리자 ID + * @param request 알림 스케줄 생성 요청 + * @return 알림 스케줄 생성 결과 + */ NotificationScheduleCreateResponse createSchedule(UUID adminId, NotificationScheduleCreateRequest request); + + /** + * 알림 스케줄 목록을 조회합니다. + * + * @param adminId 요청 관리자 ID + * @param page 조회할 페이지 번호 + * @param size 페이지당 알림 스케줄 수 + * @param status 알림 스케줄 상태 필터 + * @return 알림 스케줄 목록 조회 결과 + */ + NotificationScheduleListResponse getSchedules(UUID adminId, int page, int size, NotificationScheduleStatus status); + + /** + * 알림 스케줄을 취소합니다. + * + * @param adminId 요청 관리자 ID + * @param scheduleId 취소할 알림 스케줄 ID + * @return 알림 스케줄 취소 성공 메시지 + */ + NotificationSimpleResponse cancelSchedule(UUID adminId, Long scheduleId); } diff --git a/src/main/java/com/dodo/backend/notification/service/NotificationScheduleServiceImpl.java b/src/main/java/com/dodo/backend/notification/service/NotificationScheduleServiceImpl.java index 8207fab..af4bdd2 100644 --- a/src/main/java/com/dodo/backend/notification/service/NotificationScheduleServiceImpl.java +++ b/src/main/java/com/dodo/backend/notification/service/NotificationScheduleServiceImpl.java @@ -2,6 +2,10 @@ import com.dodo.backend.notification.dto.request.NotificationRequest.NotificationScheduleCreateRequest; import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationScheduleCreateResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationScheduleItemResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationScheduleListResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationSimpleResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.PageInfoResponse; import com.dodo.backend.notification.entity.NotificationSchedule; import com.dodo.backend.notification.entity.NotificationScheduleRepeatType; import com.dodo.backend.notification.entity.NotificationScheduleStatus; @@ -13,6 +17,10 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,12 +31,19 @@ import java.util.UUID; import static com.dodo.backend.notification.exception.NotificationErrorCode.INVALID_REQUEST; +import static com.dodo.backend.notification.exception.NotificationErrorCode.NOTIFICATION_SCHEDULE_NOT_FOUND; +/** + * {@link NotificationScheduleService} 구현체입니다. + */ @Slf4j @Service @RequiredArgsConstructor public class NotificationScheduleServiceImpl implements NotificationScheduleService { + private static final int MAX_SCHEDULE_PAGE_SIZE = 100; + private static final String SCHEDULE_CANCEL_SUCCESS_MESSAGE = "알림 스케줄이 성공적으로 취소되었습니다."; + private final NotificationScheduleRepository notificationScheduleRepository; private final UserRepository userRepository; private final NotificationScheduleExecutor notificationScheduleExecutor; @@ -37,6 +52,13 @@ public class NotificationScheduleServiceImpl implements NotificationScheduleServ @Value("${notification.scheduler.processing-timeout-minutes:10}") private long processingTimeoutMinutes; + /** + * 알림 스케줄을 생성합니다. + * + * @param adminId 요청 관리자 ID + * @param request 알림 스케줄 생성 요청 + * @return 알림 스케줄 생성 결과 + */ @Transactional @Override public NotificationScheduleCreateResponse createSchedule(UUID adminId, NotificationScheduleCreateRequest request) { @@ -60,6 +82,63 @@ public NotificationScheduleCreateResponse createSchedule(UUID adminId, Notificat return NotificationScheduleCreateResponse.toDto(savedSchedule); } + /** + * 알림 스케줄 목록을 조회합니다. + * + * @param adminId 요청 관리자 ID + * @param page 조회할 페이지 번호 + * @param size 페이지당 알림 스케줄 수 + * @param status 알림 스케줄 상태 필터 + * @return 알림 스케줄 목록 조회 결과 + */ + @Transactional(readOnly = true) + @Override + public NotificationScheduleListResponse getSchedules(UUID adminId, int page, int size, NotificationScheduleStatus status) { + validateSchedulePageRequest(adminId, page, size); + + Pageable pageable = PageRequest.of( + page - 1, + size, + Sort.by(Sort.Direction.DESC, "scheduledAt") + .and(Sort.by(Sort.Direction.DESC, "notificationScheduleId")) + ); + Page schedules = status == null + ? notificationScheduleRepository.findAll(pageable) + : notificationScheduleRepository.findAllByScheduleStatus(status, pageable); + + return NotificationScheduleListResponse.builder() + .pageInfo(PageInfoResponse.toDto(schedules, page)) + .data(schedules.stream() + .map(NotificationScheduleItemResponse::toDto) + .toList()) + .build(); + } + + /** + * 알림 스케줄을 취소합니다. + * + * @param adminId 요청 관리자 ID + * @param scheduleId 취소할 알림 스케줄 ID + * @return 알림 스케줄 취소 성공 메시지 + */ + @Transactional + @Override + public NotificationSimpleResponse cancelSchedule(UUID adminId, Long scheduleId) { + validateScheduleIdRequest(adminId, scheduleId); + NotificationSchedule schedule = notificationScheduleRepository.findById(scheduleId) + .orElseThrow(() -> new NotificationException(NOTIFICATION_SCHEDULE_NOT_FOUND)); + if (schedule.getScheduleStatus() == NotificationScheduleStatus.COMPLETED + || schedule.getScheduleStatus() == NotificationScheduleStatus.CANCELED) { + throw new NotificationException(INVALID_REQUEST); + } + + schedule.cancel(); + return NotificationSimpleResponse.toDto(SCHEDULE_CANCEL_SUCCESS_MESSAGE); + } + + /** + * 발송 예정 시간이 지난 알림 스케줄을 실행합니다. + */ @Scheduled(fixedDelayString = "${notification.scheduler.fixed-delay:60000}") public void executeDueSchedules() { LocalDateTime now = LocalDateTime.now(); @@ -125,4 +204,16 @@ private String toTargetUserIds(NotificationScheduleCreateRequest request) { .orElseThrow(() -> new NotificationException(INVALID_REQUEST)); } + private void validateSchedulePageRequest(UUID adminId, int page, int size) { + if (adminId == null || page <= 0 || size <= 0 || size > MAX_SCHEDULE_PAGE_SIZE) { + throw new NotificationException(INVALID_REQUEST); + } + } + + private void validateScheduleIdRequest(UUID adminId, Long scheduleId) { + if (adminId == null || scheduleId == null || scheduleId <= 0) { + throw new NotificationException(INVALID_REQUEST); + } + } + } diff --git a/src/main/java/com/dodo/backend/notification/service/NotificationService.java b/src/main/java/com/dodo/backend/notification/service/NotificationService.java index f349059..12cab6f 100644 --- a/src/main/java/com/dodo/backend/notification/service/NotificationService.java +++ b/src/main/java/com/dodo/backend/notification/service/NotificationService.java @@ -4,6 +4,8 @@ import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationListResponse; import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationSimpleResponse; import com.dodo.backend.notification.dto.response.NotificationResponse.UnreadNotificationCountResponse; +import com.dodo.backend.comment.entity.Comment; +import com.dodo.backend.reaction.entity.Reaction; import java.util.UUID; @@ -23,4 +25,18 @@ public interface NotificationService { NotificationSimpleResponse readAll(UUID userId); void deleteAll(UUID userId); + + /** + * 댓글 작성 알림을 생성하고 푸시 발송을 예약합니다. + * + * @param comment 생성된 댓글 + */ + void notifyCommentCreated(Comment comment); + + /** + * 반응 작성 알림을 생성하고 푸시 발송을 예약합니다. + * + * @param reaction 생성된 반응 + */ + void notifyReactionCreated(Reaction reaction); } diff --git a/src/main/java/com/dodo/backend/notification/service/NotificationServiceImpl.java b/src/main/java/com/dodo/backend/notification/service/NotificationServiceImpl.java index e53b6c1..179778a 100644 --- a/src/main/java/com/dodo/backend/notification/service/NotificationServiceImpl.java +++ b/src/main/java/com/dodo/backend/notification/service/NotificationServiceImpl.java @@ -1,5 +1,6 @@ package com.dodo.backend.notification.service; +import com.dodo.backend.comment.entity.Comment; import com.dodo.backend.notification.dto.request.NotificationRequest.NotificationReadUpdateRequest; import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationItemResponse; import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationListResponse; @@ -11,6 +12,8 @@ import com.dodo.backend.notification.exception.NotificationErrorCode; import com.dodo.backend.notification.exception.NotificationException; import com.dodo.backend.notification.repository.NotificationRepository; +import com.dodo.backend.reaction.entity.Reaction; +import com.dodo.backend.user.entity.User; import jakarta.persistence.criteria.Predicate; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -19,6 +22,8 @@ import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; @@ -42,8 +47,13 @@ public class NotificationServiceImpl implements NotificationService { private static final int MAX_PAGE_SIZE = 100; private static final String READ_SUCCESS_MESSAGE = "알림이 성공적으로 읽음 처리되었습니다."; private static final String READ_ALL_SUCCESS_MESSAGE = "모든 알림이 성공적으로 읽음 처리되었습니다."; + private static final String COMMENT_NOTIFICATION_TITLE = "게시글에 새 댓글이 달렸습니다."; + private static final int COMMENT_NOTIFICATION_BODY_MAX_LENGTH = 50; + private static final String BOARD_REACTION_TARGET_NAME = "내 게시글"; + private static final String HISTORY_REACTION_TARGET_NAME = "내 활동 기록"; private final NotificationRepository notificationRepository; + private final FcmNotificationSender fcmNotificationSender; /** * 알림 목록을 조회합니다. @@ -60,7 +70,7 @@ public class NotificationServiceImpl implements NotificationService { public NotificationListResponse getNotifications(UUID userId, int page, int size, Boolean isRead, String type) { validatePageRequest(userId, page, size); Pageable pageable = PageRequest.of( - page, + page - 1, size, Sort.by(Sort.Direction.DESC, "notificationCreatedAt").and(Sort.by(Sort.Direction.DESC, "notificationId")) ); @@ -147,6 +157,68 @@ public void deleteAll(UUID userId) { notificationRepository.deleteAllByUserUsersId(userId); } + /** + * 댓글 작성 시 게시글 작성자에게 COMMENT 알림을 생성하고 커밋 이후 FCM 푸시를 발송합니다. + * + * @param comment 생성된 댓글 + */ + @Transactional + @Override + public void notifyCommentCreated(Comment comment) { + if (comment == null || comment.getBoard() == null || comment.getUser() == null || comment.getBoard().getUser() == null) { + throw new NotificationException(INVALID_REQUEST); + } + + UUID commenterId = comment.getUser().getUsersId(); + UUID boardWriterId = comment.getBoard().getUser().getUsersId(); + if (commenterId == null || boardWriterId == null || commenterId.equals(boardWriterId)) { + return; + } + if (!Boolean.TRUE.equals(comment.getBoard().getUser().getNotificationEnabled())) { + return; + } + + createNotificationAndSendAfterCommit( + comment.getBoard().getUser(), + COMMENT_NOTIFICATION_TITLE, + buildCommentNotificationBody(comment.getCommentContent()), + NotificationType.COMMENT, + comment.getBoard().getBoardId() + ); + } + + /** + * 반응 작성 시 대상 작성자에게 REACTION 알림을 생성하고 커밋 이후 FCM 푸시를 발송합니다. + * + * @param reaction 생성된 반응 + */ + @Transactional + @Override + public void notifyReactionCreated(Reaction reaction) { + if (reaction == null || reaction.getUser() == null || reaction.getReactionType() == null) { + throw new NotificationException(INVALID_REQUEST); + } + + ReactionNotificationTarget target = resolveReactionNotificationTarget(reaction); + UUID reactorId = reaction.getUser().getUsersId(); + UUID recipientId = target.recipient().getUsersId(); + if (reactorId == null || recipientId == null || reactorId.equals(recipientId)) { + return; + } + if (!Boolean.TRUE.equals(target.recipient().getNotificationEnabled())) { + return; + } + + String reactionName = resolveReactionName(reaction); + createNotificationAndSendAfterCommit( + target.recipient(), + target.targetName() + "에 " + reactionName + "가 눌렸습니다.", + "누군가 " + reactionName + "를 눌렀습니다.", + NotificationType.REACTION, + target.relatedId() + ); + } + private Specification buildNotificationSpecification(UUID userId, Boolean isRead, List types) { return (root, query, criteriaBuilder) -> { List predicates = new ArrayList<>(); @@ -163,6 +235,80 @@ private Specification buildNotificationSpecification(UUID userId, }; } + private String buildCommentNotificationBody(String commentContent) { + String content = commentContent == null ? "" : commentContent.strip(); + if (content.length() > COMMENT_NOTIFICATION_BODY_MAX_LENGTH) { + content = content.substring(0, COMMENT_NOTIFICATION_BODY_MAX_LENGTH) + "..."; + } + return "새 댓글: '" + content + "'"; + } + + private ReactionNotificationTarget resolveReactionNotificationTarget(Reaction reaction) { + if (reaction.getBoard() != null) { + if (reaction.getBoard().getUser() == null || reaction.getBoard().getBoardId() == null) { + throw new NotificationException(INVALID_REQUEST); + } + return new ReactionNotificationTarget( + reaction.getBoard().getUser(), + reaction.getBoard().getBoardId(), + BOARD_REACTION_TARGET_NAME + ); + } + + if (reaction.getHistory() != null) { + if (reaction.getHistory().getUser() == null || reaction.getHistory().getHistoryId() == null) { + throw new NotificationException(INVALID_REQUEST); + } + return new ReactionNotificationTarget( + reaction.getHistory().getUser(), + reaction.getHistory().getHistoryId(), + HISTORY_REACTION_TARGET_NAME + ); + } + + throw new NotificationException(INVALID_REQUEST); + } + + private String resolveReactionName(Reaction reaction) { + return switch (reaction.getReactionType()) { + case LIKE -> "좋아요"; + case DISLIKE -> "싫어요"; + }; + } + + private void createNotificationAndSendAfterCommit(User recipient, String title, String body, NotificationType type, Long relatedId) { + Notification notification = Notification.builder() + .user(recipient) + .notificationTitle(title) + .notificationBody(body) + .notificationType(type) + .relatedId(relatedId) + .isRead(false) + .build(); + notificationRepository.save(notification); + + sendPushAfterCommit(List.of(recipient), title, body, type, relatedId); + } + + private void sendPushAfterCommit(List recipients, String title, String body, NotificationType type, Long relatedId) { + Runnable pushTask = () -> fcmNotificationSender.sendToUsers(recipients, title, body, type, relatedId); + + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + pushTask.run(); + return; + } + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + pushTask.run(); + } + }); + } + + private record ReactionNotificationTarget(User recipient, Long relatedId, String targetName) { + } + private Notification findOwnedNotification(UUID userId, Long notificationId, NotificationErrorCode forbiddenErrorCode) { validateUserId(userId); validateId(notificationId); @@ -196,7 +342,7 @@ private List parseTypes(String type) { } private void validatePageRequest(UUID userId, int page, int size) { - if (userId == null || page < 0 || size <= 0 || size > MAX_PAGE_SIZE) { + if (userId == null || page <= 0 || size <= 0 || size > MAX_PAGE_SIZE) { throw new NotificationException(INVALID_REQUEST); } } diff --git a/src/main/java/com/dodo/backend/reaction/service/ReactionServiceImpl.java b/src/main/java/com/dodo/backend/reaction/service/ReactionServiceImpl.java index 84dd3a1..4282c01 100644 --- a/src/main/java/com/dodo/backend/reaction/service/ReactionServiceImpl.java +++ b/src/main/java/com/dodo/backend/reaction/service/ReactionServiceImpl.java @@ -4,6 +4,7 @@ import com.dodo.backend.activityhistory.service.ActivityHistoryService; import com.dodo.backend.board.entity.Board; import com.dodo.backend.board.service.BoardService; +import com.dodo.backend.notification.service.NotificationService; import com.dodo.backend.reaction.dto.request.ReactionRequest.BoardReactionCreateRequest; import com.dodo.backend.reaction.dto.request.ReactionRequest.BoardReactionUpdateRequest; import com.dodo.backend.reaction.dto.request.ReactionRequest.HistoryReactionCreateRequest; @@ -43,6 +44,7 @@ public class ReactionServiceImpl implements ReactionService { private final ActivityHistoryService activityHistoryService; private final BoardService boardService; private final UserService userService; + private final NotificationService notificationService; /** * {@inheritDoc} @@ -68,7 +70,8 @@ public ReactionSimpleResponse createHistoryReaction(UUID userId, HistoryReaction } Reaction reaction = request.toEntity(user, history); - reactionRepository.save(reaction); + Reaction savedReaction = reactionRepository.save(reaction); + notificationService.notifyReactionCreated(savedReaction); log.info("활동 반응 추가 완료 - User: {}, HistoryId: {}, ReactionType: {}", userId, request.getHistoryId(), reaction.getReactionType()); @@ -158,7 +161,8 @@ public ReactionSimpleResponse createBoardReaction(UUID userId, BoardReactionCrea } Reaction reaction = request.toEntity(user, board); - reactionRepository.save(reaction); + Reaction savedReaction = reactionRepository.save(reaction); + notificationService.notifyReactionCreated(savedReaction); log.info("게시물 반응 추가 완료 - User: {}, BoardId: {}, ReactionType: {}", userId, request.getBoardId(), reaction.getReactionType()); diff --git a/src/test/java/com/dodo/backend/admin/controller/AdminControllerTest.java b/src/test/java/com/dodo/backend/admin/controller/AdminControllerTest.java index 3f9a6dc..142441a 100644 --- a/src/test/java/com/dodo/backend/admin/controller/AdminControllerTest.java +++ b/src/test/java/com/dodo/backend/admin/controller/AdminControllerTest.java @@ -11,7 +11,13 @@ import com.dodo.backend.admin.service.AdminService; import com.dodo.backend.notification.dto.request.NotificationRequest.NotificationScheduleCreateRequest; import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationScheduleCreateResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationScheduleItemResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationScheduleListResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationSimpleResponse; import com.dodo.backend.notification.entity.NotificationScheduleStatus; +import com.dodo.backend.notification.entity.NotificationScheduleTargetType; +import com.dodo.backend.notification.entity.NotificationScheduleRepeatType; +import com.dodo.backend.notification.entity.NotificationType; import com.dodo.backend.notification.service.NotificationScheduleService; import com.dodo.backend.report.entity.ReportStatus; import com.dodo.backend.user.entity.UserStatus; @@ -200,6 +206,56 @@ void createNotificationSchedule_Success() throws Exception { verify(notificationScheduleService).createSchedule(eq(adminId), any(NotificationScheduleCreateRequest.class)); } + @Test + @DisplayName("알림 스케줄 목록 조회 API 성공") + void getNotificationSchedules_Success() throws Exception { + LocalDateTime scheduledAt = LocalDateTime.of(2099, 1, 1, 14, 30); + NotificationScheduleListResponse response = NotificationScheduleListResponse.builder() + .pageInfo(com.dodo.backend.notification.dto.response.NotificationResponse.PageInfoResponse.builder() + .page(1) + .size(20) + .totalElements(1) + .totalPages(1) + .build()) + .data(List.of(NotificationScheduleItemResponse.builder() + .scheduleId(1L) + .notificationTitle("공지 알림") + .notificationBody("새로운 공지가 등록되었습니다.") + .notificationType(NotificationType.SYSTEM) + .targetType(NotificationScheduleTargetType.ALL) + .targetUserIds(List.of()) + .repeatType(NotificationScheduleRepeatType.NONE) + .scheduleStatus(NotificationScheduleStatus.PENDING) + .scheduledAt(scheduledAt) + .build())) + .build(); + + given(notificationScheduleService.getSchedules(adminId, 1, 20, NotificationScheduleStatus.PENDING)) + .willReturn(response); + + mockMvc.perform(get("/admin/notification-schedules") + .param("status", "PENDING")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.pageInfo.page").value(1)) + .andExpect(jsonPath("$.data[0].scheduleId").value(1)) + .andExpect(jsonPath("$.data[0].scheduleStatus").value("PENDING")); + + verify(notificationScheduleService).getSchedules(adminId, 1, 20, NotificationScheduleStatus.PENDING); + } + + @Test + @DisplayName("알림 스케줄 취소 API 성공") + void cancelNotificationSchedule_Success() throws Exception { + given(notificationScheduleService.cancelSchedule(adminId, 1L)) + .willReturn(NotificationSimpleResponse.toDto("알림 스케줄이 성공적으로 취소되었습니다.")); + + mockMvc.perform(delete("/admin/notification-schedules/{scheduleId}", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("알림 스케줄이 성공적으로 취소되었습니다.")); + + verify(notificationScheduleService).cancelSchedule(adminId, 1L); + } + /** * 공지 목록 조회 API가 명세의 메시지를 반환하는지 검증합니다. */ diff --git a/src/test/java/com/dodo/backend/comment/service/CommentServiceTest.java b/src/test/java/com/dodo/backend/comment/service/CommentServiceTest.java index 12a7474..4d28c1e 100644 --- a/src/test/java/com/dodo/backend/comment/service/CommentServiceTest.java +++ b/src/test/java/com/dodo/backend/comment/service/CommentServiceTest.java @@ -15,6 +15,7 @@ import com.dodo.backend.comment.exception.CommentException; import com.dodo.backend.comment.mapper.CommentMapper; import com.dodo.backend.comment.repository.CommentRepository; +import com.dodo.backend.notification.service.NotificationService; import com.dodo.backend.user.entity.User; import com.dodo.backend.user.service.UserService; import lombok.extern.slf4j.Slf4j; @@ -63,6 +64,9 @@ class CommentServiceTest { @Mock private UserService userService; + @Mock + private NotificationService notificationService; + /** * 댓글 작성 요청 시 게시글과 사용자를 조회하고 댓글을 저장하는지 검증합니다. */ @@ -118,6 +122,7 @@ void createComment_Success() { assertEquals(user, captor.getValue().getUser()); assertNull(captor.getValue().getParentComment()); assertEquals("좋은 정보 감사합니다!", captor.getValue().getCommentContent()); + verify(notificationService).notifyCommentCreated(savedComment); log.info("테스트 종료: 댓글 작성 성공"); } @@ -180,6 +185,7 @@ void createComment_Success_WithParentComment() { ArgumentCaptor captor = ArgumentCaptor.forClass(Comment.class); verify(commentRepository).save(captor.capture()); assertEquals(parentComment, captor.getValue().getParentComment()); + verify(notificationService).notifyCommentCreated(savedComment); log.info("테스트 종료: 대댓글 작성 성공"); } diff --git a/src/test/java/com/dodo/backend/notification/controller/NotificationControllerTest.java b/src/test/java/com/dodo/backend/notification/controller/NotificationControllerTest.java index 055022c..5906d56 100644 --- a/src/test/java/com/dodo/backend/notification/controller/NotificationControllerTest.java +++ b/src/test/java/com/dodo/backend/notification/controller/NotificationControllerTest.java @@ -65,14 +65,35 @@ void setUp() { @DisplayName("알림 목록 조회 API 성공") void getNotifications_Success() throws Exception { NotificationListResponse response = NotificationListResponse.builder() - .pageInfo(PageInfoResponse.builder().page(0).size(20).totalElements(0).totalPages(0).build()) + .pageInfo(PageInfoResponse.builder().page(1).size(20).totalElements(0).totalPages(0).build()) .data(List.of()) .build(); - given(notificationService.getNotifications(eq(userId), eq(0), eq(20), eq(null), eq(null))).willReturn(response); + given(notificationService.getNotifications(eq(userId), eq(1), eq(20), eq(null), eq(null))).willReturn(response); mockMvc.perform(get("/notifications")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.pageInfo.page").value(0)); + .andExpect(jsonPath("$.pageInfo.page").value(1)); + } + + @Test + @DisplayName("notification list API passes filters") + void getNotifications_WithFilters() throws Exception { + NotificationListResponse response = NotificationListResponse.builder() + .pageInfo(PageInfoResponse.builder().page(2).size(10).totalElements(0).totalPages(0).build()) + .data(List.of()) + .build(); + given(notificationService.getNotifications(eq(userId), eq(2), eq(10), eq(false), eq("COMMENT,REACTION"))) + .willReturn(response); + + mockMvc.perform(get("/notifications") + .param("page", "2") + .param("size", "10") + .param("isRead", "false") + .param("type", "COMMENT,REACTION")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.pageInfo.page").value(2)); + + verify(notificationService).getNotifications(userId, 2, 10, false, "COMMENT,REACTION"); } /** @@ -104,20 +125,44 @@ void getUnreadCount_Success() throws Exception { .andExpect(jsonPath("$.unreadCount").value(3)); } + @Test + @DisplayName("read all notifications API success") + void readAll_Success() throws Exception { + given(notificationService.readAll(userId)) + .willReturn(NotificationSimpleResponse.toDto("all read")); + + mockMvc.perform(patch("/notifications/read-all")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("all read")); + + verify(notificationService).readAll(userId); + } + /** - * 알림 삭제 API가 200과 성공 메시지를 반환하는지 검증합니다. + * 알림 삭제 API가 204를 반환하는지 검증합니다. */ @Test @DisplayName("알림 삭제 API 성공") void deleteNotification_Success() throws Exception { mockMvc.perform(delete("/notifications/{notificationId}", 1L) ) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("알림이 성공적으로 삭제되었습니다.")); + .andExpect(status().isNoContent()); verify(notificationService).deleteNotification(userId, 1L); } + /** + * 모든 알림 삭제 API가 204를 반환하는지 검증합니다. + */ + @Test + @DisplayName("모든 알림 삭제 API 성공") + void deleteAll_Success() throws Exception { + mockMvc.perform(delete("/notifications/all")) + .andExpect(status().isNoContent()); + + verify(notificationService).deleteAll(userId); + } + private HandlerMethodArgumentResolver authenticationPrincipalResolver() { return new HandlerMethodArgumentResolver() { @Override diff --git a/src/test/java/com/dodo/backend/notification/integration/CommentNotificationIntegrationTest.java b/src/test/java/com/dodo/backend/notification/integration/CommentNotificationIntegrationTest.java new file mode 100644 index 0000000..e5784cc --- /dev/null +++ b/src/test/java/com/dodo/backend/notification/integration/CommentNotificationIntegrationTest.java @@ -0,0 +1,157 @@ +package com.dodo.backend.notification.integration; + +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.board.entity.BoardStatus; +import com.dodo.backend.board.entity.BoardType; +import com.dodo.backend.board.repository.BoardRepository; +import com.dodo.backend.board.service.BoardService; +import com.dodo.backend.comment.dto.request.CommentRequest.CommentCreateRequest; +import com.dodo.backend.comment.mapper.CommentMapper; +import com.dodo.backend.comment.repository.CommentRepository; +import com.dodo.backend.comment.service.CommentServiceImpl; +import com.dodo.backend.common.config.JpaAuditingConfig; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationListResponse; +import com.dodo.backend.notification.entity.Notification; +import com.dodo.backend.notification.entity.NotificationType; +import com.dodo.backend.notification.repository.NotificationRepository; +import com.dodo.backend.notification.service.FcmNotificationSender; +import com.dodo.backend.notification.service.NotificationServiceImpl; +import com.dodo.backend.user.entity.User; +import com.dodo.backend.user.entity.UserRole; +import com.dodo.backend.user.entity.UserStatus; +import com.dodo.backend.user.repository.UserRepository; +import com.dodo.backend.user.service.UserService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@DataJpaTest +@Import({CommentServiceImpl.class, NotificationServiceImpl.class, JpaAuditingConfig.class}) +class CommentNotificationIntegrationTest { + + @Autowired + private CommentServiceImpl commentService; + + @Autowired + private NotificationServiceImpl notificationService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private BoardRepository boardRepository; + + @Autowired + private NotificationRepository notificationRepository; + + @Autowired + private CommentRepository commentRepository; + + @MockitoBean + private BoardService boardService; + + @MockitoBean + private UserService userService; + + @MockitoBean + private CommentMapper commentMapper; + + @MockitoBean + private FcmNotificationSender fcmNotificationSender; + + @Test + @DisplayName("comment creation persists COMMENT notification for board writer") + void createComment_SavesCommentNotificationForBoardWriter() { + User boardWriter = userRepository.save(createUser("writer@test.com", "writer")); + User commenter = userRepository.save(createUser("commenter@test.com", "commenter")); + Board board = boardRepository.save(Board.builder() + .user(boardWriter) + .boardTitle("board title") + .boardContent("board content") + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build()); + CommentCreateRequest request = CommentCreateRequest.builder() + .boardId(board.getBoardId()) + .commentContent("hello") + .build(); + + when(boardService.getBoardById(board.getBoardId())).thenReturn(board); + when(userService.getUserById(commenter.getUsersId())).thenReturn(commenter); + + commentService.createComment(commenter.getUsersId(), request); + commentRepository.flush(); + notificationRepository.flush(); + + List notifications = notificationRepository.findAll(); + + assertThat(notifications).hasSize(1); + Notification notification = notifications.get(0); + assertThat(notification.getUser().getUsersId()).isEqualTo(boardWriter.getUsersId()); + assertThat(notification.getNotificationType()).isEqualTo(NotificationType.COMMENT); + assertThat(notification.getRelatedId()).isEqualTo(board.getBoardId()); + assertThat(notification.getIsRead()).isFalse(); + assertThat(notification.getNotificationCreatedAt()).isNotNull(); + } + + @Test + @DisplayName("created COMMENT notification appears in board writer notification list") + void getNotifications_ReturnsCreatedCommentNotificationForBoardWriter() { + User boardWriter = userRepository.save(createUser("list-writer@test.com", "writer")); + User commenter = userRepository.save(createUser("list-commenter@test.com", "commenter")); + Board board = boardRepository.save(Board.builder() + .user(boardWriter) + .boardTitle("board title") + .boardContent("board content") + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build()); + CommentCreateRequest request = CommentCreateRequest.builder() + .boardId(board.getBoardId()) + .commentContent("hello") + .build(); + + when(boardService.getBoardById(board.getBoardId())).thenReturn(board); + when(userService.getUserById(commenter.getUsersId())).thenReturn(commenter); + + commentService.createComment(commenter.getUsersId(), request); + commentRepository.flush(); + notificationRepository.flush(); + + NotificationListResponse response = notificationService.getNotifications( + boardWriter.getUsersId(), + 1, + 20, + false, + "COMMENT" + ); + + assertThat(response.getPageInfo().getPage()).isEqualTo(1); + assertThat(response.getPageInfo().getTotalElements()).isEqualTo(1); + assertThat(response.getData()).hasSize(1); + assertThat(response.getData().get(0).getNotificationType()).isEqualTo(NotificationType.COMMENT); + assertThat(response.getData().get(0).getRelatedId()).isEqualTo(board.getBoardId()); + } + + private User createUser(String email, String nickname) { + return User.builder() + .role(UserRole.USER) + .name("tester") + .email(email) + .hasFamily(false) + .nickname(nickname) + .region("Seoul") + .profileUrl("https://example.com/profile.png") + .userStatus(UserStatus.ACTIVE) + .notificationEnabled(true) + .build(); + } +} diff --git a/src/test/java/com/dodo/backend/notification/service/NotificationScheduleServiceTest.java b/src/test/java/com/dodo/backend/notification/service/NotificationScheduleServiceTest.java new file mode 100644 index 0000000..8472e4b --- /dev/null +++ b/src/test/java/com/dodo/backend/notification/service/NotificationScheduleServiceTest.java @@ -0,0 +1,122 @@ +package com.dodo.backend.notification.service; + +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationScheduleListResponse; +import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationSimpleResponse; +import com.dodo.backend.notification.entity.NotificationSchedule; +import com.dodo.backend.notification.entity.NotificationScheduleRepeatType; +import com.dodo.backend.notification.entity.NotificationScheduleStatus; +import com.dodo.backend.notification.entity.NotificationScheduleTargetType; +import com.dodo.backend.notification.entity.NotificationType; +import com.dodo.backend.notification.exception.NotificationErrorCode; +import com.dodo.backend.notification.exception.NotificationException; +import com.dodo.backend.notification.repository.NotificationScheduleRepository; +import com.dodo.backend.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class NotificationScheduleServiceTest { + + @Mock + private NotificationScheduleRepository notificationScheduleRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private NotificationScheduleExecutor notificationScheduleExecutor; + + @Mock + private FcmNotificationSender fcmNotificationSender; + + @InjectMocks + private NotificationScheduleServiceImpl notificationScheduleService; + + @Test + @DisplayName("알림 스케줄 목록 조회 성공") + void getSchedules_Success() { + UUID adminId = UUID.randomUUID(); + NotificationSchedule schedule = createSchedule(NotificationScheduleStatus.PENDING); + given(notificationScheduleRepository.findAllByScheduleStatus( + eq(NotificationScheduleStatus.PENDING), + any(Pageable.class) + )).willReturn(new PageImpl<>(List.of(schedule))); + + NotificationScheduleListResponse response = notificationScheduleService.getSchedules( + adminId, + 1, + 20, + NotificationScheduleStatus.PENDING + ); + + assertEquals(1, response.getPageInfo().getPage()); + assertEquals(1, response.getData().size()); + assertEquals(1L, response.getData().get(0).getScheduleId()); + assertEquals(NotificationScheduleStatus.PENDING, response.getData().get(0).getScheduleStatus()); + verify(notificationScheduleRepository).findAllByScheduleStatus(eq(NotificationScheduleStatus.PENDING), any(Pageable.class)); + } + + @Test + @DisplayName("알림 스케줄 취소 성공") + void cancelSchedule_Success() { + UUID adminId = UUID.randomUUID(); + NotificationSchedule schedule = createSchedule(NotificationScheduleStatus.PROCESSING); + given(notificationScheduleRepository.findById(1L)).willReturn(Optional.of(schedule)); + + NotificationSimpleResponse response = notificationScheduleService.cancelSchedule(adminId, 1L); + + assertEquals("알림 스케줄이 성공적으로 취소되었습니다.", response.getMessage()); + assertEquals(NotificationScheduleStatus.CANCELED, schedule.getScheduleStatus()); + assertNull(schedule.getProcessingToken()); + assertNull(schedule.getProcessingStartedAt()); + } + + @Test + @DisplayName("완료된 알림 스케줄 취소 실패") + void cancelSchedule_Fail_CompletedSchedule() { + UUID adminId = UUID.randomUUID(); + NotificationSchedule schedule = createSchedule(NotificationScheduleStatus.COMPLETED); + given(notificationScheduleRepository.findById(1L)).willReturn(Optional.of(schedule)); + + NotificationException exception = assertThrows( + NotificationException.class, + () -> notificationScheduleService.cancelSchedule(adminId, 1L) + ); + + assertEquals(NotificationErrorCode.INVALID_REQUEST, exception.getErrorCode()); + } + + private NotificationSchedule createSchedule(NotificationScheduleStatus status) { + return NotificationSchedule.builder() + .notificationScheduleId(1L) + .notificationTitle("공지 알림") + .notificationBody("새로운 공지가 등록되었습니다.") + .notificationType(NotificationType.SYSTEM) + .targetType(NotificationScheduleTargetType.ALL) + .repeatType(NotificationScheduleRepeatType.NONE) + .scheduleStatus(status) + .scheduledAt(LocalDateTime.of(2099, 1, 1, 14, 30)) + .processingToken("claim-token") + .processingStartedAt(LocalDateTime.of(2099, 1, 1, 14, 29)) + .build(); + } +} diff --git a/src/test/java/com/dodo/backend/notification/service/NotificationServiceTest.java b/src/test/java/com/dodo/backend/notification/service/NotificationServiceTest.java index 4bb16b2..b3bea6c 100644 --- a/src/test/java/com/dodo/backend/notification/service/NotificationServiceTest.java +++ b/src/test/java/com/dodo/backend/notification/service/NotificationServiceTest.java @@ -1,15 +1,21 @@ package com.dodo.backend.notification.service; +import com.dodo.backend.activityhistory.entity.ActivityHistory; +import com.dodo.backend.board.entity.Board; +import com.dodo.backend.comment.entity.Comment; import com.dodo.backend.notification.dto.request.NotificationRequest.NotificationReadUpdateRequest; import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationListResponse; import com.dodo.backend.notification.dto.response.NotificationResponse.UnreadNotificationCountResponse; import com.dodo.backend.notification.entity.Notification; import com.dodo.backend.notification.entity.NotificationType; import com.dodo.backend.notification.repository.NotificationRepository; +import com.dodo.backend.reaction.entity.Reaction; +import com.dodo.backend.reaction.entity.ReactionType; import com.dodo.backend.user.entity.User; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -37,6 +43,9 @@ class NotificationServiceTest { @Mock private NotificationRepository notificationRepository; + @Mock + private FcmNotificationSender fcmNotificationSender; + @InjectMocks private NotificationServiceImpl notificationService; @@ -53,9 +62,9 @@ void getNotifications_Success() { given(notificationRepository.findAll(any(Specification.class), any(Pageable.class))) .willReturn(new PageImpl<>(List.of(notification))); - NotificationListResponse response = notificationService.getNotifications(userId, 0, 20, null, null); + NotificationListResponse response = notificationService.getNotifications(userId, 1, 20, null, null); - assertEquals(0, response.getPageInfo().getPage()); + assertEquals(1, response.getPageInfo().getPage()); assertEquals(1, response.getData().size()); assertEquals(NotificationType.COMMENT, response.getData().get(0).getNotificationType()); } @@ -73,12 +82,156 @@ void getNotifications_CaseInsensitiveTypeFilter() { given(notificationRepository.findAll(any(Specification.class), any(Pageable.class))) .willReturn(new PageImpl<>(List.of(notification))); - NotificationListResponse response = notificationService.getNotifications(userId, 0, 20, null, "comment,Board"); + NotificationListResponse response = notificationService.getNotifications(userId, 1, 20, null, "comment,Board"); assertEquals(1, response.getData().size()); assertEquals(NotificationType.COMMENT, response.getData().get(0).getNotificationType()); } + /** + * 댓글 작성 시 게시글 작성자에게 COMMENT 알림이 생성되는지 검증합니다. + */ + @Test + @DisplayName("댓글 작성 알림 생성 성공") + void notifyCommentCreated_Success() { + UUID commenterId = UUID.randomUUID(); + UUID boardWriterId = UUID.randomUUID(); + User commenter = User.builder() + .usersId(commenterId) + .nickname("댓글작성자") + .build(); + User boardWriter = User.builder() + .usersId(boardWriterId) + .nickname("게시글작성자") + .notificationEnabled(true) + .build(); + Board board = Board.builder() + .boardId(123L) + .user(boardWriter) + .build(); + Comment comment = Comment.builder() + .commentId(10L) + .board(board) + .user(commenter) + .commentContent("안녕하세요") + .build(); + + notificationService.notifyCommentCreated(comment); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Notification.class); + verify(notificationRepository).save(captor.capture()); + Notification notification = captor.getValue(); + assertEquals(boardWriter, notification.getUser()); + assertEquals("게시글에 새 댓글이 달렸습니다.", notification.getNotificationTitle()); + assertEquals("새 댓글: '안녕하세요'", notification.getNotificationBody()); + assertEquals(NotificationType.COMMENT, notification.getNotificationType()); + assertEquals(123L, notification.getRelatedId()); + assertEquals(false, notification.getIsRead()); + verify(fcmNotificationSender).sendToUsers( + List.of(boardWriter), + "게시글에 새 댓글이 달렸습니다.", + "새 댓글: '안녕하세요'", + NotificationType.COMMENT, + 123L + ); + } + + /** + * 게시글 반응 작성 시 게시글 작성자에게 REACTION 알림이 생성되는지 검증합니다. + */ + @Test + @DisplayName("게시글 반응 알림 생성 성공") + void notifyBoardReactionCreated_Success() { + UUID reactorId = UUID.randomUUID(); + UUID boardWriterId = UUID.randomUUID(); + User reactor = User.builder() + .usersId(reactorId) + .nickname("반응작성자") + .build(); + User boardWriter = User.builder() + .usersId(boardWriterId) + .nickname("게시글작성자") + .notificationEnabled(true) + .build(); + Board board = Board.builder() + .boardId(123L) + .user(boardWriter) + .build(); + Reaction reaction = Reaction.builder() + .reactionId(1L) + .board(board) + .user(reactor) + .reactionType(ReactionType.LIKE) + .build(); + + notificationService.notifyReactionCreated(reaction); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Notification.class); + verify(notificationRepository).save(captor.capture()); + Notification notification = captor.getValue(); + assertEquals(boardWriter, notification.getUser()); + assertEquals("내 게시글에 좋아요가 눌렸습니다.", notification.getNotificationTitle()); + assertEquals("누군가 좋아요를 눌렀습니다.", notification.getNotificationBody()); + assertEquals(NotificationType.REACTION, notification.getNotificationType()); + assertEquals(123L, notification.getRelatedId()); + assertEquals(false, notification.getIsRead()); + verify(fcmNotificationSender).sendToUsers( + List.of(boardWriter), + "내 게시글에 좋아요가 눌렸습니다.", + "누군가 좋아요를 눌렀습니다.", + NotificationType.REACTION, + 123L + ); + } + + /** + * 활동 기록 반응 작성 시 활동 기록 작성자에게 REACTION 알림이 생성되는지 검증합니다. + */ + @Test + @DisplayName("활동 기록 반응 알림 생성 성공") + void notifyHistoryReactionCreated_Success() { + UUID reactorId = UUID.randomUUID(); + UUID historyWriterId = UUID.randomUUID(); + User reactor = User.builder() + .usersId(reactorId) + .nickname("반응작성자") + .build(); + User historyWriter = User.builder() + .usersId(historyWriterId) + .nickname("활동작성자") + .notificationEnabled(true) + .build(); + ActivityHistory history = ActivityHistory.builder() + .historyId(205L) + .user(historyWriter) + .build(); + Reaction reaction = Reaction.builder() + .reactionId(2L) + .history(history) + .user(reactor) + .reactionType(ReactionType.LIKE) + .build(); + + notificationService.notifyReactionCreated(reaction); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Notification.class); + verify(notificationRepository).save(captor.capture()); + Notification notification = captor.getValue(); + assertEquals(historyWriter, notification.getUser()); + assertEquals("내 활동 기록에 좋아요가 눌렸습니다.", notification.getNotificationTitle()); + assertEquals("누군가 좋아요를 눌렀습니다.", notification.getNotificationBody()); + assertEquals(NotificationType.REACTION, notification.getNotificationType()); + assertEquals(205L, notification.getRelatedId()); + assertEquals(false, notification.getIsRead()); + verify(fcmNotificationSender).sendToUsers( + List.of(historyWriter), + "내 활동 기록에 좋아요가 눌렸습니다.", + "누군가 좋아요를 눌렀습니다.", + NotificationType.REACTION, + 205L + ); + } + /** * 알림 읽음 처리 시 엔티티의 읽음 상태가 변경되는지 검증합니다. */ diff --git a/src/test/java/com/dodo/backend/reaction/service/ReactionServiceTest.java b/src/test/java/com/dodo/backend/reaction/service/ReactionServiceTest.java index 53d9c61..c78d274 100644 --- a/src/test/java/com/dodo/backend/reaction/service/ReactionServiceTest.java +++ b/src/test/java/com/dodo/backend/reaction/service/ReactionServiceTest.java @@ -6,6 +6,7 @@ import com.dodo.backend.activityhistory.service.ActivityHistoryService; import com.dodo.backend.board.entity.Board; import com.dodo.backend.board.service.BoardService; +import com.dodo.backend.notification.service.NotificationService; import com.dodo.backend.pet.entity.Pet; import com.dodo.backend.reaction.dto.request.ReactionRequest.BoardReactionCreateRequest; import com.dodo.backend.reaction.dto.request.ReactionRequest.BoardReactionUpdateRequest; @@ -59,6 +60,9 @@ class ReactionServiceTest { @Mock private UserService userService; + @Mock + private NotificationService notificationService; + /** * 활동 반응 추가가 정상적으로 저장되고 성공 메시지를 반환하는지 검증합니다. */ @@ -96,6 +100,7 @@ void createHistoryReaction_Success() { assertNotNull(response); assertEquals("반응이 성공적으로 추가되었습니다.", response.getMessage()); verify(reactionRepository, times(1)).save(any(Reaction.class)); + verify(notificationService).notifyReactionCreated(any(Reaction.class)); } /** @@ -444,6 +449,7 @@ void createBoardReaction_Success() { assertNotNull(response); assertEquals("반응이 성공적으로 추가되었습니다.", response.getMessage()); verify(reactionRepository, times(1)).save(any(Reaction.class)); + verify(notificationService).notifyReactionCreated(any(Reaction.class)); } /**