diff --git a/docs/api-specs/notification-api.md b/docs/api-specs/notification-api.md index 59c7a4f..d9a0b1d 100644 --- a/docs/api-specs/notification-api.md +++ b/docs/api-specs/notification-api.md @@ -349,6 +349,7 @@ | `GET` | `/api/v1/admin/notices/{noticeId}` | 공지 상세 조회 | | `PUT` | `/api/v1/admin/notices/{noticeId}` | 공지 수정 (제목/본문) | | `DELETE` | `/api/v1/admin/notices/{noticeId}` | 공지 삭제 | +| `GET` | `/api/v1/admin/notices/{noticeId}/delivery-result` | 발송 결과 조회 (대상/성공/실패 건수) | | `POST` | `/api/v1/admin/notices/test` | 특정 유저 대상 테스트 푸시 발송 (알림 설정 ON/OFF 무관) | **`POST /api/v1/admin/notices` 요청 바디** @@ -389,6 +390,41 @@ 성공 시 `200 OK`, `data: null`. 삭제된 공지는 알림함에서도 사라집니다. +**`GET /api/v1/admin/notices/{noticeId}/delivery-result`** + +`CONTENT` 카테고리를 제외한 `NOTICE`/`EVENT` 공지처럼 실제로 푸시가 발송되는 알림에 한해, 발송 대상 디바이스 수와 성공/실패 건수를 조회합니다. 푸시 발송은 Android(FCM)는 동기, iOS(APNs)는 비동기로 처리되므로, 전체 발송이 끝나기 전에는 `pending: true`로 내려갑니다. + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "notificationId": 101, + "targetCount": 1200, + "successCount": 1180, + "failureCount": 20, + "pending": false + }, + "error": null +} +``` + +`failureCount`는 만료된 디바이스 토큰(`UNREGISTERED`, APNs의 `BadDeviceToken`/`Unregistered` 등) 정리 대상 건수를 포함한 전체 실패 건수이며, 실패 사유별 세부 분류는 제공하지 않습니다. + +예외 응답 `404 - 발송 결과 없음` (공지 자체가 없거나, `CONTENT` 카테고리처럼 관리자 발송 트리거를 거치지 않아 결과가 집계되지 않은 경우): + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "NOTIFICATION_404_DELIVERY_RESULT", + "message": "발송 결과가 집계되지 않은 알림입니다." + } +} +``` + **`POST /api/v1/admin/notices/test` 요청 바디** ```json @@ -480,3 +516,4 @@ | `USER_404` | `404` | 존재하지 않는 사용자입니다. | | `NOTIFICATION_404` | `404` | 존재하지 않는 알림입니다. (본인 소유가 아닌 알림 포함) | | `NOTIFICATION_404_SCHEDULE` | `404` | 존재하지 않는 예약 알림입니다. | +| `NOTIFICATION_404_DELIVERY_RESULT` | `404` | 발송 결과가 집계되지 않은 알림입니다. | diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java index 2154814..316acbe 100644 --- a/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java @@ -3,6 +3,7 @@ import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeCreateRequest; import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeUpdateRequest; import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationTestRequest; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDeliveryResultResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDetailResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeListResponse; import com.swyp.picke.domain.admin.service.AdminNotificationService; @@ -74,6 +75,12 @@ public ApiResponse deleteNotice(@PathVariable Long noticeId) { return ApiResponse.onSuccess(null); } + @Operation(summary = "공지사항 발송 결과 조회", description = "대상 디바이스 수, 성공/실패 건수를 조회한다. 푸시 발송은 비동기로 이뤄지므로 완료 전에는 pending=true로 내려간다.") + @GetMapping("/{noticeId}/delivery-result") + public ApiResponse getDeliveryResult(@PathVariable Long noticeId) { + return ApiResponse.onSuccess(adminNotificationService.getDeliveryResult(noticeId)); + } + @Operation(summary = "푸시 알림 발송 테스트", description = "특정 유저의 등록된 디바이스로 알림 설정(ON/OFF) 무관하게 즉시 테스트 푸시를 발송한다.") @PostMapping("/test") public ApiResponse sendTestPush( diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeDeliveryResultResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeDeliveryResultResponse.java new file mode 100644 index 0000000..49152ca --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeDeliveryResultResponse.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.admin.dto.notification.response; + +public record AdminNoticeDeliveryResultResponse( + Long notificationId, + int targetCount, + int successCount, + int failureCount, + boolean pending +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationService.java index 19da48c..ec436a2 100644 --- a/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationService.java +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationService.java @@ -2,12 +2,15 @@ import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeCreateRequest; import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeUpdateRequest; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDeliveryResultResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDetailResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeListResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeSummaryResponse; import com.swyp.picke.domain.notification.entity.Notification; +import com.swyp.picke.domain.notification.entity.NotificationDeliveryResult; import com.swyp.picke.domain.notification.enums.NotificationCategory; import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.notification.repository.NotificationDeliveryResultRepository; import com.swyp.picke.domain.notification.repository.NotificationRepository; import com.swyp.picke.domain.notification.service.NotificationDispatchService; import com.swyp.picke.domain.notification.service.NotificationService; @@ -29,6 +32,7 @@ public class AdminNotificationService { private final NotificationService notificationService; private final NotificationDispatchService notificationDispatchService; private final NotificationRepository notificationRepository; + private final NotificationDeliveryResultRepository notificationDeliveryResultRepository; @Transactional public AdminNoticeDetailResponse createNotice(AdminNoticeCreateRequest request) { @@ -42,7 +46,7 @@ public AdminNoticeDetailResponse createNotice(AdminNoticeCreateRequest request) if (detailCode.getCategory() == NotificationCategory.NOTICE || detailCode.getCategory() == NotificationCategory.EVENT) { - notificationDispatchService.notifyAdminNotice(detailCode, request.title(), request.body()); + notificationDispatchService.notifyAdminNotice(notification.getId(), detailCode, request.title(), request.body()); } return toDetailResponse(notification); @@ -87,6 +91,22 @@ public void deleteNotice(Long notificationId) { notification.delete(); } + public AdminNoticeDeliveryResultResponse getDeliveryResult(Long notificationId) { + notificationRepository.findByIdAndDeletedAtIsNull(notificationId) + .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); + + NotificationDeliveryResult result = notificationDeliveryResultRepository.findByNotificationId(notificationId) + .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_DELIVERY_RESULT_NOT_FOUND)); + + return new AdminNoticeDeliveryResultResponse( + result.getNotificationId(), + result.getTargetCount(), + result.getSuccessCount(), + result.getFailureCount(), + result.isPending() + ); + } + private NotificationCategory normalizeCategory(NotificationCategory category) { if (category == null || category == NotificationCategory.ALL) { return null; diff --git a/src/main/java/com/swyp/picke/domain/notification/entity/NotificationDeliveryResult.java b/src/main/java/com/swyp/picke/domain/notification/entity/NotificationDeliveryResult.java new file mode 100644 index 0000000..6129460 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/entity/NotificationDeliveryResult.java @@ -0,0 +1,41 @@ +package com.swyp.picke.domain.notification.entity; + +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "notification_delivery_results") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NotificationDeliveryResult extends BaseEntity { + + @Column(name = "notification_id", nullable = false, unique = true) + private Long notificationId; + + @Column(name = "target_count", nullable = false) + private int targetCount; + + @Column(name = "success_count", nullable = false) + private int successCount; + + @Column(name = "failure_count", nullable = false) + private int failureCount; + + @Builder + private NotificationDeliveryResult(Long notificationId, int targetCount) { + this.notificationId = notificationId; + this.targetCount = targetCount; + this.successCount = 0; + this.failureCount = 0; + } + + public boolean isPending() { + return successCount + failureCount < targetCount; + } +} diff --git a/src/main/java/com/swyp/picke/domain/notification/repository/NotificationDeliveryResultRepository.java b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationDeliveryResultRepository.java new file mode 100644 index 0000000..f538f9e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationDeliveryResultRepository.java @@ -0,0 +1,25 @@ +package com.swyp.picke.domain.notification.repository; + +import com.swyp.picke.domain.notification.entity.NotificationDeliveryResult; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface NotificationDeliveryResultRepository extends JpaRepository { + + Optional findByNotificationId(Long notificationId); + + @Modifying + @Query(""" + update NotificationDeliveryResult r + set r.successCount = :successCount, r.failureCount = :failureCount + where r.notificationId = :notificationId + """) + void updateResult( + @Param("notificationId") Long notificationId, + @Param("successCount") int successCount, + @Param("failureCount") int failureCount + ); +} diff --git a/src/main/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcher.java b/src/main/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcher.java index 2c5148e..9fb70af 100644 --- a/src/main/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcher.java +++ b/src/main/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcher.java @@ -1,5 +1,6 @@ package com.swyp.picke.domain.notification.scheduler; +import com.swyp.picke.domain.notification.entity.Notification; import com.swyp.picke.domain.notification.entity.NotificationSchedule; import com.swyp.picke.domain.notification.enums.NotificationDetailCode; import com.swyp.picke.domain.notification.repository.NotificationScheduleRepository; @@ -40,10 +41,10 @@ public void dispatchDueSchedules() { .toList(); for (NotificationSchedule schedule : dueSchedules) { - notificationService.createBroadcastNotification( + Notification notification = notificationService.createBroadcastNotification( NotificationDetailCode.DAILY_MESSAGE, schedule.getTitle(), schedule.getSubtitle(), null); notificationDispatchService.notifyAdminNotice( - NotificationDetailCode.DAILY_MESSAGE, schedule.getTitle(), schedule.getSubtitle()); + notification.getId(), NotificationDetailCode.DAILY_MESSAGE, schedule.getTitle(), schedule.getSubtitle()); schedule.markSent(today); log.info("[NotificationScheduleDispatcher] sent scheduleId={}, title={}", schedule.getId(), schedule.getTitle()); } diff --git a/src/main/java/com/swyp/picke/domain/notification/service/NotificationDispatchService.java b/src/main/java/com/swyp/picke/domain/notification/service/NotificationDispatchService.java index 3a60177..02c34aa 100644 --- a/src/main/java/com/swyp/picke/domain/notification/service/NotificationDispatchService.java +++ b/src/main/java/com/swyp/picke/domain/notification/service/NotificationDispatchService.java @@ -1,8 +1,10 @@ package com.swyp.picke.domain.notification.service; +import com.swyp.picke.domain.notification.entity.NotificationDeliveryResult; import com.swyp.picke.domain.notification.entity.UserDevice; import com.swyp.picke.domain.notification.enums.DevicePlatform; import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.notification.repository.NotificationDeliveryResultRepository; import com.swyp.picke.domain.notification.repository.UserDeviceRepository; import com.swyp.picke.domain.user.entity.UserSettings; import com.swyp.picke.domain.user.repository.UserSettingsRepository; @@ -10,6 +12,7 @@ import com.swyp.picke.global.infra.fcm.service.FcmPushService; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; import java.util.function.Predicate; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -27,6 +30,7 @@ public class NotificationDispatchService { private final NotificationService notificationService; private final UserDeviceRepository userDeviceRepository; private final UserSettingsRepository userSettingsRepository; + private final NotificationDeliveryResultRepository notificationDeliveryResultRepository; private final FcmPushService fcmPushService; private final ApnsPushService apnsPushService; @@ -83,15 +87,33 @@ public void notifyNewComment(Long perspectiveAuthorId, Long perspectiveId, Long } /** - * 관리자가 등록한 공지/이벤트 알림에 대해 '이벤트 및 소식 알림' 설정이 ON인 사용자에게 푸시를 발송한다. + * 관리자가 등록한 공지/이벤트 알림에 대해 '이벤트 및 소식 알림' 설정이 ON인 사용자에게 푸시를 발송하고, + * 발송 결과(성공/실패 건수)를 {@link NotificationDeliveryResult}에 기록한다. */ - public void notifyAdminNotice(NotificationDetailCode detailCode, String title, String body) { + public void notifyAdminNotice(Long notificationId, NotificationDetailCode detailCode, String title, String body) { Map data = Map.of("type", detailCode.getCategory().name()); List userIds = userSettingsRepository.findUserIdsByMarketingEventEnabledTrue(); - for (UserDevice device : userDeviceRepository.findAllByUserIdIn(userIds)) { - sendPush(device, title, body, data); - } + List devices = userDeviceRepository.findAllByUserIdIn(userIds); + + notificationDeliveryResultRepository.save( + NotificationDeliveryResult.builder() + .notificationId(notificationId) + .targetCount(devices.size()) + .build() + ); + + List> results = devices.stream() + .map(device -> sendPush(device, title, body, data)) + .toList(); + + CompletableFuture.allOf(results.toArray(new CompletableFuture[0])) + .whenComplete((ignored, throwable) -> { + long successCount = results.stream().filter(CompletableFuture::join).count(); + long failureCount = results.size() - successCount; + notificationDeliveryResultRepository.updateResult( + notificationId, (int) successCount, (int) failureCount); + }); } /** @@ -128,11 +150,10 @@ private void sendCommentPush(Long userId, NotificationDetailCode detailCode, Str } } - private void sendPush(UserDevice device, String title, String body, Map data) { + private CompletableFuture sendPush(UserDevice device, String title, String body, Map data) { if (device.getPlatform() == DevicePlatform.IOS) { - apnsPushService.send(device, title, body, data); - } else { - fcmPushService.send(device, title, body, data); + return apnsPushService.send(device, title, body, data); } + return fcmPushService.send(device, title, body, data); } } diff --git a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java index 277b2d5..415672d 100644 --- a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java +++ b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java @@ -50,6 +50,7 @@ public enum ErrorCode { // Notification NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION_404", "존재하지 않는 알림입니다."), NOTIFICATION_SCHEDULE_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION_404_SCHEDULE", "존재하지 않는 알림 예약입니다."), + NOTIFICATION_DELIVERY_RESULT_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION_404_DELIVERY_RESULT", "발송 결과가 집계되지 않은 알림입니다."), // TTS TTS_INVALID_VOICE_ID(HttpStatus.BAD_REQUEST, "TTS_400_VOICE", "TTS 보이스 ID가 유효하지 않습니다."), diff --git a/src/main/java/com/swyp/picke/global/infra/apns/service/ApnsPushService.java b/src/main/java/com/swyp/picke/global/infra/apns/service/ApnsPushService.java index 0133947..6aeddff 100644 --- a/src/main/java/com/swyp/picke/global/infra/apns/service/ApnsPushService.java +++ b/src/main/java/com/swyp/picke/global/infra/apns/service/ApnsPushService.java @@ -16,6 +16,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.CompletableFuture; /** * iOS 디바이스에 FCM을 거치지 않고 APNs로 직접 푸시를 발송한다. @@ -35,10 +36,10 @@ public class ApnsPushService { @Value("${apns.bundle-id}") private String bundleId; - public void send(UserDevice device, String title, String body, Map data) { + public CompletableFuture send(UserDevice device, String title, String body, Map data) { if (apnsClient.isEmpty()) { log.warn("APNs가 설정되지 않아 푸시를 건너뜁니다. deviceId={}", device.getId()); - return; + return CompletableFuture.completedFuture(false); } ApnsPayloadBuilder payloadBuilder = new SimpleApnsPayloadBuilder() @@ -51,18 +52,18 @@ public void send(UserDevice device, String title, String body, Map { + return apnsClient.get().sendNotification(notification).handle((response, cause) -> { if (cause != null) { log.warn("APNs 푸시 전송 실패. deviceId={}, error={}", device.getId(), cause.getMessage()); - return; + return false; } - handleResponse(device, response); + return handleResponse(device, response); }); } - private void handleResponse(UserDevice device, PushNotificationResponse response) { + private boolean handleResponse(UserDevice device, PushNotificationResponse response) { if (response.isAccepted()) { - return; + return true; } String reason = response.getRejectionReason().orElse("UNKNOWN"); @@ -71,5 +72,6 @@ private void handleResponse(UserDevice device, PushNotificationResponse data) { + public CompletableFuture send(UserDevice device, String title, String body, Map data) { Map payload = new HashMap<>(data); payload.put("title", title); payload.put("body", body); @@ -41,11 +42,13 @@ public void send(UserDevice device, String title, String body, Map adminNotificationService.deleteNotice(999L)) .isInstanceOf(CustomException.class); } + + @Test + @DisplayName("공지사항 발송 결과를 조회한다") + void getDeliveryResult_returnsResult() { + Notification notification = newNotice("제목", "본문"); + when(notificationRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(notification)); + NotificationDeliveryResult deliveryResult = NotificationDeliveryResult.builder() + .notificationId(1L) + .targetCount(10) + .build(); + when(notificationDeliveryResultRepository.findByNotificationId(1L)) + .thenReturn(Optional.of(deliveryResult)); + + var response = adminNotificationService.getDeliveryResult(1L); + + assertThat(response.notificationId()).isEqualTo(1L); + assertThat(response.targetCount()).isEqualTo(10); + assertThat(response.successCount()).isEqualTo(0); + assertThat(response.failureCount()).isEqualTo(0); + assertThat(response.pending()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 공지사항의 발송 결과를 조회하면 예외를 던진다") + void getDeliveryResult_throws_whenNoticeNotFound() { + when(notificationRepository.findByIdAndDeletedAtIsNull(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminNotificationService.getDeliveryResult(999L)) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("발송 결과가 아직 집계되지 않은 공지사항을 조회하면 예외를 던진다") + void getDeliveryResult_throws_whenResultNotFound() { + Notification notification = newNotice("제목", "본문"); + when(notificationRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(notification)); + when(notificationDeliveryResultRepository.findByNotificationId(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminNotificationService.getDeliveryResult(1L)) + .isInstanceOf(CustomException.class); + } } diff --git a/src/test/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcherTest.java b/src/test/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcherTest.java index a4460e8..42c1306 100644 --- a/src/test/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcherTest.java +++ b/src/test/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcherTest.java @@ -7,7 +7,9 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.swyp.picke.domain.notification.entity.Notification; import com.swyp.picke.domain.notification.entity.NotificationSchedule; +import com.swyp.picke.domain.notification.enums.NotificationCategory; import com.swyp.picke.domain.notification.enums.NotificationDetailCode; import com.swyp.picke.domain.notification.repository.NotificationScheduleRepository; import com.swyp.picke.domain.notification.service.NotificationDispatchService; @@ -22,6 +24,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) class NotificationScheduleDispatcherTest { @@ -66,14 +69,26 @@ void dispatchDueSchedules_sendsOnlyMatchingEnabledSchedules() { .build(); when(notificationScheduleRepository.findAllByEnabledTrue()).thenReturn(List.of(due, notDue)); + Notification notification = Notification.builder() + .user(null) + .category(NotificationCategory.CONTENT) + .detailCode(NotificationDetailCode.DAILY_MESSAGE) + .title("오늘의 질문") + .body("지금 확인해보세요") + .build(); + ReflectionTestUtils.setField(notification, "id", 1L); + when(notificationService.createBroadcastNotification( + eq(NotificationDetailCode.DAILY_MESSAGE), eq("오늘의 질문"), eq("지금 확인해보세요"), any())) + .thenReturn(notification); + notificationScheduleDispatcher.dispatchDueSchedules(); verify(notificationService, times(1)).createBroadcastNotification( eq(NotificationDetailCode.DAILY_MESSAGE), eq("오늘의 질문"), eq("지금 확인해보세요"), any()); verify(notificationDispatchService, times(1)).notifyAdminNotice( - eq(NotificationDetailCode.DAILY_MESSAGE), eq("오늘의 질문"), eq("지금 확인해보세요")); + eq(1L), eq(NotificationDetailCode.DAILY_MESSAGE), eq("오늘의 질문"), eq("지금 확인해보세요")); verify(notificationDispatchService, never()).notifyAdminNotice( - eq(NotificationDetailCode.DAILY_MESSAGE), eq("다른 알림"), any()); + any(), eq(NotificationDetailCode.DAILY_MESSAGE), eq("다른 알림"), any()); } @Test @@ -83,6 +98,6 @@ void dispatchDueSchedules_doesNothing_whenNoScheduleIsDue() { notificationScheduleDispatcher.dispatchDueSchedules(); - verify(notificationDispatchService, never()).notifyAdminNotice(any(), any(), any()); + verify(notificationDispatchService, never()).notifyAdminNotice(any(), any(), any(), any()); } } diff --git a/src/test/java/com/swyp/picke/domain/notification/service/NotificationDispatchServiceTest.java b/src/test/java/com/swyp/picke/domain/notification/service/NotificationDispatchServiceTest.java new file mode 100644 index 0000000..15f0812 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/notification/service/NotificationDispatchServiceTest.java @@ -0,0 +1,85 @@ +package com.swyp.picke.domain.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.notification.entity.NotificationDeliveryResult; +import com.swyp.picke.domain.notification.entity.UserDevice; +import com.swyp.picke.domain.notification.enums.DevicePlatform; +import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.notification.repository.NotificationDeliveryResultRepository; +import com.swyp.picke.domain.notification.repository.UserDeviceRepository; +import com.swyp.picke.domain.user.repository.UserSettingsRepository; +import com.swyp.picke.global.infra.apns.service.ApnsPushService; +import com.swyp.picke.global.infra.fcm.service.FcmPushService; +import java.util.List; +import java.util.concurrent.CompletableFuture; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class NotificationDispatchServiceTest { + + @Mock + private NotificationService notificationService; + + @Mock + private UserDeviceRepository userDeviceRepository; + + @Mock + private UserSettingsRepository userSettingsRepository; + + @Mock + private NotificationDeliveryResultRepository notificationDeliveryResultRepository; + + @Mock + private FcmPushService fcmPushService; + + @Mock + private ApnsPushService apnsPushService; + + private NotificationDispatchService newService() { + NotificationDispatchService service = new NotificationDispatchService( + notificationService, userDeviceRepository, userSettingsRepository, + notificationDeliveryResultRepository, fcmPushService, apnsPushService); + ReflectionTestUtils.setField(service, "baseUrl", "https://picke.store"); + return service; + } + + private UserDevice newDevice() { + return UserDevice.builder().fcmToken("token-" + Math.random()).platform(DevicePlatform.ANDROID).build(); + } + + @Test + @DisplayName("공지 발송 시 대상 디바이스 수로 발송 결과 row를 먼저 만들고, 발송 완료 후 성공/실패 건수를 갱신한다") + void notifyAdminNotice_recordsDeliveryResult() { + NotificationDispatchService notificationDispatchService = newService(); + + UserDevice success = newDevice(); + UserDevice failure = newDevice(); + when(userSettingsRepository.findUserIdsByMarketingEventEnabledTrue()).thenReturn(List.of(1L, 2L)); + when(userDeviceRepository.findAllByUserIdIn(List.of(1L, 2L))).thenReturn(List.of(success, failure)); + when(fcmPushService.send(eq(success), anyString(), anyString(), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + when(fcmPushService.send(eq(failure), anyString(), anyString(), any())) + .thenReturn(CompletableFuture.completedFuture(false)); + + notificationDispatchService.notifyAdminNotice(10L, NotificationDetailCode.POLICY_CHANGE, "제목", "본문"); + + ArgumentCaptor createdCaptor = ArgumentCaptor.forClass(NotificationDeliveryResult.class); + verify(notificationDeliveryResultRepository).save(createdCaptor.capture()); + assertThat(createdCaptor.getValue().getNotificationId()).isEqualTo(10L); + assertThat(createdCaptor.getValue().getTargetCount()).isEqualTo(2); + + verify(notificationDeliveryResultRepository).updateResult(10L, 1, 1); + } +}