diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditAction.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditAction.java index 18dcafb1..58642679 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditAction.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditAction.java @@ -17,5 +17,6 @@ public enum AdminAuditAction { ANNOUNCEMENT_UPDATED, ANNOUNCEMENT_DELETED, FREEBOARD_POST_DELETED, - FREEBOARD_COMMENT_DELETED + FREEBOARD_COMMENT_DELETED, + ADMIN_MESSAGE_SENT, } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditTargetType.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditTargetType.java index 0a40c6bc..d9b600ea 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditTargetType.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditTargetType.java @@ -12,5 +12,6 @@ public enum AdminAuditTargetType { REPORT_FREEBOARD_POST, REPORT_FREEBOARD_COMMENT, FREEBOARD_POST, - FREEBOARD_COMMENT + FREEBOARD_COMMENT, + ADMIN_MESSAGE, } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/api/AdminNotificationController.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/api/AdminNotificationController.java new file mode 100644 index 00000000..68823cf1 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/api/AdminNotificationController.java @@ -0,0 +1,55 @@ +package org.devkor.apu.saerok_server.domain.admin.notification.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.admin.notification.api.dto.request.AdminSendMessageRequest; +import org.devkor.apu.saerok_server.domain.admin.notification.application.AdminNotificationCommandService; +import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Admin Notification API", description = "관리자 알림 전송 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("${api_prefix}/admin/notifications") +public class AdminNotificationController { + + private final AdminNotificationCommandService commandService; + + @PostMapping("/messages") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("@perm.has('ADMIN_ANNOUNCEMENT_WRITE')") + @Operation( + summary = "특정 사용자들에게 관리자 메시지 전송", + description = "지정한 사용자 목록에게 커스텀 알림을 발송합니다.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "전송 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 없음", content = @Content) + } + ) + public void sendMessage( + @AuthenticationPrincipal UserPrincipal admin, + @Valid @RequestBody AdminSendMessageRequest request + ) { + commandService.sendMessageToUsers( + admin.getId(), + request.getUserIds(), + request.getTitle(), + request.getBody() + ); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/api/dto/request/AdminSendMessageRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/api/dto/request/AdminSendMessageRequest.java new file mode 100644 index 00000000..15bd50ae --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/api/dto/request/AdminSendMessageRequest.java @@ -0,0 +1,31 @@ +package org.devkor.apu.saerok_server.domain.admin.notification.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@Schema(description = "관리자 메시지 전송 요청") +public class AdminSendMessageRequest { + + @NotNull + @Size(min = 1) + @Schema(description = "수신자 사용자 ID 목록", example = "[1, 2, 3]") + private List userIds; + + @NotBlank + @Size(max = 100) + @Schema(description = "알림 제목", example = "안내 사항") + private String title; + + @NotBlank + @Size(max = 500) + @Schema(description = "알림 내용", example = "서비스 이용에 참고해 주세요.") + private String body; +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/AdminNotificationCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/AdminNotificationCommandService.java new file mode 100644 index 00000000..d99ca560 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/AdminNotificationCommandService.java @@ -0,0 +1,52 @@ +package org.devkor.apu.saerok_server.domain.admin.notification.application; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditAction; +import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditLog; +import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditTargetType; +import org.devkor.apu.saerok_server.domain.admin.audit.core.repository.AdminAuditLogRepository; +import org.devkor.apu.saerok_server.domain.admin.notification.application.event.AdminNotificationEvent; +import org.devkor.apu.saerok_server.domain.user.core.entity.User; +import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; +import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@Transactional +@RequiredArgsConstructor +public class AdminNotificationCommandService { + + private final ApplicationEventPublisher eventPublisher; + private final AdminAuditLogRepository adminAuditLogRepository; + private final UserRepository userRepository; + + public void sendMessageToUsers(Long adminUserId, List userIds, String title, String body) { + eventPublisher.publishEvent( + new AdminNotificationEvent.AdminMessageSent(userIds, title, body) + ); + + User admin = userRepository.findById(adminUserId) + .orElseThrow(() -> new NotFoundException("관리자 계정이 존재하지 않아요")); + + Map metadata = new LinkedHashMap<>(); + metadata.put("recipientCount", userIds.size()); + metadata.put("recipientIds", userIds); + metadata.put("title", title); + metadata.put("body", body); + + adminAuditLogRepository.save(AdminAuditLog.of( + admin, + AdminAuditAction.ADMIN_MESSAGE_SENT, + AdminAuditTargetType.ADMIN_MESSAGE, + null, + null, + metadata + )); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationEvent.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationEvent.java new file mode 100644 index 00000000..8a805d1b --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationEvent.java @@ -0,0 +1,17 @@ +package org.devkor.apu.saerok_server.domain.admin.notification.application.event; + +import java.util.List; + +public sealed interface AdminNotificationEvent { + + record AdminMessageSent( + List recipientIds, + String title, + String body + ) implements AdminNotificationEvent {} + + record ContentDeletedByReport( + Long contentOwnerId, + String reason + ) implements AdminNotificationEvent {} +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorker.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorker.java new file mode 100644 index 00000000..263a2b54 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorker.java @@ -0,0 +1,57 @@ +package org.devkor.apu.saerok_server.domain.admin.notification.application.event; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifySystemService; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AdminNotificationWorker { + + private final NotifySystemService notifySystemService; + + @Async("pushNotificationExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(AdminNotificationEvent.AdminMessageSent event) { + try { + Map extras = Map.of( + "title", event.title(), + "body", event.body() + ); + notifySystemService.notifyUsersDeduplicatedPush( + event.recipientIds(), + NotificationType.SYSTEM_ADMIN_MESSAGE, + null, + extras + ); + } catch (Exception e) { + log.error("Failed to send admin message notification: recipientCount={}", + event.recipientIds().size(), e); + } + } + + @Async("pushNotificationExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(AdminNotificationEvent.ContentDeletedByReport event) { + try { + Map extras = Map.of("reason", event.reason()); + notifySystemService.notifyUser( + event.contentOwnerId(), + NotificationType.SYSTEM_CONTENT_DELETED, + null, + extras + ); + } catch (Exception e) { + log.error("Failed to send content-deleted notification: ownerId={}", + event.contentOwnerId(), e); + } + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminFreeBoardReportCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminFreeBoardReportCommandService.java index ece9f88e..20518360 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminFreeBoardReportCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminFreeBoardReportCommandService.java @@ -5,6 +5,7 @@ import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditLog; import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditTargetType; import org.devkor.apu.saerok_server.domain.admin.audit.core.repository.AdminAuditLogRepository; +import org.devkor.apu.saerok_server.domain.admin.notification.application.event.AdminNotificationEvent; import org.devkor.apu.saerok_server.domain.freeboard.core.entity.FreeBoardPost; import org.devkor.apu.saerok_server.domain.freeboard.core.entity.FreeBoardPostComment; import org.devkor.apu.saerok_server.domain.freeboard.core.entity.FreeBoardPostCommentReport; @@ -16,6 +17,7 @@ import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,6 +37,7 @@ public class AdminFreeBoardReportCommandService { private final AdminAuditLogRepository adminAuditLogRepository; private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; /* ───────────── 신고 무시 ───────────── */ @@ -128,6 +131,10 @@ public void deletePostByReport(Long adminUserId, Long reportId, String reason) { reportId, metadata )); + + // 콘텐츠 삭제 알림 + eventPublisher.publishEvent(new AdminNotificationEvent.ContentDeletedByReport( + report.getReportedUser().getId(), reason)); } public void deleteCommentByReport(Long adminUserId, Long reportId, String reason) { @@ -168,5 +175,9 @@ public void deleteCommentByReport(Long adminUserId, Long reportId, String reason reportId, metadata )); + + // 콘텐츠 삭제 알림 + eventPublisher.publishEvent(new AdminNotificationEvent.ContentDeletedByReport( + report.getReportedUser().getId(), reason)); } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminReportCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminReportCommandService.java index a93b10aa..5d089126 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminReportCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminReportCommandService.java @@ -5,6 +5,7 @@ import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditLog; import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditTargetType; import org.devkor.apu.saerok_server.domain.admin.audit.core.repository.AdminAuditLogRepository; +import org.devkor.apu.saerok_server.domain.admin.notification.application.event.AdminNotificationEvent; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollectionComment; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollectionCommentReport; @@ -15,6 +16,7 @@ import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException; import org.devkor.apu.saerok_server.global.shared.infra.ImageService; import org.devkor.apu.saerok_server.global.shared.util.TransactionUtils; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -39,6 +41,7 @@ public class AdminReportCommandService { // 감사/행위자 조회 private final AdminAuditLogRepository adminAuditLogRepository; private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; /* ───────────── 신고 무시 ───────────── */ @@ -138,7 +141,11 @@ public void deleteCollectionByReport(Long adminUserId, Long reportId, String rea metadata )); - // 5) 커밋 후 S3 삭제 + // 5) 콘텐츠 삭제 알림 + eventPublisher.publishEvent(new AdminNotificationEvent.ContentDeletedByReport( + report.getReportedUser().getId(), reason)); + + // 6) 커밋 후 S3 삭제 if (!objectKeys.isEmpty()) { TransactionUtils.runAfterCommitOrNow(() -> imageService.deleteAll(objectKeys)); } @@ -182,5 +189,9 @@ public void deleteCommentByReport(Long adminUserId, Long reportId, String reason reportId, metadata )); + + // 콘텐츠 삭제 알림 + eventPublisher.publishEvent(new AdminNotificationEvent.ContentDeletedByReport( + report.getReportedUser().getId(), reason)); } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java index e8bda22b..98c9d22c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java @@ -17,5 +17,7 @@ public enum NotificationType { SUGGESTED_BIRD_ID_ON_COLLECTION, // ---- System Notification Types ---- - SYSTEM_PUBLISHED_ANNOUNCEMENT + SYSTEM_PUBLISHED_ANNOUNCEMENT, + SYSTEM_ADMIN_MESSAGE, + SYSTEM_CONTENT_DELETED, } diff --git a/src/main/resources/config/notification-messages.yml b/src/main/resources/config/notification-messages.yml index 445394cd..41023359 100644 --- a/src/main/resources/config/notification-messages.yml +++ b/src/main/resources/config/notification-messages.yml @@ -22,4 +22,10 @@ notification-messages: batch-push-body: "{count}개의 새로운 의견이 공유되었어요. 확인해볼까요?" SYSTEM_PUBLISHED_ANNOUNCEMENT: push-title: "{title}" - push-body: "{body}" \ No newline at end of file + push-body: "{body}" + SYSTEM_ADMIN_MESSAGE: + push-title: "{title}" + push-body: "{body}" + SYSTEM_CONTENT_DELETED: + push-title: "콘텐츠 삭제 안내" + push-body: "회원님의 콘텐츠가 운영정책 위반으로 삭제되었어요. 사유: {reason}" \ No newline at end of file diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminFreeBoardReportCommandServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminFreeBoardReportCommandServiceTest.java index 01d6925c..006a06b9 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminFreeBoardReportCommandServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminFreeBoardReportCommandServiceTest.java @@ -16,6 +16,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; import java.util.Map; @@ -39,6 +40,7 @@ class AdminFreeBoardReportCommandServiceTest { @Mock FreeBoardPostCommentRepository commentRepository; @Mock AdminAuditLogRepository adminAuditLogRepository; @Mock UserRepository userRepository; + @Mock ApplicationEventPublisher eventPublisher; private static User user(long id) { User u = new User(); diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminReportCommandServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminReportCommandServiceTest.java index 2f57a1ef..e0041674 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminReportCommandServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminReportCommandServiceTest.java @@ -20,6 +20,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -48,6 +49,7 @@ class AdminReportCommandServiceTest { @Mock AdminAuditLogRepository adminAuditLogRepository; @Mock UserRepository userRepository; + @Mock ApplicationEventPublisher eventPublisher; private static User user(long id) { User u = new User(); diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorkerTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorkerTest.java new file mode 100644 index 00000000..96db8ed9 --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorkerTest.java @@ -0,0 +1,103 @@ +package org.devkor.apu.saerok_server.domain.admin.notification.application.event; + +import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifySystemService; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; +import org.junit.jupiter.api.BeforeEach; +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 java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class AdminNotificationWorkerTest { + + @Mock private NotifySystemService notifySystemService; + + private AdminNotificationWorker worker; + + @BeforeEach + void setUp() { + worker = new AdminNotificationWorker(notifySystemService); + } + + @Test + @DisplayName("관리자 메시지 이벤트 처리 시 notifyUsersDeduplicatedPush를 올바르게 호출한다") + void handle_adminMessageSent_callsNotifyUsersDeduplicatedPush() { + List recipientIds = List.of(1L, 2L, 3L); + var event = new AdminNotificationEvent.AdminMessageSent(recipientIds, "안내 사항", "서비스 점검 예정입니다."); + + worker.handle(event); + + @SuppressWarnings("unchecked") + ArgumentCaptor> idsCaptor = ArgumentCaptor.forClass(List.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> extrasCaptor = ArgumentCaptor.forClass(Map.class); + + verify(notifySystemService).notifyUsersDeduplicatedPush( + idsCaptor.capture(), + eq(NotificationType.SYSTEM_ADMIN_MESSAGE), + isNull(), + extrasCaptor.capture() + ); + + assertThat(idsCaptor.getValue()).containsExactly(1L, 2L, 3L); + assertThat(extrasCaptor.getValue()) + .containsEntry("title", "안내 사항") + .containsEntry("body", "서비스 점검 예정입니다."); + } + + @Test + @DisplayName("콘텐츠 삭제 이벤트 처리 시 notifyUser를 올바르게 호출한다") + void handle_contentDeletedByReport_callsNotifyUser() { + var event = new AdminNotificationEvent.ContentDeletedByReport(42L, "커뮤니티 가이드라인 위반"); + + worker.handle(event); + + @SuppressWarnings("unchecked") + ArgumentCaptor> extrasCaptor = ArgumentCaptor.forClass(Map.class); + + verify(notifySystemService).notifyUser( + eq(42L), + eq(NotificationType.SYSTEM_CONTENT_DELETED), + isNull(), + extrasCaptor.capture() + ); + + assertThat(extrasCaptor.getValue()).containsEntry("reason", "커뮤니티 가이드라인 위반"); + } + + @Test + @DisplayName("관리자 메시지 발송 중 예외가 나도 외부로 전파하지 않는다") + void handle_adminMessageSentFailure_swallowsException() { + doThrow(new RuntimeException("push failed")) + .when(notifySystemService).notifyUsersDeduplicatedPush(any(), any(), any(), any()); + + assertThatCode(() -> worker.handle( + new AdminNotificationEvent.AdminMessageSent(List.of(1L), "제목", "내용") + )).doesNotThrowAnyException(); + } + + @Test + @DisplayName("콘텐츠 삭제 알림 발송 중 예외가 나도 외부로 전파하지 않는다") + void handle_contentDeletedFailure_swallowsException() { + doThrow(new RuntimeException("push failed")) + .when(notifySystemService).notifyUser(any(), any(), any(), any()); + + assertThatCode(() -> worker.handle( + new AdminNotificationEvent.ContentDeletedByReport(1L, "사유") + )).doesNotThrowAnyException(); + } +}