From 8c905cf811adca0a63253178162a17c59997567f Mon Sep 17 00:00:00 2001 From: si-zero Date: Sun, 16 Aug 2026 19:32:28 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=EC=9A=A9=20=ED=91=B8=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AdminNotificationController.java | 12 ++++++++++++ .../request/AdminNotificationTestRequest.java | 10 ++++++++++ .../service/NotificationDispatchService.java | 11 +++++++++++ 3 files changed, 33 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationTestRequest.java 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 bfb1cd16..c434a9c2 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 @@ -1,10 +1,12 @@ package com.swyp.picke.domain.admin.controller; import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeCreateRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationTestRequest; 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; import com.swyp.picke.domain.notification.enums.NotificationCategory; +import com.swyp.picke.domain.notification.service.NotificationDispatchService; import com.swyp.picke.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -27,6 +29,7 @@ public class AdminNotificationController { private final AdminNotificationService adminNotificationService; + private final NotificationDispatchService notificationDispatchService; @Operation(summary = "공지사항 작성") @PostMapping @@ -51,4 +54,13 @@ public ApiResponse getNotices( public ApiResponse getNoticeDetail(@PathVariable Long noticeId) { return ApiResponse.onSuccess(adminNotificationService.getNoticeDetail(noticeId)); } + + @Operation(summary = "푸시 알림 발송 테스트", description = "특정 유저의 등록된 디바이스로 알림 설정(ON/OFF) 무관하게 즉시 테스트 푸시를 발송한다.") + @PostMapping("/test") + public ApiResponse sendTestPush( + @RequestBody @Valid AdminNotificationTestRequest request + ) { + notificationDispatchService.sendTestPush(request.userId(), request.title(), request.body()); + return ApiResponse.onSuccess(null); + } } diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationTestRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationTestRequest.java new file mode 100644 index 00000000..3800db4e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationTestRequest.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.admin.dto.notification.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record AdminNotificationTestRequest( + @NotNull Long userId, + @NotBlank String title, + @NotBlank String body +) {} 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 78c1f367..3a60177e 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 @@ -94,6 +94,17 @@ public void notifyAdminNotice(NotificationDetailCode detailCode, String title, S } } + /** + * 관리자가 특정 유저에게 알림 설정(ON/OFF) 체크 없이 즉시 테스트 푸시를 발송한다. + */ + public void sendTestPush(Long userId, String title, String body) { + Map data = Map.of("type", "TEST"); + + for (UserDevice device : userDeviceRepository.findAllByUserId(userId)) { + sendPush(device, title, body, data); + } + } + /** * 유저의 알림 ON/OFF 설정을 확인한다. 설정이 없는 경우(레거시 유저 등) 기본값은 true. */ From 13c97ca2ebafb23420fefaf792e6d070c4cb6dfc Mon Sep 17 00:00:00 2001 From: si-zero Date: Sun, 16 Aug 2026 19:36:16 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=EC=9A=A9=20=ED=91=B8=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20API=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...inNotificationTestPushIntegrationTest.java | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/test/java/com/swyp/picke/domain/admin/controller/AdminNotificationTestPushIntegrationTest.java diff --git a/src/test/java/com/swyp/picke/domain/admin/controller/AdminNotificationTestPushIntegrationTest.java b/src/test/java/com/swyp/picke/domain/admin/controller/AdminNotificationTestPushIntegrationTest.java new file mode 100644 index 00000000..cf7d7d77 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/controller/AdminNotificationTestPushIntegrationTest.java @@ -0,0 +1,119 @@ +package com.swyp.picke.domain.admin.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.notification.entity.UserDevice; +import com.swyp.picke.domain.notification.enums.DevicePlatform; +import com.swyp.picke.domain.notification.repository.UserDeviceRepository; +import com.swyp.picke.domain.oauth.jwt.JwtProvider; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.global.infra.apns.service.ApnsPushService; +import com.swyp.picke.global.infra.fcm.service.FcmPushService; +import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import software.amazon.awssdk.services.s3.S3Client; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminNotificationTestPushIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JwtProvider jwtProvider; + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserDeviceRepository userDeviceRepository; + + @MockitoBean + private S3Client s3Client; + + @MockitoBean + private S3PresignedUrlService s3PresignedUrlService; + + @MockitoBean + private FcmPushService fcmPushService; + + @MockitoBean + private ApnsPushService apnsPushService; + + @Test + @DisplayName("admin can send a test push to a specific user's registered device") + void admin_can_send_test_push_to_user_device() throws Exception { + String adminToken = createAdminToken(); + + User targetUser = userRepository.save( + User.builder() + .userTag("target-" + UUID.randomUUID().toString().substring(0, 8)) + .nickname("target") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build() + ); + + UserDevice device = userDeviceRepository.save( + UserDevice.builder() + .user(targetUser) + .fcmToken("test-token-" + UUID.randomUUID()) + .platform(DevicePlatform.ANDROID) + .build() + ); + + Map payload = Map.of( + "userId", targetUser.getId(), + "title", "테스트 알림", + "body", "테스트 발송 본문" + ); + + mockMvc.perform(post("/api/v1/admin/notices/test") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()); + + ArgumentCaptor deviceCaptor = ArgumentCaptor.forClass(UserDevice.class); + verify(fcmPushService).send(deviceCaptor.capture(), eq("테스트 알림"), eq("테스트 발송 본문"), any()); + assertThat(deviceCaptor.getValue().getId()).isEqualTo(device.getId()); + } + + private String createAdminToken() { + User admin = userRepository.save( + User.builder() + .userTag("adm-" + UUID.randomUUID().toString().substring(0, 8)) + .nickname("adm") + .role(UserRole.ADMIN) + .status(UserStatus.ACTIVE) + .build() + ); + return jwtProvider.createAccessToken(admin.getId(), UserRole.ADMIN.name()); + } +}