diff --git a/docs/api-specs/notification-api.md b/docs/api-specs/notification-api.md index d9a0b1d..6151c7f 100644 --- a/docs/api-specs/notification-api.md +++ b/docs/api-specs/notification-api.md @@ -446,6 +446,7 @@ | `GET` | `/api/v1/admin/notification-schedules/{scheduleId}` | 예약 알림 상세 조회 | | `PUT` | `/api/v1/admin/notification-schedules/{scheduleId}` | 예약 알림 수정 (제목/부제목/발송시간/on-off 전체 교체) | | `PATCH` | `/api/v1/admin/notification-schedules/{scheduleId}/toggle` | 예약 알림 On/Off만 전환 | +| `POST` | `/api/v1/admin/notification-schedules/{scheduleId}/test` | 저장된 제목/부제목으로 특정 유저에게 테스트 발송 | | `DELETE` | `/api/v1/admin/notification-schedules/{scheduleId}` | 예약 알림 삭제 | **`POST` / `PUT` 요청 바디** @@ -474,6 +475,16 @@ } ``` +**`POST .../test` 요청 바디** + +```json +{ + "userId": 123 +} +``` + +저장되어 있는 예약 알림의 `title`/`subtitle`을 그대로 사용해, 지정한 유저의 등록된 디바이스로 알림 설정(ON/OFF) 무관하게 즉시 테스트 푸시를 발송합니다. 등록/수정 폼에서 저장 전 미리보기 용도로 쓰려면, 먼저 원하는 문구로 `POST`/`PUT`을 호출해 저장한 뒤 이 API로 확인하는 흐름을 권장합니다. + **응답 (`AdminNotificationScheduleResponse`)** ```json diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationScheduleController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationScheduleController.java index 09982ec..ee2cb23 100644 --- a/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationScheduleController.java +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationScheduleController.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.admin.controller; import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleTestRequest; import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleToggleRequest; import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleListResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleResponse; @@ -68,6 +69,16 @@ public ApiResponse toggle( return ApiResponse.onSuccess(adminNotificationScheduleService.toggle(scheduleId, request)); } + @Operation(summary = "예약 알림 테스트 발송", description = "저장된 예약 알림의 제목/부제목을 특정 유저에게 알림 설정(ON/OFF) 무관하게 즉시 테스트 발송한다.") + @PostMapping("/{scheduleId}/test") + public ApiResponse sendTest( + @PathVariable Long scheduleId, + @RequestBody @Valid AdminNotificationScheduleTestRequest request + ) { + adminNotificationScheduleService.sendTest(scheduleId, request); + return ApiResponse.onSuccess(null); + } + @Operation(summary = "예약 알림 삭제") @DeleteMapping("/{scheduleId}") public ApiResponse delete(@PathVariable Long scheduleId) { diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleTestRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleTestRequest.java new file mode 100644 index 0000000..52341bb --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleTestRequest.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.admin.dto.notification.request; + +import jakarta.validation.constraints.NotNull; + +public record AdminNotificationScheduleTestRequest( + @NotNull Long userId +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleService.java index 4bc06cc..770915a 100644 --- a/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleService.java +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleService.java @@ -1,11 +1,13 @@ package com.swyp.picke.domain.admin.service; import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleTestRequest; import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleToggleRequest; import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleListResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleResponse; import com.swyp.picke.domain.notification.entity.NotificationSchedule; import com.swyp.picke.domain.notification.repository.NotificationScheduleRepository; +import com.swyp.picke.domain.notification.service.NotificationDispatchService; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; @@ -18,6 +20,7 @@ public class AdminNotificationScheduleService { private final NotificationScheduleRepository notificationScheduleRepository; + private final NotificationDispatchService notificationDispatchService; @Transactional public AdminNotificationScheduleResponse create(AdminNotificationScheduleRequest request) { @@ -57,6 +60,11 @@ public AdminNotificationScheduleResponse toggle(Long scheduleId, AdminNotificati return toResponse(schedule); } + public void sendTest(Long scheduleId, AdminNotificationScheduleTestRequest request) { + NotificationSchedule schedule = getExistingSchedule(scheduleId); + notificationDispatchService.sendTestPush(request.userId(), schedule.getTitle(), schedule.getSubtitle()); + } + @Transactional public void delete(Long scheduleId) { NotificationSchedule schedule = getExistingSchedule(scheduleId); diff --git a/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleServiceTest.java b/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleServiceTest.java index b5508bf..9b73e0c 100644 --- a/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleServiceTest.java @@ -7,11 +7,13 @@ import static org.mockito.Mockito.when; import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleTestRequest; import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleToggleRequest; import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleListResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleResponse; import com.swyp.picke.domain.notification.entity.NotificationSchedule; import com.swyp.picke.domain.notification.repository.NotificationScheduleRepository; +import com.swyp.picke.domain.notification.service.NotificationDispatchService; import com.swyp.picke.global.common.exception.CustomException; import java.time.LocalTime; import java.util.List; @@ -29,6 +31,9 @@ class AdminNotificationScheduleServiceTest { @Mock private NotificationScheduleRepository notificationScheduleRepository; + @Mock + private NotificationDispatchService notificationDispatchService; + @InjectMocks private AdminNotificationScheduleService adminNotificationScheduleService; @@ -133,6 +138,32 @@ void toggle_throws_whenNotFound() { .isInstanceOf(CustomException.class); } + @Test + @DisplayName("예약 알림 저장 내용으로 특정 유저에게 테스트 발송한다") + void sendTest_dispatchesSavedScheduleContent() { + NotificationSchedule schedule = NotificationSchedule.builder() + .title("오늘의 질문") + .subtitle("지금 확인해보세요") + .sendTime(LocalTime.of(19, 0)) + .enabled(true) + .build(); + when(notificationScheduleRepository.findById(1L)).thenReturn(Optional.of(schedule)); + + adminNotificationScheduleService.sendTest(1L, new AdminNotificationScheduleTestRequest(123L)); + + verify(notificationDispatchService).sendTestPush(123L, "오늘의 질문", "지금 확인해보세요"); + } + + @Test + @DisplayName("존재하지 않는 예약 알림을 테스트 발송하면 예외를 던진다") + void sendTest_throws_whenNotFound() { + when(notificationScheduleRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminNotificationScheduleService.sendTest( + 999L, new AdminNotificationScheduleTestRequest(123L))) + .isInstanceOf(CustomException.class); + } + @Test @DisplayName("예약 알림을 삭제한다") void delete_removesExistingSchedule() {