diff --git a/build.gradle b/build.gradle index ff3abae8..dc638465 100644 --- a/build.gradle +++ b/build.gradle @@ -60,6 +60,8 @@ dependencies { implementation 'com.amazonaws:aws-java-sdk-s3:1.12.529' + implementation 'com.google.firebase:firebase-admin:9.4.3' + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/docs/asciidoc/admin/index.adoc b/src/docs/asciidoc/admin/index.adoc index 603fd3f4..c31755fd 100644 --- a/src/docs/asciidoc/admin/index.adoc +++ b/src/docs/asciidoc/admin/index.adoc @@ -16,3 +16,4 @@ include::auth.adoc[] include::management.adoc[] include::location.adoc[] include::notice.adoc[] +include::push.adoc[] diff --git a/src/docs/asciidoc/admin/push.adoc b/src/docs/asciidoc/admin/push.adoc new file mode 100644 index 00000000..a608e462 --- /dev/null +++ b/src/docs/asciidoc/admin/push.adoc @@ -0,0 +1,22 @@ +[[admin-push-api]] +== 푸시 브로드캐스트 관리 + +[[admin-push-broadcast]] +=== 전체 기기 푸시 발송 + +관리자가 입력한 제목·본문으로 활성 FCM 토큰 전체에 푸시를 발송합니다. + +* ADMIN 권한이 필요합니다. +* 발송 결과(대상/성공/실패 수)는 이력으로 저장됩니다. +* Firebase가 설정되지 않은 경우 전송하지 않고 대상 기기 수만 반환합니다. + +operation::admin/push/broadcast[snippets='http-request,request-fields,http-response,response-fields'] + +[[admin-push-broadcast-history]] +=== 푸시 발송 이력 조회 + +푸시 브로드캐스트 발송 이력을 최신순으로 페이지 조회합니다. + +* VIEWER 이상 권한이 필요합니다. + +operation::admin/push/broadcast-history[snippets='http-request,query-parameters,http-response,response-fields'] diff --git a/src/docs/asciidoc/app/index.adoc b/src/docs/asciidoc/app/index.adoc index bb5a6ec2..7393a93d 100644 --- a/src/docs/asciidoc/app/index.adoc +++ b/src/docs/asciidoc/app/index.adoc @@ -24,3 +24,4 @@ include::location.adoc[] include::geocoding.adoc[] include::search.adoc[] include::notice.adoc[] +include::notification.adoc[] diff --git a/src/docs/asciidoc/app/notification.adoc b/src/docs/asciidoc/app/notification.adoc new file mode 100644 index 00000000..db1243ec --- /dev/null +++ b/src/docs/asciidoc/app/notification.adoc @@ -0,0 +1,21 @@ +[[notification-api]] +== FCM 토큰 API + +[[fcm-token-register]] +=== FCM 토큰 등록 + +로그인 사용자의 FCM 디바이스 토큰을 등록하거나 갱신합니다. + +* 인증이 필요합니다. +* 동일 토큰 또는 동일 디바이스가 있으면 갱신하고, 없으면 신규 등록합니다. + +operation::fcm-token/register[snippets='http-request,request-fields,http-response,response-fields'] + +[[fcm-token-deactivate]] +=== FCM 토큰 비활성화 + +로그아웃 등으로 해당 디바이스의 FCM 토큰을 비활성화합니다. + +* 인증이 필요합니다. + +operation::fcm-token/deactivate[snippets='http-request,query-parameters,http-response,response-fields'] diff --git a/src/main/java/com/souzip/adapter/webapi/admin/AdminPushApi.java b/src/main/java/com/souzip/adapter/webapi/admin/AdminPushApi.java new file mode 100644 index 00000000..b3e42074 --- /dev/null +++ b/src/main/java/com/souzip/adapter/webapi/admin/AdminPushApi.java @@ -0,0 +1,75 @@ +package com.souzip.adapter.webapi.admin; + +import com.souzip.adapter.webapi.admin.dto.PushBroadcastRequest; +import com.souzip.application.notification.FcmNotificationService; +import com.souzip.application.notification.PushBroadcastHistoryCommandService; +import com.souzip.application.notification.PushBroadcastHistoryQueryService; +import com.souzip.application.notification.dto.PushBroadcastHistoryResponse; +import com.souzip.application.notification.dto.PushBroadcastResult; +import com.souzip.domain.admin.infrastructure.security.annotation.AdminAccess; +import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId; +import com.souzip.domain.admin.infrastructure.security.annotation.ViewerAccess; +import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.shared.common.dto.pagination.PaginationRequest; +import com.souzip.shared.common.dto.pagination.PaginationResponse; +import jakarta.validation.Valid; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +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.RestController; + +@RequestMapping("/api/admin/push") +@RequiredArgsConstructor +@RestController +public class AdminPushApi { + + private final FcmNotificationService fcmNotificationService; + private final PushBroadcastHistoryCommandService pushBroadcastHistoryCommandService; + private final PushBroadcastHistoryQueryService pushBroadcastHistoryQueryService; + + // 관리자가 입력한 제목·본문으로 활성 FCM 토큰 전체에 푸시를 발송합니다. + @AdminAccess + @PostMapping("/broadcast") + public SuccessResponse broadcast( + @CurrentAdminId UUID adminId, + @Valid @RequestBody PushBroadcastRequest request + ) { + PushBroadcastResult result = fcmNotificationService.broadcastToAllActiveTokens( + request.title(), + request.body() + ); + pushBroadcastHistoryCommandService.record(adminId, request.title(), request.body(), result); + String message = buildMessage(result); + return SuccessResponse.of(result, message); + } + + // 푸시 브로드캐스트 발송 이력을 최신순으로 페이지 조회합니다. + @ViewerAccess + @GetMapping("/broadcast/history") + public SuccessResponse> broadcastHistory( + @ModelAttribute PaginationRequest paginationRequest + ) { + PaginationResponse page = + pushBroadcastHistoryQueryService.findPage(paginationRequest); + return SuccessResponse.of(page); + } + + private static String buildMessage(PushBroadcastResult result) { + if (result.totalTargets() == 0) { + return "등록된 활성 기기가 없어 전송하지 않았습니다."; + } + if (!result.firebaseConfigured()) { + return "Firebase가 설정되지 않아 전송하지 않았습니다. (대상 기기 " + result.totalTargets() + "건)"; + } + return String.format( + "푸시 발송 완료. 대상 %d건, 성공 %d건, 실패 %d건", + result.totalTargets(), + result.successCount(), + result.failCount() + ); + } +} diff --git a/src/main/java/com/souzip/adapter/webapi/admin/dto/PushBroadcastRequest.java b/src/main/java/com/souzip/adapter/webapi/admin/dto/PushBroadcastRequest.java new file mode 100644 index 00000000..cc63fc20 --- /dev/null +++ b/src/main/java/com/souzip/adapter/webapi/admin/dto/PushBroadcastRequest.java @@ -0,0 +1,19 @@ +package com.souzip.adapter.webapi.admin.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PushBroadcastRequest( + @NotBlank(message = "제목은 필수입니다.") + @Size(max = 200, message = "제목은 200자를 초과할 수 없습니다.") + String title, + + @NotBlank(message = "내용은 필수입니다.") + @Size(max = 1000, message = "내용은 1000자를 초과할 수 없습니다.") + String body +) { + public PushBroadcastRequest { + title = title != null ? title.trim() : null; + body = body != null ? body.trim() : null; + } +} diff --git a/src/main/java/com/souzip/application/notification/FcmNotificationService.java b/src/main/java/com/souzip/application/notification/FcmNotificationService.java new file mode 100644 index 00000000..b27f1425 --- /dev/null +++ b/src/main/java/com/souzip/application/notification/FcmNotificationService.java @@ -0,0 +1,145 @@ +package com.souzip.application.notification; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import com.souzip.application.notification.dto.PushBroadcastResult; +import com.souzip.application.notification.provided.FcmTokenFinder; +import com.souzip.domain.notification.FcmToken; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FcmNotificationService { + + private final FcmTokenFinder fcmTokenFinder; + private final FcmTokenCommandService fcmTokenCommandService; + private final ObjectProvider firebaseMessaging; + + // 단일 기기 토큰으로 알림(제목·본문)을 전송합니다. + public void sendToToken(String registrationToken, String title, String body) { + sendToToken(registrationToken, title, body, Map.of()); + } + + // 데이터 페이로드를 포함해 전송합니다. + public void sendToToken(String registrationToken, String title, String body, Map data) { + FirebaseMessaging messaging = firebaseMessaging.getIfAvailable(); + if (messaging == null) { + log.warn("FirebaseMessaging 빈이 없습니다. firebase.enabled 와 credentials 를 확인하세요. 전송을 건너뜁니다."); + return; + } + try { + Notification notification = Notification.builder() + .setTitle(title) + .setBody(body) + .build(); + Message.Builder messageBuilder = Message.builder() + .setToken(registrationToken) + .setNotification(notification); + if (data != null && !data.isEmpty()) { + messageBuilder.putAllData(new HashMap<>(data)); + } + messaging.send(messageBuilder.build()); + } catch (FirebaseMessagingException e) { + log.error("FCM 전송 실패 tokenPrefix={} error={}", maskToken(registrationToken), e.getMessagingErrorCode(), e); + // 예외 메시지에 원본 토큰 등 민감 정보가 섞일 수 있어 그대로 전파하지 않고, 오류 코드만 보존합니다. + throw new FcmSendException(e.getMessagingErrorCode()); + } + } + + // DB 조회는 FcmTokenFinder(짧은 readOnly 트랜잭션)에서만 하고, FCM 전송은 트랜잭션 밖에서 수행합니다. + public void sendToUser(Long userId, String title, String body) { + List tokens = fcmTokenFinder.getActiveTokensByUserId(userId); + if (tokens.isEmpty()) { + return; + } + int successCount = 0; + int failCount = 0; + List staleTokenIds = new ArrayList<>(); + for (FcmToken token : tokens) { + try { + sendToToken(token.getToken(), title, body); + successCount++; + } catch (FcmSendException e) { + failCount++; + if (e.isPermanentFailure()) { + staleTokenIds.add(token.getId()); + } + log.warn( + "FCM 전송 실패(다음 토큰으로 계속) userId={}, fcmTokenId={}, errorCode={}", + userId, + token.getId(), + e.getMessagingErrorCode() + ); + } + } + deactivateStaleTokens(staleTokenIds); + if (failCount > 0 && successCount == 0) { + throw new BusinessException(ErrorCode.FCM_SEND_FAILED, "활성 토큰 전송이 모두 실패했습니다."); + } + if (failCount > 0) { + log.warn("FCM 일부 토큰 실패 userId={}, 성공={}, 실패={}", userId, successCount, failCount); + } + } + + // DB 조회는 FcmTokenFinder(짧은 readOnly 트랜잭션)에서만 하고, FCM 전송 루프는 트랜잭션 밖에서 수행합니다. + public PushBroadcastResult broadcastToAllActiveTokens(String title, String body) { + List tokens = fcmTokenFinder.getAllActiveTokens(); + if (tokens.isEmpty()) { + return new PushBroadcastResult(0, 0, 0, true); + } + FirebaseMessaging messaging = firebaseMessaging.getIfAvailable(); + if (messaging == null) { + log.warn("FirebaseMessaging 빈이 없습니다. 브로드캐스트를 건너뜁니다. 대상 기기 수={}", tokens.size()); + return new PushBroadcastResult(tokens.size(), 0, 0, false); + } + int successCount = 0; + int failCount = 0; + List staleTokenIds = new ArrayList<>(); + for (FcmToken token : tokens) { + try { + sendToToken(token.getToken(), title, body); + successCount++; + } catch (FcmSendException e) { + failCount++; + if (e.isPermanentFailure()) { + staleTokenIds.add(token.getId()); + } + log.warn( + "FCM 브로드캐스트 실패(다음 토큰으로 계속) fcmTokenId={}, errorCode={}", + token.getId(), + e.getMessagingErrorCode() + ); + } + } + deactivateStaleTokens(staleTokenIds); + return new PushBroadcastResult(tokens.size(), successCount, failCount, true); + } + + // 영구 실패(UNREGISTERED 등) 토큰은 이후 전송 대상에서 제외되도록 비활성화합니다. + private void deactivateStaleTokens(List staleTokenIds) { + if (staleTokenIds.isEmpty()) { + return; + } + fcmTokenCommandService.deactivateByIds(staleTokenIds); + log.info("영구 실패로 비활성화한 FCM 토큰 수={}", staleTokenIds.size()); + } + + private static String maskToken(String token) { + if (token == null || token.length() < 12) { + return "***"; + } + return token.substring(0, 6) + "..." + token.substring(token.length() - 4); + } +} diff --git a/src/main/java/com/souzip/application/notification/FcmSendException.java b/src/main/java/com/souzip/application/notification/FcmSendException.java new file mode 100644 index 00000000..28a09cb1 --- /dev/null +++ b/src/main/java/com/souzip/application/notification/FcmSendException.java @@ -0,0 +1,28 @@ +package com.souzip.application.notification; + +import com.google.firebase.messaging.MessagingErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; + +// FCM 전송 실패를 나타내며, 영구 실패 여부 판별을 위해 MessagingErrorCode 를 보존합니다. +// (예외 메시지에는 토큰 등 민감 정보를 담지 않습니다.) +public class FcmSendException extends BusinessException { + + private final transient MessagingErrorCode messagingErrorCode; + + public FcmSendException(MessagingErrorCode messagingErrorCode) { + super(ErrorCode.FCM_SEND_FAILED); + this.messagingErrorCode = messagingErrorCode; + } + + public MessagingErrorCode getMessagingErrorCode() { + return messagingErrorCode; + } + + // 토큰이 더 이상 유효하지 않아 재전송해도 계속 실패하는 영구 오류인지 여부. + public boolean isPermanentFailure() { + return messagingErrorCode == MessagingErrorCode.UNREGISTERED + || messagingErrorCode == MessagingErrorCode.INVALID_ARGUMENT + || messagingErrorCode == MessagingErrorCode.SENDER_ID_MISMATCH; + } +} diff --git a/src/main/java/com/souzip/application/notification/FcmTokenCommandService.java b/src/main/java/com/souzip/application/notification/FcmTokenCommandService.java new file mode 100644 index 00000000..273a03d3 --- /dev/null +++ b/src/main/java/com/souzip/application/notification/FcmTokenCommandService.java @@ -0,0 +1,80 @@ +package com.souzip.application.notification; + +import com.souzip.application.notification.required.FcmTokenRepository; +import com.souzip.domain.notification.FcmToken; +import com.souzip.domain.notification.FcmTokenRegisterRequest; +import java.util.Collection; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class FcmTokenCommandService { + + private final FcmTokenRepository fcmTokenRepository; + + // 로그인 사용자 기준으로 FCM 토큰을 등록하거나 동일 디바이스·토큰에 맞춰 갱신합니다. + public FcmToken registerOrUpdate(Long userId, FcmTokenRegisterRequest request) { + return fcmTokenRepository.findByToken(request.fcmToken()) + .map(existing -> syncExistingToken(existing, userId, request)) + .orElseGet(() -> fcmTokenRepository.findByUserIdAndDeviceId(userId, request.deviceId()) + .map(existing -> updateExistingDeviceRow(existing, request, userId)) + .orElseGet(() -> createNew(userId, request))); + } + + private FcmToken syncExistingToken(FcmToken existing, Long userId, FcmTokenRegisterRequest request) { + existing.linkUser(userId); + existing.syncDeviceIdentity(request.deviceType(), request.deviceId()); + existing.updateDeviceInfo(request.deviceModel(), request.osVersion(), request.appVersion()); + existing.activate(); + return fcmTokenRepository.save(existing); + } + + private FcmToken updateExistingDeviceRow(FcmToken existing, FcmTokenRegisterRequest request, Long userId) { + existing.updateToken(request.fcmToken()); + existing.syncDeviceIdentity(request.deviceType(), request.deviceId()); + existing.updateDeviceInfo(request.deviceModel(), request.osVersion(), request.appVersion()); + existing.linkUser(userId); + existing.activate(); + return fcmTokenRepository.save(existing); + } + + private FcmToken createNew(Long userId, FcmTokenRegisterRequest request) { + FcmToken created = FcmToken.register(request); + created.linkUser(userId); + return fcmTokenRepository.save(created); + } + + // 해당 디바이스의 푸시 토큰을 비활성화합니다. + public void deactivateByDevice(Long userId, String deviceId) { + fcmTokenRepository.findByUserIdAndDeviceId(userId, deviceId) + .ifPresent(token -> { + token.deactivate(); + fcmTokenRepository.save(token); + }); + } + + // 회원 탈퇴 시 해당 사용자의 모든 활성 FCM 토큰을 비활성화합니다. + public void deactivateAllByUserId(Long userId) { + List tokens = fcmTokenRepository.findByUserIdAndActiveTrue(userId); + tokens.forEach(token -> { + token.deactivate(); + fcmTokenRepository.save(token); + }); + } + + // 영구 실패(UNREGISTERED 등)로 더 이상 유효하지 않은 토큰들을 일괄 비활성화합니다. + public void deactivateByIds(Collection ids) { + if (ids.isEmpty()) { + return; + } + List tokens = fcmTokenRepository.findAllByIdIn(ids); + tokens.forEach(token -> { + token.deactivate(); + fcmTokenRepository.save(token); + }); + } +} diff --git a/src/main/java/com/souzip/application/notification/FcmTokenQueryService.java b/src/main/java/com/souzip/application/notification/FcmTokenQueryService.java new file mode 100644 index 00000000..c9bd7003 --- /dev/null +++ b/src/main/java/com/souzip/application/notification/FcmTokenQueryService.java @@ -0,0 +1,41 @@ +package com.souzip.application.notification; + +import com.souzip.application.notification.provided.FcmTokenFinder; +import com.souzip.application.notification.required.FcmTokenRepository; +import com.souzip.domain.notification.FcmToken; +import com.souzip.domain.notification.FcmTokenNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class FcmTokenQueryService implements FcmTokenFinder { + + private final FcmTokenRepository fcmTokenRepository; + + @Override + public FcmToken getByDeviceId(String deviceId) { + return fcmTokenRepository.findByDeviceId(deviceId) + .orElseThrow(() -> FcmTokenNotFoundException.byDeviceId(deviceId)); + } + + @Override + public FcmToken getByToken(String token) { + return fcmTokenRepository.findByToken(token) + .orElseThrow(() -> FcmTokenNotFoundException.byToken(token)); + } + + @Override + public List getActiveTokensByUserId(Long userId) { + return fcmTokenRepository.findByUserIdAndActiveTrue(userId); + } + + @Override + public List getAllActiveTokens() { + return fcmTokenRepository.findAllByActiveTrue(); + } +} diff --git a/src/main/java/com/souzip/application/notification/PushBroadcastHistoryCommandService.java b/src/main/java/com/souzip/application/notification/PushBroadcastHistoryCommandService.java new file mode 100644 index 00000000..49e02368 --- /dev/null +++ b/src/main/java/com/souzip/application/notification/PushBroadcastHistoryCommandService.java @@ -0,0 +1,31 @@ +package com.souzip.application.notification; + +import com.souzip.application.notification.dto.PushBroadcastResult; +import com.souzip.application.notification.required.PushBroadcastHistoryRepository; +import com.souzip.domain.notification.PushBroadcastHistory; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PushBroadcastHistoryCommandService { + + private final PushBroadcastHistoryRepository pushBroadcastHistoryRepository; + + // 관리자 푸시 브로드캐스트 결과를 이력으로 저장합니다. + @Transactional + public void record(UUID adminId, String title, String body, PushBroadcastResult result) { + PushBroadcastHistory row = PushBroadcastHistory.record( + adminId, + title, + body, + result.totalTargets(), + result.successCount(), + result.failCount(), + result.firebaseConfigured() + ); + pushBroadcastHistoryRepository.save(row); + } +} diff --git a/src/main/java/com/souzip/application/notification/PushBroadcastHistoryQueryService.java b/src/main/java/com/souzip/application/notification/PushBroadcastHistoryQueryService.java new file mode 100644 index 00000000..b46dbcf4 --- /dev/null +++ b/src/main/java/com/souzip/application/notification/PushBroadcastHistoryQueryService.java @@ -0,0 +1,30 @@ +package com.souzip.application.notification; + +import com.souzip.application.notification.dto.PushBroadcastHistoryResponse; +import com.souzip.application.notification.required.PushBroadcastHistoryRepository; +import com.souzip.domain.notification.PushBroadcastHistory; +import com.souzip.shared.common.dto.pagination.PaginationRequest; +import com.souzip.shared.common.dto.pagination.PaginationResponse; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PushBroadcastHistoryQueryService { + + private final PushBroadcastHistoryRepository pushBroadcastHistoryRepository; + + // 푸시 브로드캐스트 이력을 최신순 페이지로 조회합니다. + public PaginationResponse findPage(PaginationRequest paginationRequest) { + Page page = + pushBroadcastHistoryRepository.findAllByOrderByCreatedAtDesc(paginationRequest.toPageable()); + List content = page.getContent().stream() + .map(PushBroadcastHistoryResponse::from) + .toList(); + return PaginationResponse.of(page, content); + } +} diff --git a/src/main/java/com/souzip/application/notification/dto/PushBroadcastHistoryResponse.java b/src/main/java/com/souzip/application/notification/dto/PushBroadcastHistoryResponse.java new file mode 100644 index 00000000..958536ac --- /dev/null +++ b/src/main/java/com/souzip/application/notification/dto/PushBroadcastHistoryResponse.java @@ -0,0 +1,32 @@ +package com.souzip.application.notification.dto; + +import com.souzip.domain.notification.PushBroadcastHistory; +import java.time.LocalDateTime; +import java.util.UUID; + +public record PushBroadcastHistoryResponse( + Long id, + UUID adminId, + String title, + String body, + int totalTargets, + int successCount, + int failCount, + boolean firebaseConfigured, + LocalDateTime createdAt +) { + + public static PushBroadcastHistoryResponse from(PushBroadcastHistory entity) { + return new PushBroadcastHistoryResponse( + entity.getId(), + entity.getAdminId(), + entity.getTitle(), + entity.getBody(), + entity.getTotalTargets(), + entity.getSuccessCount(), + entity.getFailCount(), + entity.isFirebaseConfigured(), + entity.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/souzip/application/notification/dto/PushBroadcastResult.java b/src/main/java/com/souzip/application/notification/dto/PushBroadcastResult.java new file mode 100644 index 00000000..94c01244 --- /dev/null +++ b/src/main/java/com/souzip/application/notification/dto/PushBroadcastResult.java @@ -0,0 +1,9 @@ +package com.souzip.application.notification.dto; + +public record PushBroadcastResult( + int totalTargets, + int successCount, + int failCount, + boolean firebaseConfigured +) { +} diff --git a/src/main/java/com/souzip/application/notification/provided/FcmTokenFinder.java b/src/main/java/com/souzip/application/notification/provided/FcmTokenFinder.java new file mode 100644 index 00000000..171d314a --- /dev/null +++ b/src/main/java/com/souzip/application/notification/provided/FcmTokenFinder.java @@ -0,0 +1,15 @@ +package com.souzip.application.notification.provided; + +import com.souzip.domain.notification.FcmToken; +import java.util.List; + +public interface FcmTokenFinder { + + FcmToken getByDeviceId(String deviceId); + + FcmToken getByToken(String token); + + List getActiveTokensByUserId(Long userId); + + List getAllActiveTokens(); +} diff --git a/src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java b/src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java new file mode 100644 index 00000000..15c8e8b7 --- /dev/null +++ b/src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java @@ -0,0 +1,32 @@ +package com.souzip.application.notification.required; + +import com.souzip.domain.notification.FcmToken; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.repository.Repository; + +public interface FcmTokenRepository extends Repository { + + FcmToken save(FcmToken fcmToken); + + List findAllByIdIn(Collection ids); + + Optional findByToken(String token); + + Optional findByDeviceId(String deviceId); + + Optional findByUserIdAndDeviceId(Long userId, String deviceId); + + List findByUserIdAndActiveTrue(Long userId); + + List findAllByActiveTrue(); + + void delete(FcmToken fcmToken); + + void deleteByDeviceId(String deviceId); + + boolean existsByToken(String token); + + boolean existsByDeviceId(String deviceId); +} diff --git a/src/main/java/com/souzip/application/notification/required/PushBroadcastHistoryRepository.java b/src/main/java/com/souzip/application/notification/required/PushBroadcastHistoryRepository.java new file mode 100644 index 00000000..d9f4397b --- /dev/null +++ b/src/main/java/com/souzip/application/notification/required/PushBroadcastHistoryRepository.java @@ -0,0 +1,13 @@ +package com.souzip.application.notification.required; + +import com.souzip.domain.notification.PushBroadcastHistory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.repository.Repository; + +public interface PushBroadcastHistoryRepository extends Repository { + + PushBroadcastHistory save(PushBroadcastHistory entity); + + Page findAllByOrderByCreatedAtDesc(Pageable pageable); +} diff --git a/src/main/java/com/souzip/domain/notification/DeviceType.java b/src/main/java/com/souzip/domain/notification/DeviceType.java new file mode 100644 index 00000000..f75060a6 --- /dev/null +++ b/src/main/java/com/souzip/domain/notification/DeviceType.java @@ -0,0 +1,6 @@ +package com.souzip.domain.notification; + +public enum DeviceType { + ANDROID, + IOS +} diff --git a/src/main/java/com/souzip/domain/notification/FcmToken.java b/src/main/java/com/souzip/domain/notification/FcmToken.java new file mode 100644 index 00000000..3268edf5 --- /dev/null +++ b/src/main/java/com/souzip/domain/notification/FcmToken.java @@ -0,0 +1,93 @@ +package com.souzip.domain.notification; + +import com.souzip.shared.domain.BaseEntity; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +import static java.util.Objects.requireNonNull; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FcmToken extends BaseEntity { + + private Long userId; + + private String token; + + private DeviceType deviceType; + + private String deviceId; + + private String deviceModel; + + private String osVersion; + + private String appVersion; + + private boolean active; + + private LocalDateTime lastUsedAt; + + public static FcmToken register(FcmTokenRegisterRequest request) { + FcmToken fcmToken = new FcmToken(); + + fcmToken.token = requireNonNull(request.fcmToken(), "FCM 토큰은 필수입니다."); + fcmToken.deviceType = requireNonNull(request.deviceType(), "디바이스 타입은 필수입니다."); + fcmToken.deviceId = requireNonNull(request.deviceId(), "디바이스 ID는 필수입니다."); + fcmToken.deviceModel = request.deviceModel(); + fcmToken.osVersion = request.osVersion(); + fcmToken.appVersion = request.appVersion(); + fcmToken.active = true; + fcmToken.lastUsedAt = LocalDateTime.now(); + + return fcmToken; + } + + public void linkUser(Long userId) { + this.userId = requireNonNull(userId, "사용자 ID는 필수입니다."); + this.lastUsedAt = LocalDateTime.now(); + } + + public void unlinkUser() { + this.userId = null; + this.lastUsedAt = LocalDateTime.now(); + } + + public void updateToken(String token) { + this.token = requireNonNull(token, "FCM 토큰은 필수입니다."); + this.lastUsedAt = LocalDateTime.now(); + } + + public void updateDeviceInfo(String deviceModel, String osVersion, String appVersion) { + this.deviceModel = deviceModel; + this.osVersion = osVersion; + this.appVersion = appVersion; + this.lastUsedAt = LocalDateTime.now(); + } + + public void syncDeviceIdentity(DeviceType deviceType, String deviceId) { + this.deviceType = requireNonNull(deviceType, "디바이스 타입은 필수입니다."); + this.deviceId = requireNonNull(deviceId, "디바이스 ID는 필수입니다."); + this.lastUsedAt = LocalDateTime.now(); + } + + public void deactivate() { + this.active = false; + } + + public void activate() { + this.active = true; + this.lastUsedAt = LocalDateTime.now(); + } + + public boolean isActive() { + return this.active; + } + + public boolean isLoggedIn() { + return this.userId != null; + } +} diff --git a/src/main/java/com/souzip/domain/notification/FcmTokenNotFoundException.java b/src/main/java/com/souzip/domain/notification/FcmTokenNotFoundException.java new file mode 100644 index 00000000..e12d49a9 --- /dev/null +++ b/src/main/java/com/souzip/domain/notification/FcmTokenNotFoundException.java @@ -0,0 +1,17 @@ +package com.souzip.domain.notification; + +public class FcmTokenNotFoundException extends RuntimeException { + + public FcmTokenNotFoundException(String message) { + super(message); + } + + // 토큰·디바이스 ID 등 식별자는 민감 정보이므로 메시지에 포함하지 않습니다. + public static FcmTokenNotFoundException byDeviceId(String deviceId) { + return new FcmTokenNotFoundException("해당 디바이스에 등록된 FCM 토큰을 찾을 수 없습니다."); + } + + public static FcmTokenNotFoundException byToken(String token) { + return new FcmTokenNotFoundException("FCM 토큰을 찾을 수 없습니다."); + } +} diff --git a/src/main/java/com/souzip/domain/notification/FcmTokenRegisterRequest.java b/src/main/java/com/souzip/domain/notification/FcmTokenRegisterRequest.java new file mode 100644 index 00000000..dd8cc355 --- /dev/null +++ b/src/main/java/com/souzip/domain/notification/FcmTokenRegisterRequest.java @@ -0,0 +1,94 @@ +package com.souzip.domain.notification; + +import static java.util.Objects.requireNonNull; + +public record FcmTokenRegisterRequest( + String fcmToken, + DeviceType deviceType, + String deviceId, + String deviceModel, + String osVersion, + String appVersion +) { + private static final int MAX_FCM_TOKEN_LENGTH = 255; + private static final int MAX_DEVICE_ID_LENGTH = 100; + private static final int MAX_DEVICE_MODEL_LENGTH = 100; + private static final int MAX_OS_VERSION_LENGTH = 50; + private static final int MAX_APP_VERSION_LENGTH = 50; + + public FcmTokenRegisterRequest { + fcmToken = validateFcmToken(fcmToken); + requireNonNull(deviceType, "디바이스 타입은 필수입니다."); + deviceId = validateDeviceId(deviceId); + deviceModel = validateOptionalField(deviceModel, MAX_DEVICE_MODEL_LENGTH, "디바이스 모델"); + osVersion = validateOptionalField(osVersion, MAX_OS_VERSION_LENGTH, "OS 버전"); + appVersion = validateOptionalField(appVersion, MAX_APP_VERSION_LENGTH, "앱 버전"); + } + + public static FcmTokenRegisterRequest of( + String fcmToken, + DeviceType deviceType, + String deviceId, + String deviceModel, + String osVersion, + String appVersion + ) { + return new FcmTokenRegisterRequest( + fcmToken, + deviceType, + deviceId, + deviceModel, + osVersion, + appVersion + ); + } + + private static String validateFcmToken(String fcmToken) { + requireNonNull(fcmToken, "FCM 토큰은 필수입니다."); + + String trimmed = fcmToken.trim(); + if (trimmed.isEmpty()) { + throw new IllegalArgumentException("FCM 토큰은 비어있을 수 없습니다."); + } + + if (trimmed.length() > MAX_FCM_TOKEN_LENGTH) { + throw new IllegalArgumentException( + String.format("FCM 토큰은 %d자를 초과할 수 없습니다.", MAX_FCM_TOKEN_LENGTH) + ); + } + + return trimmed; + } + + private static String validateDeviceId(String deviceId) { + requireNonNull(deviceId, "디바이스 ID는 필수입니다."); + + String trimmed = deviceId.trim(); + if (trimmed.isEmpty()) { + throw new IllegalArgumentException("디바이스 ID는 비어있을 수 없습니다."); + } + + if (trimmed.length() > MAX_DEVICE_ID_LENGTH) { + throw new IllegalArgumentException( + String.format("디바이스 ID는 %d자를 초과할 수 없습니다.", MAX_DEVICE_ID_LENGTH) + ); + } + + return trimmed; + } + + private static String validateOptionalField(String value, int maxLength, String fieldName) { + if (value == null || value.trim().isEmpty()) { + return null; + } + + String trimmed = value.trim(); + if (trimmed.length() > maxLength) { + throw new IllegalArgumentException( + String.format("%s는 %d자를 초과할 수 없습니다.", fieldName, maxLength) + ); + } + + return trimmed; + } +} diff --git a/src/main/java/com/souzip/domain/notification/PushBroadcastHistory.java b/src/main/java/com/souzip/domain/notification/PushBroadcastHistory.java new file mode 100644 index 00000000..d3fe0e4d --- /dev/null +++ b/src/main/java/com/souzip/domain/notification/PushBroadcastHistory.java @@ -0,0 +1,48 @@ +package com.souzip.domain.notification; + +import com.souzip.shared.domain.BaseEntity; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static java.util.Objects.requireNonNull; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PushBroadcastHistory extends BaseEntity { + + private UUID adminId; + + private String title; + + private String body; + + private int totalTargets; + + private int successCount; + + private int failCount; + + private boolean firebaseConfigured; + + public static PushBroadcastHistory record( + UUID adminId, + String title, + String body, + int totalTargets, + int successCount, + int failCount, + boolean firebaseConfigured + ) { + PushBroadcastHistory row = new PushBroadcastHistory(); + row.adminId = requireNonNull(adminId, "관리자 ID는 필수입니다."); + row.title = requireNonNull(title, "제목은 필수입니다."); + row.body = requireNonNull(body, "본문은 필수입니다."); + row.totalTargets = totalTargets; + row.successCount = successCount; + row.failCount = failCount; + row.firebaseConfigured = firebaseConfigured; + return row; + } +} diff --git a/src/main/java/com/souzip/domain/notification/controller/FcmTokenController.java b/src/main/java/com/souzip/domain/notification/controller/FcmTokenController.java new file mode 100644 index 00000000..0066473f --- /dev/null +++ b/src/main/java/com/souzip/domain/notification/controller/FcmTokenController.java @@ -0,0 +1,47 @@ +package com.souzip.domain.notification.controller; + +import com.souzip.application.notification.FcmTokenCommandService; +import com.souzip.domain.notification.FcmToken; +import com.souzip.domain.notification.FcmTokenRegisterRequest; +import com.souzip.domain.notification.dto.FcmTokenResponse; +import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.auth.adapter.security.annotation.CurrentUserId; +import com.souzip.auth.adapter.security.annotation.RequireAuth; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RequestMapping("/api/users/me/fcm-tokens") +@RestController +public class FcmTokenController { + + private final FcmTokenCommandService fcmTokenCommandService; + + // 로그인 사용자의 FCM 디바이스 토큰을 등록하거나 갱신합니다. + @PostMapping + @RequireAuth + public SuccessResponse register( + @CurrentUserId Long userId, + @Valid @RequestBody FcmTokenRegisterRequest request + ) { + FcmToken saved = fcmTokenCommandService.registerOrUpdate(userId, request); + return SuccessResponse.of(FcmTokenResponse.from(saved), "FCM 토큰이 등록되었습니다."); + } + + // 로그아웃 등으로 해당 디바이스 토큰을 비활성화합니다. + @DeleteMapping + @RequireAuth + public SuccessResponse deactivate( + @CurrentUserId Long userId, + @RequestParam String deviceId + ) { + fcmTokenCommandService.deactivateByDevice(userId, deviceId); + return SuccessResponse.of("FCM 토큰이 비활성화되었습니다."); + } +} diff --git a/src/main/java/com/souzip/domain/notification/dto/FcmTokenResponse.java b/src/main/java/com/souzip/domain/notification/dto/FcmTokenResponse.java new file mode 100644 index 00000000..8807399f --- /dev/null +++ b/src/main/java/com/souzip/domain/notification/dto/FcmTokenResponse.java @@ -0,0 +1,14 @@ +package com.souzip.domain.notification.dto; + +import com.souzip.domain.notification.FcmToken; + +public record FcmTokenResponse( + Long id, + String deviceId, + boolean active +) { + + public static FcmTokenResponse from(FcmToken token) { + return new FcmTokenResponse(token.getId(), token.getDeviceId(), token.isActive()); + } +} diff --git a/src/main/java/com/souzip/domain/user/service/UserService.java b/src/main/java/com/souzip/domain/user/service/UserService.java index d0fd2a22..778f4193 100644 --- a/src/main/java/com/souzip/domain/user/service/UserService.java +++ b/src/main/java/com/souzip/domain/user/service/UserService.java @@ -3,6 +3,7 @@ import com.souzip.application.file.FileQueryService; import com.souzip.application.file.dto.FileResponse; import com.souzip.application.file.required.FileStorage; +import com.souzip.application.notification.FcmTokenCommandService; import com.souzip.auth.application.required.RefreshTokenRepository; import com.souzip.domain.audit.entity.AuditAction; import com.souzip.domain.category.dto.CategoryDto; @@ -50,6 +51,7 @@ public class UserService { private final UserRepository userRepository; private final UserAgreementRepository userAgreementRepository; private final RefreshTokenRepository refreshTokenRepository; + private final FcmTokenCommandService fcmTokenCommandService; private final ProfileImageService profileImageService; private final SouvenirRepository souvenirRepository; private final WishlistRepository wishlistRepository; @@ -92,6 +94,7 @@ public void withdraw(Long userId) { deleteRefreshTokenIfExists(userId); deleteUserAgreementIfExists(user); + fcmTokenCommandService.deactivateAllByUserId(userId); user.anonymize(); diff --git a/src/main/java/com/souzip/shared/config/FirebaseConfig.java b/src/main/java/com/souzip/shared/config/FirebaseConfig.java new file mode 100644 index 00000000..bc600406 --- /dev/null +++ b/src/main/java/com/souzip/shared/config/FirebaseConfig.java @@ -0,0 +1,42 @@ +package com.souzip.shared.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import java.io.FileInputStream; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; + +@Configuration +@RequiredArgsConstructor +@ConditionalOnProperty(name = "firebase.enabled", havingValue = "true") +public class FirebaseConfig { + + private final FirebaseProperties firebaseProperties; + + @Bean + public FirebaseApp firebaseApp() throws IOException { + if (!StringUtils.hasText(firebaseProperties.getCredentialsPath())) { + throw new IllegalStateException("firebase.enabled=true 일 때 firebase.credentials-path 가 필요합니다."); + } + try (FileInputStream serviceAccount = new FileInputStream(firebaseProperties.getCredentialsPath())) { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccount)) + .build(); + if (FirebaseApp.getApps().isEmpty()) { + return FirebaseApp.initializeApp(options); + } + return FirebaseApp.getInstance(); + } + } + + @Bean + public FirebaseMessaging firebaseMessaging(FirebaseApp firebaseApp) { + return FirebaseMessaging.getInstance(firebaseApp); + } +} diff --git a/src/main/java/com/souzip/shared/config/FirebaseProperties.java b/src/main/java/com/souzip/shared/config/FirebaseProperties.java new file mode 100644 index 00000000..d5c57fa7 --- /dev/null +++ b/src/main/java/com/souzip/shared/config/FirebaseProperties.java @@ -0,0 +1,15 @@ +package com.souzip.shared.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "firebase") +public class FirebaseProperties { + + private boolean enabled = false; + + private String credentialsPath = ""; +} diff --git a/src/main/java/com/souzip/shared/config/FirebasePropertiesConfiguration.java b/src/main/java/com/souzip/shared/config/FirebasePropertiesConfiguration.java new file mode 100644 index 00000000..53ce0cbd --- /dev/null +++ b/src/main/java/com/souzip/shared/config/FirebasePropertiesConfiguration.java @@ -0,0 +1,9 @@ +package com.souzip.shared.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(FirebaseProperties.class) +public class FirebasePropertiesConfiguration { +} diff --git a/src/main/java/com/souzip/shared/exception/ErrorCode.java b/src/main/java/com/souzip/shared/exception/ErrorCode.java index 00cbc984..905793b5 100644 --- a/src/main/java/com/souzip/shared/exception/ErrorCode.java +++ b/src/main/java/com/souzip/shared/exception/ErrorCode.java @@ -51,7 +51,9 @@ public enum ErrorCode implements BaseErrorCode { APPLE_MIGRATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Apple 마이그레이션 준비 중 오류가 발생했습니다."), APPLE_TRANSFER_NOT_COMPLETED(HttpStatus.BAD_REQUEST, "App Transfer가 아직 완료되지 않았습니다."), - NO_APPLE_USERS_FOUND(HttpStatus.NOT_FOUND, "마이그레이션할 Apple 사용자가 없습니다.") + NO_APPLE_USERS_FOUND(HttpStatus.NOT_FOUND, "마이그레이션할 Apple 사용자가 없습니다."), + + FCM_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FCM 푸시 전송에 실패했습니다.") ; private final HttpStatus status; diff --git a/src/main/resources/META-INF/orm.xml b/src/main/resources/META-INF/orm.xml index dd65e9f9..b17274b7 100644 --- a/src/main/resources/META-INF/orm.xml +++ b/src/main/resources/META-INF/orm.xml @@ -128,6 +128,75 @@ + + + + token + + + user_id + device_id + +
+ + + + + + + + + + STRING + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/db/migration/V16__create_push_broadcast_history.sql b/src/main/resources/db/migration/V16__create_push_broadcast_history.sql new file mode 100644 index 00000000..d1d791f4 --- /dev/null +++ b/src/main/resources/db/migration/V16__create_push_broadcast_history.sql @@ -0,0 +1,14 @@ +CREATE TABLE push_broadcast_histories ( + id BIGSERIAL PRIMARY KEY, + admin_id UUID NOT NULL, + title VARCHAR(200) NOT NULL, + body VARCHAR(1000) NOT NULL, + total_targets INT NOT NULL, + success_count INT NOT NULL, + fail_count INT NOT NULL, + firebase_configured BOOLEAN NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE INDEX idx_push_broadcast_histories_created_at ON push_broadcast_histories (created_at DESC); diff --git a/src/test/java/com/souzip/adapter/webapi/admin/AdminPushApiTest.java b/src/test/java/com/souzip/adapter/webapi/admin/AdminPushApiTest.java new file mode 100644 index 00000000..b70ea7be --- /dev/null +++ b/src/test/java/com/souzip/adapter/webapi/admin/AdminPushApiTest.java @@ -0,0 +1,214 @@ +package com.souzip.adapter.webapi.admin; + +import com.souzip.adapter.webapi.admin.dto.PushBroadcastRequest; +import com.souzip.application.notification.FcmNotificationService; +import com.souzip.application.notification.PushBroadcastHistoryCommandService; +import com.souzip.application.notification.PushBroadcastHistoryQueryService; +import com.souzip.application.notification.dto.PushBroadcastHistoryResponse; +import com.souzip.application.notification.dto.PushBroadcastResult; +import com.souzip.docs.RestDocsSupport; +import com.souzip.shared.common.dto.pagination.PaginationResponse; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; + +import static com.souzip.docs.ApiDocumentUtils.getDocumentRequest; +import static com.souzip.docs.ApiDocumentUtils.getDocumentResponse; +import static com.souzip.docs.CommonDocumentation.apiResponseFields; +import static com.souzip.docs.CommonDocumentation.paginationResponseFields; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.mock; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class AdminPushApiTest extends RestDocsSupport { + + private final FcmNotificationService fcmNotificationService = mock(FcmNotificationService.class); + private final PushBroadcastHistoryCommandService pushBroadcastHistoryCommandService = mock(PushBroadcastHistoryCommandService.class); + private final PushBroadcastHistoryQueryService pushBroadcastHistoryQueryService = mock(PushBroadcastHistoryQueryService.class); + + @Override + protected Object initController() { + return new AdminPushApi(fcmNotificationService, pushBroadcastHistoryCommandService, pushBroadcastHistoryQueryService); + } + + @DisplayName("전체 기기에 푸시를 브로드캐스트할 수 있다") + @Test + void broadcast() throws Exception { + // given + PushBroadcastRequest request = new PushBroadcastRequest("공지 제목", "공지 내용"); + PushBroadcastResult result = new PushBroadcastResult(100, 95, 5, true); + + given(fcmNotificationService.broadcastToAllActiveTokens("공지 제목", "공지 내용")).willReturn(result); + willDoNothing().given(pushBroadcastHistoryCommandService) + .record(eq(TEST_ADMIN_ID), eq("공지 제목"), eq("공지 내용"), any(PushBroadcastResult.class)); + + // when & then + mockMvc.perform(post("/api/admin/push/broadcast") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalTargets").value(100)) + .andExpect(jsonPath("$.data.successCount").value(95)) + .andExpect(jsonPath("$.data.failCount").value(5)) + .andExpect(jsonPath("$.data.firebaseConfigured").value(true)) + .andDo(document("admin/push/broadcast", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("title").type(JsonFieldType.STRING).description("푸시 알림 제목 (최대 200자)"), + fieldWithPath("body").type(JsonFieldType.STRING).description("푸시 알림 내용 (최대 1000자)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.totalTargets").type(JsonFieldType.NUMBER).description("전체 대상 기기 수"), + fieldWithPath("data.successCount").type(JsonFieldType.NUMBER).description("전송 성공 수"), + fieldWithPath("data.failCount").type(JsonFieldType.NUMBER).description("전송 실패 수"), + fieldWithPath("data.firebaseConfigured").type(JsonFieldType.BOOLEAN).description("Firebase 설정 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지") + ) + )); + } + + @DisplayName("Firebase 미설정 시 브로드캐스트 결과에 firebaseConfigured=false가 반환된다") + @Test + void broadcast_firebaseNotConfigured() throws Exception { + // given + PushBroadcastRequest request = new PushBroadcastRequest("공지 제목", "공지 내용"); + PushBroadcastResult result = new PushBroadcastResult(50, 0, 0, false); + + given(fcmNotificationService.broadcastToAllActiveTokens("공지 제목", "공지 내용")).willReturn(result); + willDoNothing().given(pushBroadcastHistoryCommandService) + .record(eq(TEST_ADMIN_ID), eq("공지 제목"), eq("공지 내용"), any(PushBroadcastResult.class)); + + // when & then + mockMvc.perform(post("/api/admin/push/broadcast") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.firebaseConfigured").value(false)) + .andExpect(jsonPath("$.message").value("Firebase가 설정되지 않아 전송하지 않았습니다. (대상 기기 50건)")); + } + + @DisplayName("활성 기기가 없으면 브로드캐스트 결과에 전송 없음 메시지가 반환된다") + @Test + void broadcast_noActiveDevices() throws Exception { + // given + PushBroadcastRequest request = new PushBroadcastRequest("공지 제목", "공지 내용"); + PushBroadcastResult result = new PushBroadcastResult(0, 0, 0, true); + + given(fcmNotificationService.broadcastToAllActiveTokens("공지 제목", "공지 내용")).willReturn(result); + willDoNothing().given(pushBroadcastHistoryCommandService) + .record(eq(TEST_ADMIN_ID), eq("공지 제목"), eq("공지 내용"), any(PushBroadcastResult.class)); + + // when & then + mockMvc.perform(post("/api/admin/push/broadcast") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("등록된 활성 기기가 없어 전송하지 않았습니다.")); + } + + @DisplayName("제목 없이 브로드캐스트하면 400 에러가 반환된다") + @Test + void broadcast_missingTitle_returnsBadRequest() throws Exception { + // given + String requestBody = "{\"body\": \"내용만 있음\"}"; + + // when & then + mockMvc.perform(post("/api/admin/push/broadcast") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @DisplayName("브로드캐스트 이력을 페이지 조회할 수 있다") + @Test + void broadcastHistory() throws Exception { + // given + PushBroadcastHistoryResponse item = new PushBroadcastHistoryResponse( + 1L, + TEST_ADMIN_ID, + "공지 제목", + "공지 내용", + 100, 95, 5, + true, + LocalDateTime.of(2026, 6, 13, 12, 0, 0) + ); + + PaginationResponse page = PaginationResponse.of( + List.of(item), 1, 20, 1L, 1 + ); + + given(pushBroadcastHistoryQueryService.findPage(any())).willReturn(page); + + // when & then + mockMvc.perform(get("/api/admin/push/broadcast/history") + .param("page", "1") + .param("size", "20")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].id").value(1)) + .andExpect(jsonPath("$.data.content[0].title").value("공지 제목")) + .andExpect(jsonPath("$.data.content[0].totalTargets").value(100)) + .andDo(document("admin/push/broadcast-history", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("페이지 번호 (1부터 시작)"), + parameterWithName("size").description("페이지 크기") + ), + apiResponseFields( + paginationResponseFields( + fieldWithPath("data.content[].id").type(JsonFieldType.NUMBER).description("이력 ID"), + fieldWithPath("data.content[].adminId").type(JsonFieldType.STRING).description("발송한 관리자 ID"), + fieldWithPath("data.content[].title").type(JsonFieldType.STRING).description("푸시 제목"), + fieldWithPath("data.content[].body").type(JsonFieldType.STRING).description("푸시 내용"), + fieldWithPath("data.content[].totalTargets").type(JsonFieldType.NUMBER).description("전체 대상 기기 수"), + fieldWithPath("data.content[].successCount").type(JsonFieldType.NUMBER).description("전송 성공 수"), + fieldWithPath("data.content[].failCount").type(JsonFieldType.NUMBER).description("전송 실패 수"), + fieldWithPath("data.content[].firebaseConfigured").type(JsonFieldType.BOOLEAN).description("Firebase 설정 여부"), + fieldWithPath("data.content[].createdAt").type(JsonFieldType.STRING).description("발송 일시") + ) + ) + )); + } + + @DisplayName("브로드캐스트 이력이 없으면 빈 목록이 반환된다") + @Test + void broadcastHistory_empty() throws Exception { + // given + PaginationResponse emptyPage = PaginationResponse.of( + List.of(), 1, 20, 0L, 0 + ); + + given(pushBroadcastHistoryQueryService.findPage(any())).willReturn(emptyPage); + + // when & then + mockMvc.perform(get("/api/admin/push/broadcast/history") + .param("page", "1") + .param("size", "20")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content").isEmpty()); + } +} diff --git a/src/test/java/com/souzip/application/notification/FcmNotificationServiceTest.java b/src/test/java/com/souzip/application/notification/FcmNotificationServiceTest.java new file mode 100644 index 00000000..8bb57ace --- /dev/null +++ b/src/test/java/com/souzip/application/notification/FcmNotificationServiceTest.java @@ -0,0 +1,231 @@ +package com.souzip.application.notification; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.MessagingErrorCode; +import com.souzip.application.notification.provided.FcmTokenFinder; +import com.souzip.application.notification.dto.PushBroadcastResult; +import com.souzip.domain.notification.DeviceType; +import com.souzip.domain.notification.FcmToken; +import com.souzip.domain.notification.FcmTokenRegisterRequest; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +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.springframework.beans.factory.ObjectProvider; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +class FcmNotificationServiceTest { + + @Mock + private FcmTokenFinder fcmTokenFinder; + + @Mock + private FcmTokenCommandService fcmTokenCommandService; + + @Mock + private ObjectProvider firebaseMessagingProvider; + + @Mock + private FirebaseMessaging firebaseMessaging; + + @InjectMocks + private FcmNotificationService fcmNotificationService; + + @DisplayName("Firebase 미설정 시 전송을 건너뛴다") + @Test + void sendToToken_firebaseNotConfigured_skips() { + // given + given(firebaseMessagingProvider.getIfAvailable()).willReturn(null); + + // when & then (예외 없이 종료되어야 함) + fcmNotificationService.sendToToken("some-token", "제목", "본문"); + + then(firebaseMessaging).shouldHaveNoInteractions(); + } + + @DisplayName("사용자에게 전송 시 활성 토큰이 없으면 전송하지 않는다") + @Test + void sendToUser_noActiveTokens_doesNotSend() { + // given + given(fcmTokenFinder.getActiveTokensByUserId(1L)).willReturn(List.of()); + + // when + fcmNotificationService.sendToUser(1L, "제목", "본문"); + + // then + then(firebaseMessagingProvider).should(never()).getIfAvailable(); + } + + @DisplayName("사용자 토큰 전부 실패 시 예외를 던진다") + @Test + void sendToUser_allTokensFail_throwsException() throws FirebaseMessagingException { + // given + FcmToken token = createToken("fcm-token-1", "device-id-1", 1L); + FirebaseMessagingException fcmException = mockFirebaseException(); + + given(fcmTokenFinder.getActiveTokensByUserId(1L)).willReturn(List.of(token)); + given(firebaseMessagingProvider.getIfAvailable()).willReturn(firebaseMessaging); + given(firebaseMessaging.send(any())).willThrow(fcmException); + + // when & then + assertThatThrownBy(() -> fcmNotificationService.sendToUser(1L, "제목", "본문")) + .isInstanceOf(BusinessException.class); + } + + @DisplayName("브로드캐스트 - 활성 토큰 없으면 전송 건너뛰고 결과 반환") + @Test + void broadcastToAllActiveTokens_noTokens_returnsEmptyResult() { + // given + given(fcmTokenFinder.getAllActiveTokens()).willReturn(List.of()); + + // when + PushBroadcastResult result = fcmNotificationService.broadcastToAllActiveTokens("제목", "본문"); + + // then + assertThat(result.totalTargets()).isZero(); + assertThat(result.successCount()).isZero(); + assertThat(result.firebaseConfigured()).isTrue(); + } + + @DisplayName("브로드캐스트 - Firebase 미설정 시 전송 없이 결과 반환") + @Test + void broadcastToAllActiveTokens_firebaseNotConfigured_returnsResultWithoutSending() { + // given + FcmToken token = createToken("fcm-token-1", "device-id-1", 1L); + + given(fcmTokenFinder.getAllActiveTokens()).willReturn(List.of(token)); + given(firebaseMessagingProvider.getIfAvailable()).willReturn(null); + + // when + PushBroadcastResult result = fcmNotificationService.broadcastToAllActiveTokens("제목", "본문"); + + // then + assertThat(result.totalTargets()).isEqualTo(1); + assertThat(result.successCount()).isZero(); + assertThat(result.firebaseConfigured()).isFalse(); + } + + @DisplayName("브로드캐스트 - 일부 토큰 실패해도 나머지는 전송된다") + @Test + void broadcastToAllActiveTokens_partialFailure_continuesAndReturnsResult() throws FirebaseMessagingException { + // given + FcmToken token1 = createToken("fcm-token-1", "device-id-1", 1L); + FcmToken token2 = createToken("fcm-token-2", "device-id-2", 2L); + FirebaseMessagingException fcmException = mockFirebaseException(); + + given(fcmTokenFinder.getAllActiveTokens()).willReturn(List.of(token1, token2)); + given(firebaseMessagingProvider.getIfAvailable()).willReturn(firebaseMessaging); + given(firebaseMessaging.send(any())) + .willThrow(fcmException) + .willReturn("message-id"); + + // when + PushBroadcastResult result = fcmNotificationService.broadcastToAllActiveTokens("제목", "본문"); + + // then + assertThat(result.totalTargets()).isEqualTo(2); + assertThat(result.successCount()).isEqualTo(1); + assertThat(result.failCount()).isEqualTo(1); + assertThat(result.firebaseConfigured()).isTrue(); + then(firebaseMessaging).should(times(2)).send(any()); + } + + @DisplayName("브로드캐스트 - 전체 성공 시 결과 반환") + @Test + void broadcastToAllActiveTokens_allSuccess_returnsSuccessResult() throws FirebaseMessagingException { + // given + FcmToken token1 = createToken("fcm-token-1", "device-id-1", 1L); + FcmToken token2 = createToken("fcm-token-2", "device-id-2", 2L); + + given(fcmTokenFinder.getAllActiveTokens()).willReturn(List.of(token1, token2)); + given(firebaseMessagingProvider.getIfAvailable()).willReturn(firebaseMessaging); + given(firebaseMessaging.send(any())).willReturn("message-id"); + + // when + PushBroadcastResult result = fcmNotificationService.broadcastToAllActiveTokens("제목", "본문"); + + // then + assertThat(result.totalTargets()).isEqualTo(2); + assertThat(result.successCount()).isEqualTo(2); + assertThat(result.failCount()).isZero(); + } + + @DisplayName("브로드캐스트 - 영구 실패(UNREGISTERED) 토큰은 비활성화한다") + @Test + void broadcastToAllActiveTokens_permanentFailure_deactivatesStaleToken() throws FirebaseMessagingException { + // given + FcmToken token = createTokenWithId(10L, "stale-token", "device-id-1", 1L); + FirebaseMessagingException permanent = mockFirebaseException(MessagingErrorCode.UNREGISTERED); + + given(fcmTokenFinder.getAllActiveTokens()).willReturn(List.of(token)); + given(firebaseMessagingProvider.getIfAvailable()).willReturn(firebaseMessaging); + given(firebaseMessaging.send(any())).willThrow(permanent); + + // when + PushBroadcastResult result = fcmNotificationService.broadcastToAllActiveTokens("제목", "본문"); + + // then + assertThat(result.failCount()).isEqualTo(1); + then(fcmTokenCommandService).should(times(1)).deactivateByIds(List.of(10L)); + } + + @DisplayName("브로드캐스트 - 일시 실패(INTERNAL) 토큰은 비활성화하지 않는다") + @Test + void broadcastToAllActiveTokens_transientFailure_keepsToken() throws FirebaseMessagingException { + // given + FcmToken token = createTokenWithId(11L, "fcm-token-1", "device-id-1", 1L); + FirebaseMessagingException transientErr = mockFirebaseException(MessagingErrorCode.INTERNAL); + + given(fcmTokenFinder.getAllActiveTokens()).willReturn(List.of(token)); + given(firebaseMessagingProvider.getIfAvailable()).willReturn(firebaseMessaging); + given(firebaseMessaging.send(any())).willThrow(transientErr); + + // when + fcmNotificationService.broadcastToAllActiveTokens("제목", "본문"); + + // then + then(fcmTokenCommandService).should(never()).deactivateByIds(any()); + } + + private FcmToken createToken(String fcmToken, String deviceId, Long userId) { + FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( + fcmToken, DeviceType.ANDROID, deviceId, "Galaxy S23", "Android 14", "1.0.0" + ); + FcmToken token = FcmToken.register(request); + token.linkUser(userId); + return token; + } + + private FcmToken createTokenWithId(Long id, String fcmToken, String deviceId, Long userId) { + FcmToken token = createToken(fcmToken, deviceId, userId); + ReflectionTestUtils.setField(token, "id", id); + return token; + } + + private FirebaseMessagingException mockFirebaseException() { + return mockFirebaseException(MessagingErrorCode.INTERNAL); + } + + private FirebaseMessagingException mockFirebaseException(MessagingErrorCode code) { + FirebaseMessagingException exception = mock(FirebaseMessagingException.class); + given(exception.getMessagingErrorCode()).willReturn(code); + return exception; + } +} diff --git a/src/test/java/com/souzip/application/notification/FcmTokenCommandServiceTest.java b/src/test/java/com/souzip/application/notification/FcmTokenCommandServiceTest.java new file mode 100644 index 00000000..ace9b136 --- /dev/null +++ b/src/test/java/com/souzip/application/notification/FcmTokenCommandServiceTest.java @@ -0,0 +1,196 @@ +package com.souzip.application.notification; + +import com.souzip.application.notification.required.FcmTokenRepository; +import com.souzip.domain.notification.DeviceType; +import com.souzip.domain.notification.FcmToken; +import com.souzip.domain.notification.FcmTokenRegisterRequest; +import org.junit.jupiter.api.DisplayName; +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 java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +class FcmTokenCommandServiceTest { + + @Mock + private FcmTokenRepository fcmTokenRepository; + + @InjectMocks + private FcmTokenCommandService fcmTokenCommandService; + + @DisplayName("동일 FCM 토큰이 이미 존재하면 사용자를 연결하고 활성화한다") + @Test + void registerOrUpdate_existingToken_linksUserAndActivates() { + // given + FcmToken existing = createToken("fcm-token-123", "device-id-123", 2L); + existing.deactivate(); + + FcmTokenRegisterRequest request = createRequest("fcm-token-123", "device-id-123"); + + given(fcmTokenRepository.findByToken("fcm-token-123")).willReturn(Optional.of(existing)); + given(fcmTokenRepository.save(existing)).willReturn(existing); + + // when + FcmToken result = fcmTokenCommandService.registerOrUpdate(1L, request); + + // then + assertThat(result.getUserId()).isEqualTo(1L); + assertThat(result.isActive()).isTrue(); + then(fcmTokenRepository).should(times(1)).save(existing); + } + + @DisplayName("동일 디바이스가 있으면 FCM 토큰을 갱신한다") + @Test + void registerOrUpdate_sameDevice_updatesToken() { + // given + FcmToken existing = createToken("old-fcm-token", "device-id-123", 1L); + FcmTokenRegisterRequest request = createRequest("new-fcm-token", "device-id-123"); + + given(fcmTokenRepository.findByToken("new-fcm-token")).willReturn(Optional.empty()); + given(fcmTokenRepository.findByUserIdAndDeviceId(1L, "device-id-123")).willReturn(Optional.of(existing)); + given(fcmTokenRepository.save(existing)).willReturn(existing); + + // when + FcmToken result = fcmTokenCommandService.registerOrUpdate(1L, request); + + // then + assertThat(result.getToken()).isEqualTo("new-fcm-token"); + assertThat(result.isActive()).isTrue(); + } + + @DisplayName("신규 토큰이면 새 레코드를 생성한다") + @Test + void registerOrUpdate_newToken_creates() { + // given + FcmTokenRegisterRequest request = createRequest("brand-new-token", "device-id-new"); + + given(fcmTokenRepository.findByToken("brand-new-token")).willReturn(Optional.empty()); + given(fcmTokenRepository.findByUserIdAndDeviceId(1L, "device-id-new")).willReturn(Optional.empty()); + given(fcmTokenRepository.save(any(FcmToken.class))).willAnswer(inv -> inv.getArgument(0)); + + // when + FcmToken result = fcmTokenCommandService.registerOrUpdate(1L, request); + + // then + assertThat(result.getToken()).isEqualTo("brand-new-token"); + assertThat(result.getUserId()).isEqualTo(1L); + assertThat(result.isActive()).isTrue(); + } + + @DisplayName("디바이스 ID로 토큰을 비활성화한다") + @Test + void deactivateByDevice_deactivatesToken() { + // given + FcmToken token = createToken("fcm-token-123", "device-id-123", 1L); + + given(fcmTokenRepository.findByUserIdAndDeviceId(1L, "device-id-123")).willReturn(Optional.of(token)); + given(fcmTokenRepository.save(token)).willReturn(token); + + // when + fcmTokenCommandService.deactivateByDevice(1L, "device-id-123"); + + // then + assertThat(token.isActive()).isFalse(); + then(fcmTokenRepository).should(times(1)).save(token); + } + + @DisplayName("해당 디바이스가 없으면 아무것도 하지 않는다") + @Test + void deactivateByDevice_noToken_doesNothing() { + // given + given(fcmTokenRepository.findByUserIdAndDeviceId(1L, "nonexistent-device")).willReturn(Optional.empty()); + + // when + fcmTokenCommandService.deactivateByDevice(1L, "nonexistent-device"); + + // then + then(fcmTokenRepository).should(never()).save(any()); + } + + @DisplayName("회원 탈퇴 시 해당 사용자의 모든 활성 토큰을 비활성화한다") + @Test + void deactivateAllByUserId_deactivatesAllActiveTokens() { + // given + FcmToken token1 = createToken("fcm-token-1", "device-id-1", 1L); + FcmToken token2 = createToken("fcm-token-2", "device-id-2", 1L); + + given(fcmTokenRepository.findByUserIdAndActiveTrue(1L)).willReturn(List.of(token1, token2)); + given(fcmTokenRepository.save(any(FcmToken.class))).willAnswer(inv -> inv.getArgument(0)); + + // when + fcmTokenCommandService.deactivateAllByUserId(1L); + + // then + assertThat(token1.isActive()).isFalse(); + assertThat(token2.isActive()).isFalse(); + then(fcmTokenRepository).should(times(2)).save(any(FcmToken.class)); + } + + @DisplayName("회원 탈퇴 시 활성 토큰이 없으면 아무것도 하지 않는다") + @Test + void deactivateAllByUserId_noActiveTokens_doesNothing() { + // given + given(fcmTokenRepository.findByUserIdAndActiveTrue(1L)).willReturn(List.of()); + + // when + fcmTokenCommandService.deactivateAllByUserId(1L); + + // then + then(fcmTokenRepository).should(never()).save(any()); + } + + @DisplayName("영구 실패 토큰들을 ID로 일괄 비활성화한다") + @Test + void deactivateByIds_deactivatesGivenTokens() { + // given + FcmToken token1 = createToken("fcm-token-1", "device-id-1", 1L); + FcmToken token2 = createToken("fcm-token-2", "device-id-2", 2L); + + given(fcmTokenRepository.findAllByIdIn(List.of(10L, 20L))).willReturn(List.of(token1, token2)); + given(fcmTokenRepository.save(any(FcmToken.class))).willAnswer(inv -> inv.getArgument(0)); + + // when + fcmTokenCommandService.deactivateByIds(List.of(10L, 20L)); + + // then + assertThat(token1.isActive()).isFalse(); + assertThat(token2.isActive()).isFalse(); + then(fcmTokenRepository).should(times(2)).save(any(FcmToken.class)); + } + + @DisplayName("비활성화할 ID가 비어있으면 조회조차 하지 않는다") + @Test + void deactivateByIds_emptyIds_doesNothing() { + // when + fcmTokenCommandService.deactivateByIds(List.of()); + + // then + then(fcmTokenRepository).should(never()).findAllByIdIn(any()); + then(fcmTokenRepository).should(never()).save(any()); + } + + private FcmToken createToken(String fcmToken, String deviceId, Long userId) { + FcmTokenRegisterRequest request = createRequest(fcmToken, deviceId); + FcmToken token = FcmToken.register(request); + token.linkUser(userId); + return token; + } + + private FcmTokenRegisterRequest createRequest(String fcmToken, String deviceId) { + return FcmTokenRegisterRequest.of( + fcmToken, DeviceType.ANDROID, deviceId, "Galaxy S23", "Android 14", "1.0.0" + ); + } +} diff --git a/src/test/java/com/souzip/domain/notification/FcmTokenRegisterRequestTest.java b/src/test/java/com/souzip/domain/notification/FcmTokenRegisterRequestTest.java new file mode 100644 index 00000000..615f0474 --- /dev/null +++ b/src/test/java/com/souzip/domain/notification/FcmTokenRegisterRequestTest.java @@ -0,0 +1,201 @@ +package com.souzip.domain.notification; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.*; + +class FcmTokenRegisterRequestTest { + + @DisplayName("정상적으로 요청 객체를 생성한다") + @Test + void create_success() { + // when + FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( + "fcm-token-123", + DeviceType.ANDROID, + "device-id-123", + "Galaxy S23", + "Android 14", + "1.0.0" + ); + + // then + assertThat(request.fcmToken()).isEqualTo("fcm-token-123"); + assertThat(request.deviceType()).isEqualTo(DeviceType.ANDROID); + assertThat(request.deviceId()).isEqualTo("device-id-123"); + assertThat(request.deviceModel()).isEqualTo("Galaxy S23"); + assertThat(request.osVersion()).isEqualTo("Android 14"); + assertThat(request.appVersion()).isEqualTo("1.0.0"); + } + + @DisplayName("선택 필드 없이 요청 객체를 생성한다") + @Test + void create_withoutOptionalFields() { + // when + FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( + "fcm-token-123", + DeviceType.IOS, + "device-id-456", + null, + null, + null + ); + + // then + assertThat(request.fcmToken()).isEqualTo("fcm-token-123"); + assertThat(request.deviceType()).isEqualTo(DeviceType.IOS); + assertThat(request.deviceModel()).isNull(); + assertThat(request.osVersion()).isNull(); + assertThat(request.appVersion()).isNull(); + } + + @DisplayName("FCM 토큰이 null이면 예외가 발생한다") + @Test + void create_withNullFcmToken_throwsException() { + // when & then + assertThatThrownBy(() -> FcmTokenRegisterRequest.of( + null, + DeviceType.ANDROID, + "device-id-123", + null, + null, + null + )).isInstanceOf(NullPointerException.class) + .hasMessage("FCM 토큰은 필수입니다."); + } + + @DisplayName("FCM 토큰이 비어있으면 예외가 발생한다") + @ValueSource(strings = {"", " ", " "}) + @ParameterizedTest + void create_withEmptyFcmToken_throwsException(String emptyToken) { + // when & then + assertThatThrownBy(() -> FcmTokenRegisterRequest.of( + emptyToken, + DeviceType.ANDROID, + "device-id-123", + null, + null, + null + )).isInstanceOf(IllegalArgumentException.class) + .hasMessage("FCM 토큰은 비어있을 수 없습니다."); + } + + @DisplayName("FCM 토큰이 최대 길이를 초과하면 예외가 발생한다") + @Test + void create_withTooLongFcmToken_throwsException() { + // given + String tooLongToken = "a".repeat(256); + + // when & then + assertThatThrownBy(() -> FcmTokenRegisterRequest.of( + tooLongToken, + DeviceType.ANDROID, + "device-id-123", + null, + null, + null + )).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("FCM 토큰은 255자를 초과할 수 없습니다"); + } + + @DisplayName("디바이스 타입이 null이면 예외가 발생한다") + @Test + void create_withNullDeviceType_throwsException() { + // when & then + assertThatThrownBy(() -> FcmTokenRegisterRequest.of( + "fcm-token-123", + null, + "device-id-123", + null, + null, + null + )).isInstanceOf(NullPointerException.class) + .hasMessage("디바이스 타입은 필수입니다."); + } + + @DisplayName("디바이스 ID가 null이면 예외가 발생한다") + @Test + void create_withNullDeviceId_throwsException() { + // when & then + assertThatThrownBy(() -> FcmTokenRegisterRequest.of( + "fcm-token-123", + DeviceType.ANDROID, + null, + null, + null, + null + )).isInstanceOf(NullPointerException.class) + .hasMessage("디바이스 ID는 필수입니다."); + } + + @DisplayName("디바이스 ID가 비어있으면 예외가 발생한다") + @ValueSource(strings = {"", " ", " "}) + @ParameterizedTest + void create_withEmptyDeviceId_throwsException(String emptyDeviceId) { + // when & then + assertThatThrownBy(() -> FcmTokenRegisterRequest.of( + "fcm-token-123", + DeviceType.ANDROID, + emptyDeviceId, + null, + null, + null + )).isInstanceOf(IllegalArgumentException.class) + .hasMessage("디바이스 ID는 비어있을 수 없습니다."); + } + + @DisplayName("디바이스 ID가 최대 길이를 초과하면 예외가 발생한다") + @Test + void create_withTooLongDeviceId_throwsException() { + // given + String tooLongDeviceId = "a".repeat(101); + + // when & then + assertThatThrownBy(() -> FcmTokenRegisterRequest.of( + "fcm-token-123", + DeviceType.ANDROID, + tooLongDeviceId, + null, + null, + null + )).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("디바이스 ID는 100자를 초과할 수 없습니다"); + } + + @DisplayName("FCM 토큰 앞뒤 공백을 제거한다") + @Test + void create_trimsFcmToken() { + // when + FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( + " fcm-token-123 ", + DeviceType.ANDROID, + "device-id-123", + null, + null, + null + ); + + // then + assertThat(request.fcmToken()).isEqualTo("fcm-token-123"); + } + + @DisplayName("디바이스 ID 앞뒤 공백을 제거한다") + @Test + void create_trimsDeviceId() { + // when + FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( + "fcm-token-123", + DeviceType.ANDROID, + " device-id-123 ", + null, + null, + null + ); + + // then + assertThat(request.deviceId()).isEqualTo("device-id-123"); + } +} diff --git a/src/test/java/com/souzip/domain/notification/FcmTokenTest.java b/src/test/java/com/souzip/domain/notification/FcmTokenTest.java new file mode 100644 index 00000000..90dc5cbd --- /dev/null +++ b/src/test/java/com/souzip/domain/notification/FcmTokenTest.java @@ -0,0 +1,210 @@ +package com.souzip.domain.notification; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.*; + +class FcmTokenTest { + + @DisplayName("FCM 토큰을 정상적으로 등록한다") + @Test + void register_success() { + // given + FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( + "fcm-token-123", + DeviceType.ANDROID, + "device-id-123", + "Galaxy S23", + "Android 14", + "1.0.0" + ); + + // when + FcmToken fcmToken = FcmToken.register(request); + + // then + assertThat(fcmToken.getToken()).isEqualTo("fcm-token-123"); + assertThat(fcmToken.getDeviceType()).isEqualTo(DeviceType.ANDROID); + assertThat(fcmToken.getDeviceId()).isEqualTo("device-id-123"); + assertThat(fcmToken.getDeviceModel()).isEqualTo("Galaxy S23"); + assertThat(fcmToken.getOsVersion()).isEqualTo("Android 14"); + assertThat(fcmToken.getAppVersion()).isEqualTo("1.0.0"); + assertThat(fcmToken.isActive()).isTrue(); + assertThat(fcmToken.getUserId()).isNull(); + assertThat(fcmToken.getLastUsedAt()).isNotNull(); + } + + @DisplayName("선택 필드 없이 FCM 토큰을 등록한다") + @Test + void register_withoutOptionalFields() { + // given + FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( + "fcm-token-123", + DeviceType.IOS, + "device-id-456", + null, + null, + null + ); + + // when + FcmToken fcmToken = FcmToken.register(request); + + // then + assertThat(fcmToken.getToken()).isEqualTo("fcm-token-123"); + assertThat(fcmToken.getDeviceType()).isEqualTo(DeviceType.IOS); + assertThat(fcmToken.getDeviceModel()).isNull(); + assertThat(fcmToken.getOsVersion()).isNull(); + assertThat(fcmToken.getAppVersion()).isNull(); + } + + @DisplayName("로그인 시 사용자 ID를 연결한다") + @Test + void linkUser_success() { + // given + FcmToken fcmToken = createFcmToken(); + Long userId = 1L; + + // when + fcmToken.linkUser(userId); + + // then + assertThat(fcmToken.getUserId()).isEqualTo(userId); + assertThat(fcmToken.isLoggedIn()).isTrue(); + assertThat(fcmToken.getLastUsedAt()).isNotNull(); + } + + @Test + @DisplayName("null 사용자 ID로 연결하면 예외가 발생한다") + void linkUser_withNull_throwsException() { + // given + FcmToken fcmToken = createFcmToken(); + + // when & then + assertThatThrownBy(() -> fcmToken.linkUser(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("사용자 ID는 필수입니다."); + } + + @DisplayName("로그아웃 시 사용자 ID 연결을 해제한다") + @Test + void unlinkUser_success() { + // given + FcmToken fcmToken = createFcmToken(); + fcmToken.linkUser(1L); + + // when + fcmToken.unlinkUser(); + + // then + assertThat(fcmToken.getUserId()).isNull(); + assertThat(fcmToken.isLoggedIn()).isFalse(); + } + + @DisplayName("FCM 토큰을 갱신한다") + @Test + void updateToken_success() { + // given + FcmToken fcmToken = createFcmToken(); + String newToken = "new-fcm-token"; + LocalDateTime beforeUpdate = fcmToken.getLastUsedAt(); + + // when + fcmToken.updateToken(newToken); + + // then + assertThat(fcmToken.getToken()).isEqualTo(newToken); + assertThat(fcmToken.getLastUsedAt()).isAfter(beforeUpdate); + } + + @DisplayName("null 토큰으로 갱신하면 예외가 발생한다") + @Test + void updateToken_withNull_throwsException() { + // given + FcmToken fcmToken = createFcmToken(); + + // when & then + assertThatThrownBy(() -> fcmToken.updateToken(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("FCM 토큰은 필수입니다."); + } + + @DisplayName("디바이스 정보를 업데이트한다") + @Test + void updateDeviceInfo_success() { + // given + FcmToken fcmToken = createFcmToken(); + + // when + fcmToken.updateDeviceInfo("iPhone 15 Pro", "iOS 17.2", "1.1.0"); + + // then + assertThat(fcmToken.getDeviceModel()).isEqualTo("iPhone 15 Pro"); + assertThat(fcmToken.getOsVersion()).isEqualTo("iOS 17.2"); + assertThat(fcmToken.getAppVersion()).isEqualTo("1.1.0"); + } + + @DisplayName("토큰을 비활성화한다") + @Test + void deactivate_success() { + // given + FcmToken fcmToken = createFcmToken(); + + // when + fcmToken.deactivate(); + + // then + assertThat(fcmToken.isActive()).isFalse(); + } + + @DisplayName("토큰을 활성화한다") + @Test + void activate_success() { + // given + FcmToken fcmToken = createFcmToken(); + fcmToken.deactivate(); + + // when + fcmToken.activate(); + + // then + assertThat(fcmToken.isActive()).isTrue(); + } + + @DisplayName("비로그인 상태를 확인한다") + @Test + void isLoggedIn_whenNotLoggedIn_returnsFalse() { + // given + FcmToken fcmToken = createFcmToken(); + + // when & then + assertThat(fcmToken.isLoggedIn()).isFalse(); + } + + @DisplayName("로그인 상태를 확인한다") + @Test + void isLoggedIn_whenLoggedIn_returnsTrue() { + // given + FcmToken fcmToken = createFcmToken(); + fcmToken.linkUser(1L); + + // when & then + assertThat(fcmToken.isLoggedIn()).isTrue(); + } + + private FcmToken createFcmToken() { + return FcmToken.register( + FcmTokenRegisterRequest.of( + "fcm-token-123", + DeviceType.ANDROID, + "device-id-123", + "Galaxy S23", + "Android 14", + "1.0.0" + ) + ); + } +} diff --git a/src/test/java/com/souzip/domain/notification/PushBroadcastHistoryTest.java b/src/test/java/com/souzip/domain/notification/PushBroadcastHistoryTest.java new file mode 100644 index 00000000..2ced8c11 --- /dev/null +++ b/src/test/java/com/souzip/domain/notification/PushBroadcastHistoryTest.java @@ -0,0 +1,73 @@ +package com.souzip.domain.notification; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PushBroadcastHistoryTest { + + private static final UUID ADMIN_ID = UUID.fromString("550e8400-e29b-41d4-a716-446655440000"); + + @DisplayName("브로드캐스트 이력을 정상적으로 기록한다") + @Test + void record_success() { + // when + PushBroadcastHistory history = PushBroadcastHistory.record( + ADMIN_ID, "제목", "본문", 100, 95, 5, true + ); + + // then + assertThat(history.getAdminId()).isEqualTo(ADMIN_ID); + assertThat(history.getTitle()).isEqualTo("제목"); + assertThat(history.getBody()).isEqualTo("본문"); + assertThat(history.getTotalTargets()).isEqualTo(100); + assertThat(history.getSuccessCount()).isEqualTo(95); + assertThat(history.getFailCount()).isEqualTo(5); + assertThat(history.isFirebaseConfigured()).isTrue(); + } + + @DisplayName("Firebase 미설정 상태도 기록된다") + @Test + void record_withFirebaseDisabled() { + // when + PushBroadcastHistory history = PushBroadcastHistory.record( + ADMIN_ID, "제목", "본문", 50, 0, 0, false + ); + + // then + assertThat(history.isFirebaseConfigured()).isFalse(); + assertThat(history.getTotalTargets()).isEqualTo(50); + assertThat(history.getSuccessCount()).isZero(); + } + + @DisplayName("관리자 ID가 null이면 예외가 발생한다") + @Test + void record_withNullAdminId_throwsException() { + assertThatThrownBy(() -> + PushBroadcastHistory.record(null, "제목", "본문", 0, 0, 0, true) + ).isInstanceOf(NullPointerException.class) + .hasMessage("관리자 ID는 필수입니다."); + } + + @DisplayName("제목이 null이면 예외가 발생한다") + @Test + void record_withNullTitle_throwsException() { + assertThatThrownBy(() -> + PushBroadcastHistory.record(ADMIN_ID, null, "본문", 0, 0, 0, true) + ).isInstanceOf(NullPointerException.class) + .hasMessage("제목은 필수입니다."); + } + + @DisplayName("본문이 null이면 예외가 발생한다") + @Test + void record_withNullBody_throwsException() { + assertThatThrownBy(() -> + PushBroadcastHistory.record(ADMIN_ID, "제목", null, 0, 0, 0, true) + ).isInstanceOf(NullPointerException.class) + .hasMessage("본문은 필수입니다."); + } +} diff --git a/src/test/java/com/souzip/domain/notification/controller/FcmTokenControllerTest.java b/src/test/java/com/souzip/domain/notification/controller/FcmTokenControllerTest.java new file mode 100644 index 00000000..4fb8fe8c --- /dev/null +++ b/src/test/java/com/souzip/domain/notification/controller/FcmTokenControllerTest.java @@ -0,0 +1,172 @@ +package com.souzip.domain.notification.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.souzip.application.notification.FcmTokenCommandService; +import com.souzip.docs.RestDocsSupport; +import com.souzip.domain.notification.DeviceType; +import com.souzip.domain.notification.FcmToken; +import com.souzip.domain.notification.FcmTokenRegisterRequest; +import com.souzip.domain.notification.dto.FcmTokenResponse; +import com.souzip.shared.exception.GlobalExceptionHandler; +import com.souzip.auth.adapter.security.annotation.CurrentUserId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.Map; + +import static com.souzip.docs.ApiDocumentUtils.getDocumentRequest; +import static com.souzip.docs.ApiDocumentUtils.getDocumentResponse; +import static com.souzip.docs.CommonDocumentation.apiResponseFields; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.mock; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class FcmTokenControllerTest extends RestDocsSupport { + + private final FcmTokenCommandService fcmTokenCommandService = mock(FcmTokenCommandService.class); + + @Override + protected Object initController() { + return new FcmTokenController(fcmTokenCommandService); + } + + @BeforeEach + void setUpWithUserIdResolver(RestDocumentationContextProvider provider) { + ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + this.mockMvc = MockMvcBuilders.standaloneSetup(initController()) + .setControllerAdvice(new GlobalExceptionHandler()) + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUserId.class) + && parameter.getParameterType().equals(Long.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + return 1L; + } + }) + .apply(documentationConfiguration(provider)) + .build(); + } + + @DisplayName("FCM 토큰을 등록할 수 있다") + @Test + void register() throws Exception { + // given + FcmToken token = createToken("fcm-token-123", "device-id-123"); + ReflectionTestUtils.setField(token, "id", 10L); + + Map requestBody = Map.of( + "fcmToken", "fcm-token-123", + "deviceType", "ANDROID", + "deviceId", "device-id-123", + "deviceModel", "Galaxy S23", + "osVersion", "Android 14", + "appVersion", "1.0.0" + ); + + given(fcmTokenCommandService.registerOrUpdate(eq(1L), any(FcmTokenRegisterRequest.class))) + .willReturn(token); + + // when & then + mockMvc.perform(post("/api/users/me/fcm-tokens") + .contentType(MediaType.APPLICATION_JSON) + .content(new ObjectMapper().writeValueAsString(requestBody))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(10)) + .andExpect(jsonPath("$.data.deviceId").value("device-id-123")) + .andExpect(jsonPath("$.data.active").value(true)) + .andExpect(jsonPath("$.message").value("FCM 토큰이 등록되었습니다.")) + .andDo(document("fcm-token/register", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("fcmToken").type(JsonFieldType.STRING).description("FCM 디바이스 토큰"), + fieldWithPath("deviceType").type(JsonFieldType.STRING).description("디바이스 타입 (ANDROID, IOS)"), + fieldWithPath("deviceId").type(JsonFieldType.STRING).description("디바이스 고유 ID"), + fieldWithPath("deviceModel").type(JsonFieldType.STRING).description("디바이스 모델명").optional(), + fieldWithPath("osVersion").type(JsonFieldType.STRING).description("OS 버전").optional(), + fieldWithPath("appVersion").type(JsonFieldType.STRING).description("앱 버전").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("FCM 토큰 ID"), + fieldWithPath("data.deviceId").type(JsonFieldType.STRING).description("디바이스 ID"), + fieldWithPath("data.active").type(JsonFieldType.BOOLEAN).description("활성화 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지") + ) + )); + } + + @DisplayName("FCM 토큰을 비활성화할 수 있다") + @Test + void deactivate() throws Exception { + // given + willDoNothing().given(fcmTokenCommandService).deactivateByDevice(1L, "device-id-123"); + + // when & then + mockMvc.perform(delete("/api/users/me/fcm-tokens?deviceId={deviceId}", "device-id-123")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("FCM 토큰이 비활성화되었습니다.")) + .andDo(document("fcm-token/deactivate", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("deviceId").description("비활성화할 디바이스 ID") + ), + responseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지") + ) + )); + } + + private FcmToken createToken(String fcmToken, String deviceId) { + FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( + fcmToken, DeviceType.ANDROID, deviceId, "Galaxy S23", "Android 14", "1.0.0" + ); + FcmToken token = FcmToken.register(request); + token.linkUser(1L); + return token; + } +} diff --git a/src/test/java/com/souzip/domain/user/service/UserServiceTest.java b/src/test/java/com/souzip/domain/user/service/UserServiceTest.java index d0ce4e93..dcaa9882 100644 --- a/src/test/java/com/souzip/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/souzip/domain/user/service/UserServiceTest.java @@ -1,5 +1,6 @@ package com.souzip.domain.user.service; +import com.souzip.application.notification.FcmTokenCommandService; import com.souzip.auth.application.required.RefreshTokenRepository; import com.souzip.domain.user.dto.NicknameCheckResponse; import com.souzip.domain.user.dto.OnboardingRequest; @@ -38,6 +39,9 @@ class UserServiceTest { @Mock private RefreshTokenRepository refreshTokenRepository; + @Mock + private FcmTokenCommandService fcmTokenCommandService; + @Mock private ProfileImageService profileImageService; @@ -248,6 +252,7 @@ void withdraw_success() { verify(spyUser).anonymize(); verify(refreshTokenRepository).deleteByUserId(1L); verify(userAgreementRepository).delete(agreement); + verify(fcmTokenCommandService).deactivateAllByUserId(1L); verify(userRepository).delete(spyUser); } @@ -274,6 +279,7 @@ void withdraw_withoutAgreement_success() { verify(spyUser).anonymize(); verify(refreshTokenRepository).deleteByUserId(1L); + verify(fcmTokenCommandService).deactivateAllByUserId(1L); verify(userRepository).delete(spyUser); verify(userAgreementRepository, never()).delete(any()); }