From 8d73c35a4515cf1048dc8980c2231603d7415aa3 Mon Sep 17 00:00:00 2001 From: MyoJun <168921419+hyoj-dev@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:12:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=A7=88?= =?UTF-8?q?=EC=BC=80=ED=8C=85=20=EC=95=8C=EB=A6=BC=20=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/docs/asciidoc/admin/notice.adoc | 11 ++ .../webapi/notification/AdminNoticePushApi.kt | 39 +++++ .../dto/AdminNoticePushSendApiRequest.kt | 12 ++ .../auth/required/UserRepository.kt | 48 +++++- .../notification/AdminNoticeEmailService.kt | 4 +- .../notification/AdminNoticePushService.kt | 86 ++++++++++ .../provided/AdminNoticePushSender.kt | 7 + .../AdminNoticePushSendRequest.kt | 25 +++ .../notification/AdminNoticePushTargetType.kt | 6 + .../domain/notification/NotificationType.kt | 1 + .../notification/AdminNoticePushApiTest.kt | 70 ++++++++ .../auth/required/UserRepositoryTest.kt | 162 ++++++++++++++++-- .../AdminNoticeEmailServiceTest.kt | 6 +- .../AdminNoticePushServiceTest.kt | 137 +++++++++++++++ .../notification/AdminNoticePushTest.kt | 71 ++++++++ 15 files changed, 666 insertions(+), 19 deletions(-) create mode 100644 src/main/kotlin/com/didit/adapter/webapi/notification/AdminNoticePushApi.kt create mode 100644 src/main/kotlin/com/didit/adapter/webapi/notification/dto/AdminNoticePushSendApiRequest.kt create mode 100644 src/main/kotlin/com/didit/application/notification/AdminNoticePushService.kt create mode 100644 src/main/kotlin/com/didit/application/notification/provided/AdminNoticePushSender.kt create mode 100644 src/main/kotlin/com/didit/domain/notification/AdminNoticePushSendRequest.kt create mode 100644 src/main/kotlin/com/didit/domain/notification/AdminNoticePushTargetType.kt create mode 100644 src/test/kotlin/com/didit/adapter/webapi/notification/AdminNoticePushApiTest.kt create mode 100644 src/test/kotlin/com/didit/application/notification/AdminNoticePushServiceTest.kt create mode 100644 src/test/kotlin/com/didit/domain/notification/AdminNoticePushTest.kt diff --git a/src/docs/asciidoc/admin/notice.adoc b/src/docs/asciidoc/admin/notice.adoc index 5df52039..741e79b5 100644 --- a/src/docs/asciidoc/admin/notice.adoc +++ b/src/docs/asciidoc/admin/notice.adoc @@ -69,3 +69,14 @@ include::{snippets}/admin-notice-email/send/request-fields.adoc[] === Response include::{snippets}/admin-notice-email/send/http-response.adoc[] + +== 푸시 알림 발송 + +=== Request + +include::{snippets}/admin-notice-push/send/http-request.adoc[] +include::{snippets}/admin-notice-push/send/request-fields.adoc[] + +=== Response + +include::{snippets}/admin-notice-push/send/http-response.adoc[] diff --git a/src/main/kotlin/com/didit/adapter/webapi/notification/AdminNoticePushApi.kt b/src/main/kotlin/com/didit/adapter/webapi/notification/AdminNoticePushApi.kt new file mode 100644 index 00000000..420ec149 --- /dev/null +++ b/src/main/kotlin/com/didit/adapter/webapi/notification/AdminNoticePushApi.kt @@ -0,0 +1,39 @@ +package com.didit.adapter.webapi.notification + +import com.didit.adapter.webapi.admin.annotation.CurrentAdminId +import com.didit.adapter.webapi.admin.annotation.RequireSuperAdmin +import com.didit.adapter.webapi.notification.dto.AdminNoticePushSendApiRequest +import com.didit.application.notification.provided.AdminNoticePushSender +import com.didit.domain.notification.AdminNoticePushSendRequest +import org.springframework.http.HttpStatus +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 +import java.util.UUID + +@RequestMapping("/api/v1/admin/notice-pushes") +@RestController +class AdminNoticePushApi( + private val adminNoticePushSender: AdminNoticePushSender, +) { + @RequireSuperAdmin + @ResponseStatus(HttpStatus.NO_CONTENT) + @PostMapping + fun send( + @CurrentAdminId adminId: UUID, + @RequestBody request: AdminNoticePushSendApiRequest, + ) { + adminNoticePushSender.send( + AdminNoticePushSendRequest( + adminId = adminId, + targetType = request.targetType, + userIds = request.userIds, + title = request.title, + body = request.body, + link = request.link, + ), + ) + } +} diff --git a/src/main/kotlin/com/didit/adapter/webapi/notification/dto/AdminNoticePushSendApiRequest.kt b/src/main/kotlin/com/didit/adapter/webapi/notification/dto/AdminNoticePushSendApiRequest.kt new file mode 100644 index 00000000..3a215301 --- /dev/null +++ b/src/main/kotlin/com/didit/adapter/webapi/notification/dto/AdminNoticePushSendApiRequest.kt @@ -0,0 +1,12 @@ +package com.didit.adapter.webapi.notification.dto + +import com.didit.domain.notification.AdminNoticePushTargetType +import java.util.UUID + +data class AdminNoticePushSendApiRequest( + val targetType: AdminNoticePushTargetType, + val userIds: List, + val title: String, + val body: String, + val link: String, +) diff --git a/src/main/kotlin/com/didit/application/auth/required/UserRepository.kt b/src/main/kotlin/com/didit/application/auth/required/UserRepository.kt index 7cb40c37..d63f36d4 100644 --- a/src/main/kotlin/com/didit/application/auth/required/UserRepository.kt +++ b/src/main/kotlin/com/didit/application/auth/required/UserRepository.kt @@ -37,9 +37,53 @@ interface UserRepository : Repository { providerId: String, ): User? - fun findAllByDeletedAtIsNullAndEmailIsNotNull(): List + @Query( + """ + SELECT u FROM User u + JOIN u.consent c + WHERE u.deletedAt IS NULL + AND u.email IS NOT NULL + AND c.marketingAgreed = true + """, + ) + fun findAllMarketingAgreedWithEmail(): List + + @Query( + """ + SELECT u FROM User u + JOIN u.consent c + WHERE u.id IN :ids + AND u.deletedAt IS NULL + AND u.email IS NOT NULL + AND c.marketingAgreed = true + """, + ) + fun findAllMarketingAgreedWithEmailByIdIn( + @Param("ids") ids: List, + ): List - fun findAllByIdInAndDeletedAtIsNullAndEmailIsNotNull(ids: List): List + @Query( + """ + SELECT u FROM User u + JOIN u.consent c + WHERE u.deletedAt IS NULL + AND c.marketingAgreed = true + """, + ) + fun findAllMarketingAgreed(): List + + @Query( + """ + SELECT u FROM User u + JOIN u.consent c + WHERE u.id IN :ids + AND u.deletedAt IS NULL + AND c.marketingAgreed = true + """, + ) + fun findAllMarketingAgreedByIdIn( + @Param("ids") ids: List, + ): List @Query("SELECT u FROM User u WHERE u.deletedAt < :cutoff") fun findAllWithdrawnBefore( diff --git a/src/main/kotlin/com/didit/application/notification/AdminNoticeEmailService.kt b/src/main/kotlin/com/didit/application/notification/AdminNoticeEmailService.kt index 3f409297..6a4dcea7 100644 --- a/src/main/kotlin/com/didit/application/notification/AdminNoticeEmailService.kt +++ b/src/main/kotlin/com/didit/application/notification/AdminNoticeEmailService.kt @@ -27,10 +27,10 @@ class AdminNoticeEmailService( val users = when (request.targetType) { AdminNoticeEmailTargetType.ALL -> - userRepository.findAllByDeletedAtIsNullAndEmailIsNotNull() + userRepository.findAllMarketingAgreedWithEmail() AdminNoticeEmailTargetType.SELECTED_USERS -> - userRepository.findAllByIdInAndDeletedAtIsNullAndEmailIsNotNull(request.userIds) + userRepository.findAllMarketingAgreedWithEmailByIdIn(request.userIds) } var sentCount = 0 diff --git a/src/main/kotlin/com/didit/application/notification/AdminNoticePushService.kt b/src/main/kotlin/com/didit/application/notification/AdminNoticePushService.kt new file mode 100644 index 00000000..2a127364 --- /dev/null +++ b/src/main/kotlin/com/didit/application/notification/AdminNoticePushService.kt @@ -0,0 +1,86 @@ +package com.didit.application.notification + +import com.didit.application.audit.ActorType +import com.didit.application.audit.AuditAction +import com.didit.application.audit.AuditLogger +import com.didit.application.auth.required.UserRepository +import com.didit.application.notification.provided.AdminNoticePushSender +import com.didit.application.notification.provided.NotificationHistoryRegister +import com.didit.application.notification.provided.UserPushSender +import com.didit.domain.notification.AdminNoticePushSendRequest +import com.didit.domain.notification.AdminNoticePushTargetType +import com.didit.domain.notification.NotificationHistoryCreateRequest +import com.didit.domain.notification.NotificationType +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +@Service +class AdminNoticePushService( + private val userRepository: UserRepository, + private val userPushSender: UserPushSender, + private val notificationHistoryRegister: NotificationHistoryRegister, + private val auditLogger: AuditLogger, +) : AdminNoticePushSender { + companion object { + private val logger = LoggerFactory.getLogger(AdminNoticePushService::class.java) + } + + override fun send(request: AdminNoticePushSendRequest) { + val users = + when (request.targetType) { + AdminNoticePushTargetType.ALL -> + userRepository.findAllMarketingAgreed() + + AdminNoticePushTargetType.SELECTED_USERS -> + userRepository.findAllMarketingAgreedByIdIn(request.userIds) + } + + var sentCount = 0 + var failedCount = 0 + + users.forEach { user -> + runCatching { + userPushSender.sendToUser( + userId = user.id, + title = request.title, + body = request.body, + link = request.link, + ) + notificationHistoryRegister.save( + NotificationHistoryCreateRequest( + userId = user.id, + type = NotificationType.ADMIN_MARKETING, + title = request.title, + body = request.body, + link = request.link, + ), + ) + }.onSuccess { + sentCount++ + }.onFailure { e -> + failedCount++ + logger.warn( + "관리자 마케팅 푸시 발송 실패 - adminId: ${request.adminId}, userId: ${user.id}, reason: ${e.message}", + ) + } + } + + logger.info( + "관리자 마케팅 푸시 발송 완료 - adminId: ${request.adminId}, targetType: ${request.targetType}, " + + "targetCount: ${users.size}, sentCount: $sentCount, failedCount: $failedCount", + ) + + auditLogger.log( + actorId = request.adminId, + actorType = ActorType.ADMIN, + action = AuditAction.ADMIN_NOTIFICATION_SENT, + payload = + mapOf( + "targetType" to request.targetType.name, + "targetCount" to users.size, + "sentCount" to sentCount, + "failedCount" to failedCount, + ), + ) + } +} diff --git a/src/main/kotlin/com/didit/application/notification/provided/AdminNoticePushSender.kt b/src/main/kotlin/com/didit/application/notification/provided/AdminNoticePushSender.kt new file mode 100644 index 00000000..8ac1793a --- /dev/null +++ b/src/main/kotlin/com/didit/application/notification/provided/AdminNoticePushSender.kt @@ -0,0 +1,7 @@ +package com.didit.application.notification.provided + +import com.didit.domain.notification.AdminNoticePushSendRequest + +interface AdminNoticePushSender { + fun send(request: AdminNoticePushSendRequest) +} diff --git a/src/main/kotlin/com/didit/domain/notification/AdminNoticePushSendRequest.kt b/src/main/kotlin/com/didit/domain/notification/AdminNoticePushSendRequest.kt new file mode 100644 index 00000000..4c4330f4 --- /dev/null +++ b/src/main/kotlin/com/didit/domain/notification/AdminNoticePushSendRequest.kt @@ -0,0 +1,25 @@ +package com.didit.domain.notification + +import java.util.UUID + +data class AdminNoticePushSendRequest( + val adminId: UUID, + val targetType: AdminNoticePushTargetType, + val userIds: List, + val title: String, + val body: String, + val link: String, +) { + init { + require(title.isNotBlank()) { "제목은 비어 있을 수 없습니다." } + require(body.isNotBlank()) { "본문은 비어 있을 수 없습니다." } + require(link.isNotBlank()) { "링크는 비어 있을 수 없습니다." } + when (targetType) { + AdminNoticePushTargetType.ALL -> + require(userIds.isEmpty()) { "전체 발송에서 사용자 ID 목록을 지정할 수 없습니다." } + + AdminNoticePushTargetType.SELECTED_USERS -> + require(userIds.isNotEmpty()) { "선택 발송에서는 사용자 ID 목록이 필요합니다." } + } + } +} diff --git a/src/main/kotlin/com/didit/domain/notification/AdminNoticePushTargetType.kt b/src/main/kotlin/com/didit/domain/notification/AdminNoticePushTargetType.kt new file mode 100644 index 00000000..a3ea6db1 --- /dev/null +++ b/src/main/kotlin/com/didit/domain/notification/AdminNoticePushTargetType.kt @@ -0,0 +1,6 @@ +package com.didit.domain.notification + +enum class AdminNoticePushTargetType { + ALL, + SELECTED_USERS, +} diff --git a/src/main/kotlin/com/didit/domain/notification/NotificationType.kt b/src/main/kotlin/com/didit/domain/notification/NotificationType.kt index 4c1a37a5..dda96cc9 100644 --- a/src/main/kotlin/com/didit/domain/notification/NotificationType.kt +++ b/src/main/kotlin/com/didit/domain/notification/NotificationType.kt @@ -4,4 +4,5 @@ enum class NotificationType { DAILY_REMINDER, INQUIRY_ANSWERED, RETROSPECTIVE_RESULT_CREATED, + ADMIN_MARKETING, } diff --git a/src/test/kotlin/com/didit/adapter/webapi/notification/AdminNoticePushApiTest.kt b/src/test/kotlin/com/didit/adapter/webapi/notification/AdminNoticePushApiTest.kt new file mode 100644 index 00000000..c9252f53 --- /dev/null +++ b/src/test/kotlin/com/didit/adapter/webapi/notification/AdminNoticePushApiTest.kt @@ -0,0 +1,70 @@ +package com.didit.adapter.webapi.notification + +import com.didit.application.notification.provided.AdminNoticePushSender +import com.didit.docs.AdminAuthenticatedRestDocsSupport +import com.didit.docs.ApiDocumentUtils +import com.didit.domain.notification.AdminNoticePushSendRequest +import com.didit.domain.notification.AdminNoticePushTargetType +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.springframework.http.MediaType +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document +import org.springframework.restdocs.payload.JsonFieldType +import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath +import org.springframework.restdocs.payload.PayloadDocumentation.requestFields +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.util.UUID + +class AdminNoticePushApiTest : AdminAuthenticatedRestDocsSupport() { + private val adminNoticePushSender: AdminNoticePushSender = mock() + + override fun initController() = AdminNoticePushApi(adminNoticePushSender) + + @Test + fun `send admin marketing push`() { + val userId = UUID.randomUUID() + val request = + mapOf( + "targetType" to "SELECTED_USERS", + "userIds" to listOf(userId), + "title" to "새로운 소식", + "body" to "디딧의 새로운 기능을 확인해 보세요.", + "link" to "/notices/1", + ) + + mockMvc + .perform( + post("/api/v1/admin/notice-pushes") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andExpect(status().isNoContent) + .andDo( + document( + "admin-notice-push/send", + ApiDocumentUtils.getDocumentRequest(), + ApiDocumentUtils.getDocumentResponse(), + requestFields( + fieldWithPath("targetType").type(JsonFieldType.STRING).description("발송 대상 유형"), + fieldWithPath("userIds").type(JsonFieldType.ARRAY).description("선택 사용자 ID 목록"), + fieldWithPath("title").type(JsonFieldType.STRING).description("푸시 알림 제목"), + fieldWithPath("body").type(JsonFieldType.STRING).description("푸시 알림 본문"), + fieldWithPath("link").type(JsonFieldType.STRING).description("알림 클릭 시 이동할 링크"), + ), + ), + ) + + val captor = argumentCaptor() + verify(adminNoticePushSender).send(captor.capture()) + + assertThat(captor.firstValue.adminId).isEqualTo(adminId) + assertThat(captor.firstValue.targetType).isEqualTo(AdminNoticePushTargetType.SELECTED_USERS) + assertThat(captor.firstValue.userIds).containsExactly(userId) + assertThat(captor.firstValue.title).isEqualTo("새로운 소식") + assertThat(captor.firstValue.body).isEqualTo("디딧의 새로운 기능을 확인해 보세요.") + assertThat(captor.firstValue.link).isEqualTo("/notices/1") + } +} diff --git a/src/test/kotlin/com/didit/application/auth/required/UserRepositoryTest.kt b/src/test/kotlin/com/didit/application/auth/required/UserRepositoryTest.kt index 3d1ce4d2..274b1e40 100644 --- a/src/test/kotlin/com/didit/application/auth/required/UserRepositoryTest.kt +++ b/src/test/kotlin/com/didit/application/auth/required/UserRepositoryTest.kt @@ -178,27 +178,165 @@ class UserRepositoryTest : RepositoryTestSupport() { } @Test - fun `findAllByDeletedAtIsNullAndEmailIsNotNull - no withdraw and exist email`() { - val activeWithEmail = userRepository.save(UserFixture.create(providerId = "kakao-1", email = "a@test.com")) - userRepository.save(UserFixture.create(providerId = "kakao-2", email = null)) - userRepository.save(UserFixture.create(providerId = "kakao-3", email = "deleted@test.com").apply { withdraw() }) + fun `findAllMarketingAgreedWithEmail - returns only active marketing agreed users with email`() { + val marketingAgreed = + userRepository.save( + UserFixture.createOnboardedWithConsent( + providerId = "kakao-1", + email = "agreed@test.com", + marketingAgreed = true, + ), + ) + userRepository.save( + UserFixture.createOnboardedWithConsent( + providerId = "kakao-2", + email = "disagreed@test.com", + marketingAgreed = false, + ), + ) + userRepository.save( + UserFixture.createOnboardedWithConsent( + providerId = "kakao-3", + email = null, + marketingAgreed = true, + ), + ) + userRepository.save( + UserFixture + .createOnboardedWithConsent( + providerId = "kakao-4", + email = "deleted@test.com", + marketingAgreed = true, + ).apply { withdraw() }, + ) + userRepository.save(UserFixture.create(providerId = "kakao-5", email = "no-consent@test.com")) + + val result = userRepository.findAllMarketingAgreedWithEmail() + + assertThat(result).extracting("id").containsExactly(marketingAgreed.id) + } + + @Test + fun `findAllMarketingAgreedWithEmailByIdIn - returns only selected active marketing agreed users with email`() { + val selected = + userRepository.save( + UserFixture.createOnboardedWithConsent( + providerId = "kakao-1", + email = "agreed@test.com", + marketingAgreed = true, + ), + ) + val disagreed = + userRepository.save( + UserFixture.createOnboardedWithConsent( + providerId = "kakao-2", + email = "disagreed@test.com", + marketingAgreed = false, + ), + ) + val noEmail = + userRepository.save( + UserFixture.createOnboardedWithConsent( + providerId = "kakao-3", + email = null, + marketingAgreed = true, + ), + ) + val deleted = + userRepository.save( + UserFixture + .createOnboardedWithConsent( + providerId = "kakao-4", + email = "deleted@test.com", + marketingAgreed = true, + ).apply { withdraw() }, + ) + val noConsent = userRepository.save(UserFixture.create(providerId = "kakao-5", email = "no-consent@test.com")) - val result = userRepository.findAllByDeletedAtIsNullAndEmailIsNotNull() + val result = + userRepository.findAllMarketingAgreedWithEmailByIdIn( + listOf(selected.id, disagreed.id, noEmail.id, deleted.id, noConsent.id), + ) - assertThat(result).extracting("id").containsExactly(activeWithEmail.id) + assertThat(result).extracting("id").containsExactly(selected.id) } @Test - fun `findAllByIdInAndDeletedAtIsNullAndEmailIsNotNull - select only active email users`() { - val selected = userRepository.save(UserFixture.create(providerId = "kakao-1", email = "a@test.com")) - val noEmail = userRepository.save(UserFixture.create(providerId = "kakao-2", email = null)) - val deleted = userRepository.save(UserFixture.create(providerId = "kakao-3", email = "deleted@test.com").apply { withdraw() }) + fun `findAllMarketingAgreed - returns active marketing agreed users regardless of email`() { + val withEmail = + userRepository.save( + UserFixture.createOnboardedWithConsent( + providerId = "kakao-push-1", + email = "agreed@test.com", + marketingAgreed = true, + ), + ) + val withoutEmail = + userRepository.save( + UserFixture.createOnboardedWithConsent( + providerId = "kakao-push-2", + email = null, + marketingAgreed = true, + ), + ) + userRepository.save( + UserFixture.createOnboardedWithConsent( + providerId = "kakao-push-3", + marketingAgreed = false, + ), + ) + userRepository.save( + UserFixture + .createOnboardedWithConsent( + providerId = "kakao-push-4", + marketingAgreed = true, + ).apply { withdraw() }, + ) + userRepository.save(UserFixture.create(providerId = "kakao-push-5")) + + val result = userRepository.findAllMarketingAgreed() + + assertThat(result).extracting("id").containsExactlyInAnyOrder(withEmail.id, withoutEmail.id) + } + + @Test + fun `findAllMarketingAgreedByIdIn - returns only selected active marketing agreed users`() { + val selected = + userRepository.save( + UserFixture.createOnboardedWithConsent( + providerId = "kakao-push-1", + marketingAgreed = true, + ), + ) + val notSelected = + userRepository.save( + UserFixture.createOnboardedWithConsent( + providerId = "kakao-push-2", + marketingAgreed = true, + ), + ) + val disagreed = + userRepository.save( + UserFixture.createOnboardedWithConsent( + providerId = "kakao-push-3", + marketingAgreed = false, + ), + ) + val deleted = + userRepository.save( + UserFixture + .createOnboardedWithConsent( + providerId = "kakao-push-4", + marketingAgreed = true, + ).apply { withdraw() }, + ) val result = - userRepository.findAllByIdInAndDeletedAtIsNullAndEmailIsNotNull( - listOf(selected.id, noEmail.id, deleted.id), + userRepository.findAllMarketingAgreedByIdIn( + listOf(selected.id, disagreed.id, deleted.id), ) assertThat(result).extracting("id").containsExactly(selected.id) + assertThat(result).extracting("id").doesNotContain(notSelected.id) } } diff --git a/src/test/kotlin/com/didit/application/notification/AdminNoticeEmailServiceTest.kt b/src/test/kotlin/com/didit/application/notification/AdminNoticeEmailServiceTest.kt index 0b91dcba..c9f6b907 100644 --- a/src/test/kotlin/com/didit/application/notification/AdminNoticeEmailServiceTest.kt +++ b/src/test/kotlin/com/didit/application/notification/AdminNoticeEmailServiceTest.kt @@ -36,7 +36,7 @@ class AdminNoticeEmailServiceTest { val user1 = UserFixture.create(email = "a@test.com") val user2 = UserFixture.create(providerId = "kakao-2", email = "b@test.com") - whenever(userRepository.findAllByDeletedAtIsNullAndEmailIsNotNull()) + whenever(userRepository.findAllMarketingAgreedWithEmail()) .thenReturn(listOf(user1, user2)) service.send( @@ -59,7 +59,7 @@ class AdminNoticeEmailServiceTest { val userId = UUID.randomUUID() val user = UserFixture.create(email = "selected@test.com") - whenever(userRepository.findAllByIdInAndDeletedAtIsNullAndEmailIsNotNull(listOf(userId))) + whenever(userRepository.findAllMarketingAgreedWithEmailByIdIn(listOf(userId))) .thenReturn(listOf(user)) service.send( @@ -81,7 +81,7 @@ class AdminNoticeEmailServiceTest { val user1 = UserFixture.create(email = "fail@test.com") val user2 = UserFixture.create(providerId = "kakao-2", email = "success@test.com") - whenever(userRepository.findAllByDeletedAtIsNullAndEmailIsNotNull()) + whenever(userRepository.findAllMarketingAgreedWithEmail()) .thenReturn(listOf(user1, user2)) doThrow(RuntimeException("smtp error")) diff --git a/src/test/kotlin/com/didit/application/notification/AdminNoticePushServiceTest.kt b/src/test/kotlin/com/didit/application/notification/AdminNoticePushServiceTest.kt new file mode 100644 index 00000000..56ffa7e3 --- /dev/null +++ b/src/test/kotlin/com/didit/application/notification/AdminNoticePushServiceTest.kt @@ -0,0 +1,137 @@ +package com.didit.application.notification + +import com.didit.application.audit.ActorType +import com.didit.application.audit.AuditAction +import com.didit.application.audit.AuditLogger +import com.didit.application.auth.required.UserRepository +import com.didit.application.notification.provided.NotificationHistoryRegister +import com.didit.application.notification.provided.UserPushSender +import com.didit.domain.notification.AdminNoticePushSendRequest +import com.didit.domain.notification.AdminNoticePushTargetType +import com.didit.domain.notification.NotificationHistoryCreateRequest +import com.didit.domain.notification.NotificationType +import com.didit.support.UserFixture +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.mockito.kotlin.doThrow +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.UUID + +@ExtendWith(MockitoExtension::class) +class AdminNoticePushServiceTest { + @Mock + lateinit var userRepository: UserRepository + + @Mock + lateinit var userPushSender: UserPushSender + + @Mock + lateinit var notificationHistoryRegister: NotificationHistoryRegister + + @Mock + lateinit var auditLogger: AuditLogger + + @InjectMocks + lateinit var service: AdminNoticePushService + + @Test + fun `send to all marketing agreed users and save histories`() { + val user1 = UserFixture.create(providerId = "kakao-1") + val user2 = UserFixture.create(providerId = "kakao-2") + whenever(userRepository.findAllMarketingAgreed()).thenReturn(listOf(user1, user2)) + val request = createRequest() + + service.send(request) + + listOf(user1, user2).forEach { user -> + verify(userPushSender).sendToUser(user.id, "마케팅 제목", "마케팅 본문", "/notices/1") + verify(notificationHistoryRegister).save( + NotificationHistoryCreateRequest( + userId = user.id, + type = NotificationType.ADMIN_MARKETING, + title = "마케팅 제목", + body = "마케팅 본문", + link = "/notices/1", + ), + ) + } + verify(auditLogger).log( + request.adminId, + ActorType.ADMIN, + AuditAction.ADMIN_NOTIFICATION_SENT, + null, + null, + mapOf( + "targetType" to AdminNoticePushTargetType.ALL.name, + "targetCount" to 2, + "sentCount" to 2, + "failedCount" to 0, + ), + ) + } + + @Test + fun `send only selected marketing agreed users`() { + val userId = UUID.randomUUID() + val user = UserFixture.create() + whenever(userRepository.findAllMarketingAgreedByIdIn(listOf(userId))).thenReturn(listOf(user)) + + service.send( + createRequest( + targetType = AdminNoticePushTargetType.SELECTED_USERS, + userIds = listOf(userId), + ), + ) + + verify(userPushSender).sendToUser(user.id, "마케팅 제목", "마케팅 본문", "/notices/1") + } + + @Test + fun `continue sending when one user fails and do not save failed history`() { + val failedUser = UserFixture.create(providerId = "kakao-1") + val successUser = UserFixture.create(providerId = "kakao-2") + whenever(userRepository.findAllMarketingAgreed()).thenReturn(listOf(failedUser, successUser)) + doThrow(RuntimeException("fcm error")) + .whenever(userPushSender) + .sendToUser(failedUser.id, "마케팅 제목", "마케팅 본문", "/notices/1") + + service.send(createRequest()) + + verify(userPushSender).sendToUser(successUser.id, "마케팅 제목", "마케팅 본문", "/notices/1") + verify(notificationHistoryRegister, never()).save( + NotificationHistoryCreateRequest( + userId = failedUser.id, + type = NotificationType.ADMIN_MARKETING, + title = "마케팅 제목", + body = "마케팅 본문", + link = "/notices/1", + ), + ) + verify(notificationHistoryRegister).save( + NotificationHistoryCreateRequest( + userId = successUser.id, + type = NotificationType.ADMIN_MARKETING, + title = "마케팅 제목", + body = "마케팅 본문", + link = "/notices/1", + ), + ) + } + + private fun createRequest( + targetType: AdminNoticePushTargetType = AdminNoticePushTargetType.ALL, + userIds: List = emptyList(), + ) = AdminNoticePushSendRequest( + adminId = UUID.randomUUID(), + targetType = targetType, + userIds = userIds, + title = "마케팅 제목", + body = "마케팅 본문", + link = "/notices/1", + ) +} diff --git a/src/test/kotlin/com/didit/domain/notification/AdminNoticePushTest.kt b/src/test/kotlin/com/didit/domain/notification/AdminNoticePushTest.kt new file mode 100644 index 00000000..6e9f2d08 --- /dev/null +++ b/src/test/kotlin/com/didit/domain/notification/AdminNoticePushTest.kt @@ -0,0 +1,71 @@ +package com.didit.domain.notification + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatIllegalArgumentException +import org.junit.jupiter.api.Test +import java.util.UUID + +class AdminNoticePushTest { + @Test + fun `success - create all target request`() { + val request = + AdminNoticePushSendRequest( + adminId = UUID.randomUUID(), + targetType = AdminNoticePushTargetType.ALL, + userIds = emptyList(), + title = "새로운 소식", + body = "디딧의 새로운 기능을 확인해 보세요.", + link = "/notices/1", + ) + + assertThat(request.targetType).isEqualTo(AdminNoticePushTargetType.ALL) + } + + @Test + fun `fail - all target request has user ids`() { + assertThatIllegalArgumentException().isThrownBy { + AdminNoticePushSendRequest( + adminId = UUID.randomUUID(), + targetType = AdminNoticePushTargetType.ALL, + userIds = listOf(UUID.randomUUID()), + title = "제목", + body = "본문", + link = "/notices/1", + ) + } + } + + @Test + fun `fail - selected target request has no user ids`() { + assertThatIllegalArgumentException().isThrownBy { + AdminNoticePushSendRequest( + adminId = UUID.randomUUID(), + targetType = AdminNoticePushTargetType.SELECTED_USERS, + userIds = emptyList(), + title = "제목", + body = "본문", + link = "/notices/1", + ) + } + } + + @Test + fun `fail - title body or link is blank`() { + listOf( + Triple("", "본문", "/notices/1"), + Triple("제목", "", "/notices/1"), + Triple("제목", "본문", ""), + ).forEach { (title, body, link) -> + assertThatIllegalArgumentException().isThrownBy { + AdminNoticePushSendRequest( + adminId = UUID.randomUUID(), + targetType = AdminNoticePushTargetType.ALL, + userIds = emptyList(), + title = title, + body = body, + link = link, + ) + } + } + } +}