diff --git a/.env.example b/.env.example index b4de72f..45fa0f8 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,9 @@ NAVER_CLIENT_SECRET= AWS_S3_BUCKET= AWS_REGION= TOUR_SERVICE_KEY= +FIREBASE_ENABLED=false +FIREBASE_PROJECT_ID= +GOOGLE_APPLICATION_CREDENTIALS= +NOTIFICATION_ZONE=Asia/Seoul +COURSE_REMINDER_CRON=0 0 9 * * * +NOTIFICATION_DISPATCH_DELAY_MS=60000 diff --git a/README.md b/README.md index e605ae5..424a8af 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,17 @@ Elastic Beanstalk EC2 Instance Profile을 포함한 AWS SDK 기본 자격 증명 반환됩니다. 버킷 정책상 반환된 객체 URL을 클라이언트가 읽을 수 있도록 별도 접근 정책도 배포 전에 준비해야 합니다. +## Firebase Cloud Messaging 설정 + +푸시 알림을 실제 발송하려면 `FIREBASE_ENABLED=true`와 `FIREBASE_PROJECT_ID`를 설정하고, +Firebase 서비스 계정 파일 경로를 `GOOGLE_APPLICATION_CREDENTIALS`로 제공해야 합니다. +서비스 계정 JSON은 저장소에 커밋하지 않습니다. 설정하지 않은 환경에서도 서버와 테스트는 +시작되지만 푸시 발송은 실패 상태로 기록되고 재시도됩니다. + +코스 D-1/D-0 알림은 기본적으로 `Asia/Seoul` 기준 매일 오전 9시에 생성됩니다. +`NOTIFICATION_ZONE`, `COURSE_REMINDER_CRON`, `NOTIFICATION_DISPATCH_DELAY_MS` 환경변수로 +시간대, 생성 시각, 실패 알림 재시도 주기를 변경할 수 있습니다. + ## 정책 문서 `src/main/resources/policies`의 두 문서는 현재 출시 전 자리표시자입니다. 운영 배포 전에 diff --git a/build.gradle b/build.gradle index 9354ea4..2fc41d8 100644 --- a/build.gradle +++ b/build.gradle @@ -56,6 +56,9 @@ dependencies { // AWS S3 implementation platform('software.amazon.awssdk:bom:2.41.24') implementation 'software.amazon.awssdk:s3' + + // Firebase Cloud Messaging + implementation 'com.google.firebase:firebase-admin:9.10.0' } tasks.named('test') { diff --git a/src/main/java/com/daytodo/domain/auth/service/AuthService.java b/src/main/java/com/daytodo/domain/auth/service/AuthService.java index 742e812..2459abf 100644 --- a/src/main/java/com/daytodo/domain/auth/service/AuthService.java +++ b/src/main/java/com/daytodo/domain/auth/service/AuthService.java @@ -14,9 +14,11 @@ import com.daytodo.domain.auth.repository.RefreshTokenRepository; import com.daytodo.domain.auth.repository.SocialAccountRepository; import com.daytodo.domain.user.entity.User; +import com.daytodo.domain.user.entity.UserNotificationSetting; import com.daytodo.domain.user.enums.LoginType; import com.daytodo.domain.user.enums.UserStatus; import com.daytodo.domain.user.repository.UserRepository; +import com.daytodo.domain.user.repository.UserNotificationSettingRepository; import com.daytodo.global.apiPayload.exception.ProjectException; import com.daytodo.global.security.JwtProperties; import com.daytodo.global.security.JwtTokenProvider; @@ -46,6 +48,7 @@ public class AuthService { private static final long PASSWORD_RESET_EXPIRY_MINUTES = 10; private final UserRepository userRepository; + private final UserNotificationSettingRepository userNotificationSettingRepository; private final RefreshTokenRepository refreshTokenRepository; private final SocialAccountRepository socialAccountRepository; private final EmailVerificationTokenRepository emailVerificationTokenRepository; @@ -88,6 +91,7 @@ public AuthResponse.SignUp signUp(AuthRequest.SignUp request) { // 이메일 인증 전까지는 INACTIVE 상태로 두고, 인증 완료 시 ACTIVE로 전환한다. user.changeStatus(UserStatus.INACTIVE); + userNotificationSettingRepository.save(new UserNotificationSetting(user)); issueEmailVerificationToken(user); return new AuthResponse.SignUp(user.getId(), user.getEmail(), user.getNickname(), user.getCreatedAt()); } @@ -298,6 +302,7 @@ private User createUserFromNaverProfile(NaverApiClient.NaverProfileResponse prof null, LoginType.NAVER )); + userNotificationSettingRepository.save(new UserNotificationSetting(user)); socialAccountRepository.save(new SocialAccount(user, SocialProvider.NAVER, profile.response().id())); return user; } @@ -334,4 +339,4 @@ private String generateDefaultNickname() { long count = userRepository.count() + 1; return String.format("투두%02d", count); } -} \ No newline at end of file +} diff --git a/src/main/java/com/daytodo/domain/course/repository/CourseMemberRepository.java b/src/main/java/com/daytodo/domain/course/repository/CourseMemberRepository.java index 7570a7d..e26c405 100644 --- a/src/main/java/com/daytodo/domain/course/repository/CourseMemberRepository.java +++ b/src/main/java/com/daytodo/domain/course/repository/CourseMemberRepository.java @@ -2,6 +2,8 @@ import com.daytodo.domain.course.entity.CourseMember; import com.daytodo.domain.course.enums.MemberStatus; +import com.daytodo.domain.course.enums.CourseStatus; +import com.daytodo.domain.user.enums.UserStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -9,6 +11,7 @@ import java.util.Collection; import java.util.List; import java.util.Optional; +import java.time.LocalDate; public interface CourseMemberRepository extends JpaRepository { @@ -18,6 +21,23 @@ public interface CourseMemberRepository extends JpaRepository findByCourseCourseIdAndMemberStatus(Long courseId, MemberStatus memberStatus); + @Query(""" + select cm from CourseMember cm + join fetch cm.course course + join fetch cm.user user + where course.courseDate = :courseDate + and course.courseStatus not in :excludedStatuses + and cm.memberStatus = :memberStatus + and user.userStatus = :userStatus + order by course.courseId asc, user.id asc + """) + List findReminderCandidates( + @Param("courseDate") LocalDate courseDate, + @Param("excludedStatuses") Collection excludedStatuses, + @Param("memberStatus") MemberStatus memberStatus, + @Param("userStatus") UserStatus userStatus + ); + Optional findByCourseCourseIdAndUserIdAndMemberStatus(Long courseId, Long targetUserId, MemberStatus memberStatus); /* diff --git a/src/main/java/com/daytodo/domain/user/controller/UserController.java b/src/main/java/com/daytodo/domain/user/controller/UserController.java index e5f77be..364a6f9 100644 --- a/src/main/java/com/daytodo/domain/user/controller/UserController.java +++ b/src/main/java/com/daytodo/domain/user/controller/UserController.java @@ -3,6 +3,7 @@ import com.daytodo.domain.user.dto.UserRequest; import com.daytodo.domain.user.dto.UserResponse; import com.daytodo.domain.user.service.FeedbackService; +import com.daytodo.domain.user.service.FcmTokenService; import com.daytodo.domain.user.service.PolicyService; import com.daytodo.domain.user.service.ProfileService; import com.daytodo.domain.user.service.UserNotificationService; @@ -34,6 +35,7 @@ public class UserController { private final UserNotificationService notificationService; private final FeedbackService feedbackService; private final PolicyService policyService; + private final FcmTokenService fcmTokenService; @Operation(summary = "프로필 조회") @GetMapping("/profile") @@ -96,6 +98,26 @@ public UserResponse.NotificationSettings updateNotificationSettings( return notificationService.updateSettings(userId, request); } + @Operation(summary = "FCM 토큰 등록 또는 갱신") + @PostMapping("/fcm-token") + public ResponseEntity registerFcmToken( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody UserRequest.RegisterFcmToken request + ) { + fcmTokenService.register(userId, request); + return ResponseEntity.noContent().build(); + } + + @Operation(summary = "FCM 토큰 삭제") + @DeleteMapping("/fcm-token") + public ResponseEntity deleteFcmToken( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody UserRequest.DeleteFcmToken request + ) { + fcmTokenService.delete(userId, request); + return ResponseEntity.noContent().build(); + } + @Operation(summary = "의견 보내기") @PostMapping("/feedback") public ResponseEntity submitFeedback( diff --git a/src/main/java/com/daytodo/domain/user/dto/UserRequest.java b/src/main/java/com/daytodo/domain/user/dto/UserRequest.java index 4b854c1..2117a1c 100644 --- a/src/main/java/com/daytodo/domain/user/dto/UserRequest.java +++ b/src/main/java/com/daytodo/domain/user/dto/UserRequest.java @@ -1,7 +1,11 @@ package com.daytodo.domain.user.dto; import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import com.daytodo.domain.user.enums.DevicePlatform; import java.util.List; @@ -17,11 +21,23 @@ public record ReplaceInterestRegions( public record UpdateNotificationSettings( @NotNull(message = "앱 푸시 알림 설정은 필수입니다.") - Boolean pushEnabled, - @NotNull(message = "D-1 코스 알림 설정은 필수입니다.") - Boolean courseD1Enabled, - @NotNull(message = "D-0 코스 알림 설정은 필수입니다.") - Boolean courseD0Enabled + Boolean pushEnabled + ) { + } + + public record RegisterFcmToken( + @NotBlank(message = "FCM 토큰은 필수입니다.") + @Size(max = 512, message = "FCM 토큰은 512자 이하여야 합니다.") + String token, + @NotNull(message = "기기 플랫폼은 필수입니다.") + DevicePlatform platform + ) { + } + + public record DeleteFcmToken( + @NotBlank(message = "FCM 토큰은 필수입니다.") + @Size(max = 512, message = "FCM 토큰은 512자 이하여야 합니다.") + String token ) { } diff --git a/src/main/java/com/daytodo/domain/user/dto/UserResponse.java b/src/main/java/com/daytodo/domain/user/dto/UserResponse.java index 2a44013..68e200f 100644 --- a/src/main/java/com/daytodo/domain/user/dto/UserResponse.java +++ b/src/main/java/com/daytodo/domain/user/dto/UserResponse.java @@ -19,9 +19,7 @@ public record InterestRegions(List regions) { } public record NotificationSettings( - boolean pushEnabled, - boolean courseD1Enabled, - boolean courseD0Enabled + boolean pushEnabled ) { } diff --git a/src/main/java/com/daytodo/domain/user/entity/FcmToken.java b/src/main/java/com/daytodo/domain/user/entity/FcmToken.java new file mode 100644 index 0000000..af30ee4 --- /dev/null +++ b/src/main/java/com/daytodo/domain/user/entity/FcmToken.java @@ -0,0 +1,47 @@ +package com.daytodo.domain.user.entity; + +import com.daytodo.domain.common.BaseEntity; +import com.daytodo.domain.user.enums.DevicePlatform; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "fcm_token") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FcmToken extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "fcm_token_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "token", nullable = false, unique = true, length = 512) + private String token; + + @Enumerated(EnumType.STRING) + @Column(name = "platform", nullable = false, length = 20) + private DevicePlatform platform; + + public FcmToken(User user, String token, DevicePlatform platform) { + this.user = user; + this.token = token; + this.platform = platform; + } +} diff --git a/src/main/java/com/daytodo/domain/user/entity/Notification.java b/src/main/java/com/daytodo/domain/user/entity/Notification.java index 4a09528..f645857 100644 --- a/src/main/java/com/daytodo/domain/user/entity/Notification.java +++ b/src/main/java/com/daytodo/domain/user/entity/Notification.java @@ -1,5 +1,6 @@ package com.daytodo.domain.user.entity; import com.daytodo.domain.user.enums.NotificationType; +import com.daytodo.domain.user.enums.NotificationDeliveryStatus; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EntityListeners; @@ -12,6 +13,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Getter; @@ -21,7 +23,13 @@ @Getter @Entity -@Table(name = "notification") +@Table( + name = "notification", + uniqueConstraints = @UniqueConstraint( + name = "uk_notification_user_course_type", + columnNames = {"user_id", "course_id", "notification_type"} + ) +) @EntityListeners(AuditingEntityListener.class) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Notification { @@ -50,6 +58,22 @@ public class Notification { @Column(name = "is_read", nullable = false) private boolean read; + @Enumerated(EnumType.STRING) + @Column(name = "delivery_status", length = 20, nullable = false) + private NotificationDeliveryStatus deliveryStatus; + + @Column(name = "attempt_count", nullable = false) + private int attemptCount; + + @Column(name = "sent_at") + private LocalDateTime sentAt; + + @Column(name = "next_retry_at") + private LocalDateTime nextRetryAt; + + @Column(name = "last_error", length = 1000) + private String lastError; + @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; @@ -67,9 +91,26 @@ public Notification( this.title = title; this.body = body; this.read = false; + this.deliveryStatus = NotificationDeliveryStatus.PENDING; + this.attemptCount = 0; } public void markAsRead() { this.read = true; } + + public void markSent(LocalDateTime sentAt) { + this.deliveryStatus = NotificationDeliveryStatus.SENT; + this.attemptCount++; + this.sentAt = sentAt; + this.nextRetryAt = null; + this.lastError = null; + } + + public void markFailed(String error, LocalDateTime nextRetryAt) { + this.deliveryStatus = NotificationDeliveryStatus.FAILED; + this.attemptCount++; + this.nextRetryAt = nextRetryAt; + this.lastError = error == null ? null : error.substring(0, Math.min(error.length(), 1000)); + } } diff --git a/src/main/java/com/daytodo/domain/user/entity/UserNotificationSetting.java b/src/main/java/com/daytodo/domain/user/entity/UserNotificationSetting.java index d294ed2..ea7ddd7 100644 --- a/src/main/java/com/daytodo/domain/user/entity/UserNotificationSetting.java +++ b/src/main/java/com/daytodo/domain/user/entity/UserNotificationSetting.java @@ -53,28 +53,23 @@ public class UserNotificationSetting { name = "course_d1_enabled", nullable = false ) - private boolean courseD1Enabled; + private boolean legacyCourseD1Enabled; @Column( name = "course_d0_enabled", nullable = false ) - private boolean courseD0Enabled; + private boolean legacyCourseD0Enabled; public UserNotificationSetting(User user) { this.user = user; this.pushEnabled = true; - this.courseD1Enabled = true; - this.courseD0Enabled = true; + // 기존 DB의 NOT NULL 컬럼과 호환하기 위한 값이다. D-1/D-0는 개별 설정하지 않는다. + this.legacyCourseD1Enabled = true; + this.legacyCourseD0Enabled = true; } - public void updateSettings( - boolean pushEnabled, - boolean courseD1Enabled, - boolean courseD0Enabled - ) { + public void updatePushEnabled(boolean pushEnabled) { this.pushEnabled = pushEnabled; - this.courseD1Enabled = courseD1Enabled; - this.courseD0Enabled = courseD0Enabled; } } diff --git a/src/main/java/com/daytodo/domain/user/enums/DevicePlatform.java b/src/main/java/com/daytodo/domain/user/enums/DevicePlatform.java new file mode 100644 index 0000000..45225ae --- /dev/null +++ b/src/main/java/com/daytodo/domain/user/enums/DevicePlatform.java @@ -0,0 +1,6 @@ +package com.daytodo.domain.user.enums; + +public enum DevicePlatform { + ANDROID, + IOS +} diff --git a/src/main/java/com/daytodo/domain/user/enums/NotificationDeliveryStatus.java b/src/main/java/com/daytodo/domain/user/enums/NotificationDeliveryStatus.java new file mode 100644 index 0000000..98c5b89 --- /dev/null +++ b/src/main/java/com/daytodo/domain/user/enums/NotificationDeliveryStatus.java @@ -0,0 +1,7 @@ +package com.daytodo.domain.user.enums; + +public enum NotificationDeliveryStatus { + PENDING, + SENT, + FAILED +} diff --git a/src/main/java/com/daytodo/domain/user/push/DisabledPushNotificationGateway.java b/src/main/java/com/daytodo/domain/user/push/DisabledPushNotificationGateway.java new file mode 100644 index 0000000..7663705 --- /dev/null +++ b/src/main/java/com/daytodo/domain/user/push/DisabledPushNotificationGateway.java @@ -0,0 +1,16 @@ +package com.daytodo.domain.user.push; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +@ConditionalOnProperty(prefix = "firebase", name = "enabled", havingValue = "false", matchIfMissing = true) +public class DisabledPushNotificationGateway implements PushNotificationGateway { + + @Override + public PushResult send(String token, String title, String body, Map data) { + return PushResult.failed(false, "Firebase Cloud Messaging이 설정되지 않았습니다."); + } +} diff --git a/src/main/java/com/daytodo/domain/user/push/FirebasePushNotificationGateway.java b/src/main/java/com/daytodo/domain/user/push/FirebasePushNotificationGateway.java new file mode 100644 index 0000000..500f7c8 --- /dev/null +++ b/src/main/java/com/daytodo/domain/user/push/FirebasePushNotificationGateway.java @@ -0,0 +1,40 @@ +package com.daytodo.domain.user.push; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.MessagingErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "firebase", name = "enabled", havingValue = "true") +public class FirebasePushNotificationGateway implements PushNotificationGateway { + + private final FirebaseMessaging firebaseMessaging; + + @Override + public PushResult send(String token, String title, String body, Map data) { + Message message = Message.builder() + .setToken(token) + .setNotification(com.google.firebase.messaging.Notification.builder() + .setTitle(title) + .setBody(body) + .build()) + .putAllData(data) + .build(); + try { + firebaseMessaging.send(message); + return PushResult.sent(); + } catch (FirebaseMessagingException exception) { + MessagingErrorCode code = exception.getMessagingErrorCode(); + boolean invalidToken = code == MessagingErrorCode.UNREGISTERED + || code == MessagingErrorCode.INVALID_ARGUMENT; + return PushResult.failed(invalidToken, exception.getMessage()); + } + } +} diff --git a/src/main/java/com/daytodo/domain/user/push/PushNotificationGateway.java b/src/main/java/com/daytodo/domain/user/push/PushNotificationGateway.java new file mode 100644 index 0000000..9ed0adc --- /dev/null +++ b/src/main/java/com/daytodo/domain/user/push/PushNotificationGateway.java @@ -0,0 +1,18 @@ +package com.daytodo.domain.user.push; + +import java.util.Map; + +public interface PushNotificationGateway { + + PushResult send(String token, String title, String body, Map data); + + record PushResult(boolean success, boolean invalidToken, String errorMessage) { + public static PushResult sent() { + return new PushResult(true, false, null); + } + + public static PushResult failed(boolean invalidToken, String errorMessage) { + return new PushResult(false, invalidToken, errorMessage); + } + } +} diff --git a/src/main/java/com/daytodo/domain/user/repository/FcmTokenRepository.java b/src/main/java/com/daytodo/domain/user/repository/FcmTokenRepository.java new file mode 100644 index 0000000..1cc2159 --- /dev/null +++ b/src/main/java/com/daytodo/domain/user/repository/FcmTokenRepository.java @@ -0,0 +1,40 @@ +package com.daytodo.domain.user.repository; + +import com.daytodo.domain.user.entity.FcmToken; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface FcmTokenRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findByToken(String token); + + @Modifying + @Query(value = """ + insert into fcm_token (user_id, token, platform, created_at, updated_at) + values (:userId, :token, :platform, current_timestamp, current_timestamp) + on duplicate key update + user_id = values(user_id), + platform = values(platform), + updated_at = current_timestamp + """, nativeQuery = true) + void upsert( + @Param("userId") Long userId, + @Param("token") String token, + @Param("platform") String platform + ); + + List findAllByUser_IdIn(Collection userIds); + + List findAllByUser_Id(Long userId); + + long deleteByTokenAndUser_Id(String token, Long userId); +} diff --git a/src/main/java/com/daytodo/domain/user/repository/NotificationRepository.java b/src/main/java/com/daytodo/domain/user/repository/NotificationRepository.java new file mode 100644 index 0000000..7d8076d --- /dev/null +++ b/src/main/java/com/daytodo/domain/user/repository/NotificationRepository.java @@ -0,0 +1,42 @@ +package com.daytodo.domain.user.repository; + +import com.daytodo.domain.user.entity.Notification; +import com.daytodo.domain.user.enums.NotificationDeliveryStatus; +import jakarta.persistence.LockModeType; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface NotificationRepository extends JpaRepository { + + boolean existsByUser_IdAndCourseIdAndNotificationType( + Long userId, + Long courseId, + com.daytodo.domain.user.enums.NotificationType notificationType + ); + + @Query(""" + select notification.id from Notification notification + where notification.deliveryStatus in :statuses + and notification.attemptCount < :maxAttempts + and (notification.nextRetryAt is null or notification.nextRetryAt <= :now) + order by notification.createdAt asc, notification.id asc + """) + List findRetryableIds( + @Param("statuses") Collection statuses, + @Param("maxAttempts") int maxAttempts, + @Param("now") LocalDateTime now, + Pageable pageable + ); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select notification from Notification notification join fetch notification.user where notification.id = :id") + Optional findByIdForDelivery(@Param("id") Long id); +} diff --git a/src/main/java/com/daytodo/domain/user/repository/UserNotificationSettingRepository.java b/src/main/java/com/daytodo/domain/user/repository/UserNotificationSettingRepository.java index c7c9f0a..62ac8cb 100644 --- a/src/main/java/com/daytodo/domain/user/repository/UserNotificationSettingRepository.java +++ b/src/main/java/com/daytodo/domain/user/repository/UserNotificationSettingRepository.java @@ -4,10 +4,14 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; +import java.util.Collection; +import java.util.List; public interface UserNotificationSettingRepository extends JpaRepository { // User 엔티티의 실제 PK 필드명은 id이므로 연관 경로도 user.id로 지정한다. Optional findByUser_Id(Long userId); + + List findAllByUser_IdIn(Collection userIds); } diff --git a/src/main/java/com/daytodo/domain/user/repository/WithdrawnUserCleanupRepository.java b/src/main/java/com/daytodo/domain/user/repository/WithdrawnUserCleanupRepository.java index 2d0863c..06a77bf 100644 --- a/src/main/java/com/daytodo/domain/user/repository/WithdrawnUserCleanupRepository.java +++ b/src/main/java/com/daytodo/domain/user/repository/WithdrawnUserCleanupRepository.java @@ -10,12 +10,13 @@ public class WithdrawnUserCleanupRepository { private final EntityManager entityManager; public void removeUserReferences(Long userId) { - delete("delete from RecommendationLike entity where entity.userId = :userId", userId); - delete("delete from RecommendationComment entity where entity.userId = :userId", userId); - update("update PlaceRecommendation entity set entity.recommenderId = null where entity.recommenderId = :userId", userId); + delete("delete from RecommendationLike entity where entity.user.id = :userId", userId); + delete("delete from RecommendationComment entity where entity.user.id = :userId", userId); + update("update PlaceRecommendation entity set entity.recommender = null where entity.recommender.id = :userId", userId); delete("delete from BookmarkPlace entity where entity.user.id = :userId", userId); delete("delete from SocialAccount entity where entity.user.id = :userId", userId); + delete("delete from FcmToken entity where entity.user.id = :userId", userId); delete("delete from Notification entity where entity.user.id = :userId", userId); delete("delete from UserNotificationSetting entity where entity.user.id = :userId", userId); delete("delete from Feedback entity where entity.user.id = :userId", userId); diff --git a/src/main/java/com/daytodo/domain/user/service/CourseNotificationScheduler.java b/src/main/java/com/daytodo/domain/user/service/CourseNotificationScheduler.java new file mode 100644 index 0000000..8d3d671 --- /dev/null +++ b/src/main/java/com/daytodo/domain/user/service/CourseNotificationScheduler.java @@ -0,0 +1,31 @@ +package com.daytodo.domain.user.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.LocalDate; + +@Component +@RequiredArgsConstructor +public class CourseNotificationScheduler { + + private final CourseReminderService reminderService; + private final NotificationDispatchService dispatchService; + private final Clock clock; + + @Scheduled( + cron = "${notification.course-reminder-cron:0 0 9 * * *}", + zone = "${notification.zone:Asia/Seoul}" + ) + public void createDailyReminders() { + reminderService.createDailyReminders(LocalDate.now(clock)); + dispatchService.dispatchRetryable(); + } + + @Scheduled(fixedDelayString = "${notification.dispatch-delay-ms:60000}") + public void retryFailedNotifications() { + dispatchService.dispatchRetryable(); + } +} diff --git a/src/main/java/com/daytodo/domain/user/service/CourseReminderService.java b/src/main/java/com/daytodo/domain/user/service/CourseReminderService.java new file mode 100644 index 0000000..20e0c8e --- /dev/null +++ b/src/main/java/com/daytodo/domain/user/service/CourseReminderService.java @@ -0,0 +1,92 @@ +package com.daytodo.domain.user.service; + +import com.daytodo.domain.course.entity.Course; +import com.daytodo.domain.course.entity.CourseMember; +import com.daytodo.domain.course.enums.CourseStatus; +import com.daytodo.domain.course.enums.MemberStatus; +import com.daytodo.domain.course.repository.CourseMemberRepository; +import com.daytodo.domain.user.entity.FcmToken; +import com.daytodo.domain.user.entity.UserNotificationSetting; +import com.daytodo.domain.user.enums.NotificationType; +import com.daytodo.domain.user.enums.UserStatus; +import com.daytodo.domain.user.repository.FcmTokenRepository; +import com.daytodo.domain.user.repository.UserNotificationSettingRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.EnumSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CourseReminderService { + + private static final Set EXCLUDED_STATUSES = + EnumSet.of(CourseStatus.CANCELED, CourseStatus.COMPLETED); + + private final CourseMemberRepository courseMemberRepository; + private final UserNotificationSettingRepository settingRepository; + private final FcmTokenRepository fcmTokenRepository; + private final NotificationEventCreator eventCreator; + + public void createDailyReminders(LocalDate today) { + createReminders(today.plusDays(1), NotificationType.COURSE_D1); + createReminders(today, NotificationType.COURSE_D0); + } + + private void createReminders(LocalDate courseDate, NotificationType type) { + var candidates = courseMemberRepository.findReminderCandidates( + courseDate, + EXCLUDED_STATUSES, + MemberStatus.JOINED, + UserStatus.ACTIVE + ); + if (candidates.isEmpty()) { + return; + } + + Set userIds = candidates.stream() + .map(candidate -> candidate.getUser().getId()) + .collect(Collectors.toSet()); + Map settings = settingRepository.findAllByUser_IdIn(userIds).stream() + .collect(Collectors.toMap(setting -> setting.getUser().getId(), Function.identity())); + Set usersWithTokens = fcmTokenRepository.findAllByUser_IdIn(userIds).stream() + .map(FcmToken::getUser) + .map(user -> user.getId()) + .collect(Collectors.toSet()); + + candidates.stream() + .filter(candidate -> usersWithTokens.contains(candidate.getUser().getId())) + .filter(candidate -> isEnabled(settings.get(candidate.getUser().getId()))) + .forEach(candidate -> createReminder(candidate, type)); + } + + private boolean isEnabled(UserNotificationSetting setting) { + return setting == null || setting.isPushEnabled(); + } + + private void createReminder(CourseMember candidate, NotificationType type) { + Course course = candidate.getCourse(); + String title = type == NotificationType.COURSE_D1 + ? "코스 일정이 하루 남았어요" + : "오늘 예정된 코스가 있어요"; + String body = type == NotificationType.COURSE_D1 + ? String.format("내일 '%s' 코스가 예정되어 있어요.", course.getCourseName()) + : String.format("오늘 '%s' 코스를 확인해 보세요.", course.getCourseName()); + try { + eventCreator.create(candidate.getUser(), course.getCourseId(), type, title, body); + } catch (DataIntegrityViolationException exception) { + log.debug("이미 생성된 코스 알림입니다. userId={}, courseId={}, type={}", + candidate.getUser().getId(), course.getCourseId(), type); + } + } +} diff --git a/src/main/java/com/daytodo/domain/user/service/FcmTokenService.java b/src/main/java/com/daytodo/domain/user/service/FcmTokenService.java new file mode 100644 index 0000000..206210d --- /dev/null +++ b/src/main/java/com/daytodo/domain/user/service/FcmTokenService.java @@ -0,0 +1,45 @@ +package com.daytodo.domain.user.service; + +import com.daytodo.domain.user.dto.UserRequest; +import com.daytodo.domain.user.entity.User; +import com.daytodo.domain.user.entity.UserNotificationSetting; +import com.daytodo.domain.user.enums.UserStatus; +import com.daytodo.domain.user.exception.code.UserErrorCode; +import com.daytodo.domain.user.repository.FcmTokenRepository; +import com.daytodo.domain.user.repository.UserNotificationSettingRepository; +import com.daytodo.domain.user.repository.UserRepository; +import com.daytodo.global.apiPayload.exception.ProjectException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class FcmTokenService { + + private final UserRepository userRepository; + private final UserNotificationSettingRepository settingRepository; + private final FcmTokenRepository fcmTokenRepository; + + @Transactional + public void register(Long userId, UserRequest.RegisterFcmToken request) { + User user = userRepository.findActiveUserForUpdate(userId, UserStatus.ACTIVE) + .orElseThrow(() -> new ProjectException(UserErrorCode.USER_NOT_FOUND)); + + settingRepository.findByUser_Id(userId) + .orElseGet(() -> settingRepository.save(new UserNotificationSetting(user))); + + fcmTokenRepository.upsert(user.getId(), request.token(), request.platform().name()); + } + + @Transactional + public void delete(Long userId, UserRequest.DeleteFcmToken request) { + requireActiveUser(userId); + fcmTokenRepository.deleteByTokenAndUser_Id(request.token(), userId); + } + + private void requireActiveUser(Long userId) { + userRepository.findByIdAndUserStatus(userId, UserStatus.ACTIVE) + .orElseThrow(() -> new ProjectException(UserErrorCode.USER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/daytodo/domain/user/service/NotificationDeliveryWorker.java b/src/main/java/com/daytodo/domain/user/service/NotificationDeliveryWorker.java new file mode 100644 index 0000000..c5f1b15 --- /dev/null +++ b/src/main/java/com/daytodo/domain/user/service/NotificationDeliveryWorker.java @@ -0,0 +1,88 @@ +package com.daytodo.domain.user.service; + +import com.daytodo.domain.user.entity.FcmToken; +import com.daytodo.domain.user.entity.Notification; +import com.daytodo.domain.user.enums.NotificationDeliveryStatus; +import com.daytodo.domain.user.push.PushNotificationGateway; +import com.daytodo.domain.user.repository.FcmTokenRepository; +import com.daytodo.domain.user.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class NotificationDeliveryWorker { + + private static final int MAX_ATTEMPTS = 5; + + private final NotificationRepository notificationRepository; + private final FcmTokenRepository fcmTokenRepository; + private final PushNotificationGateway pushGateway; + private final Clock clock; + + @Transactional + public void deliver(Long notificationId) { + Notification notification = notificationRepository.findByIdForDelivery(notificationId) + .orElse(null); + if (!isRetryable(notification)) { + return; + } + + var tokens = fcmTokenRepository.findAllByUser_Id(notification.getUser().getId()); + if (tokens.isEmpty()) { + notification.markFailed("등록된 FCM 토큰이 없습니다.", nextRetryAt(notification)); + return; + } + + boolean sent = false; + var errors = new ArrayList(); + for (FcmToken token : tokens) { + PushNotificationGateway.PushResult result = pushGateway.send( + token.getToken(), + notification.getTitle(), + notification.getBody(), + Map.of( + "courseId", String.valueOf(notification.getCourseId()), + "notificationType", notification.getNotificationType().name() + ) + ); + if (result.success()) { + sent = true; + } else { + if (result.invalidToken()) { + fcmTokenRepository.delete(token); + } + errors.add(result.errorMessage() == null ? "FCM 발송 실패" : result.errorMessage()); + } + } + + if (sent) { + notification.markSent(LocalDateTime.now(clock)); + } else { + notification.markFailed(String.join(" | ", errors), nextRetryAt(notification)); + } + } + + private boolean isRetryable(Notification notification) { + if (notification == null || notification.getAttemptCount() >= MAX_ATTEMPTS) { + return false; + } + if (notification.getDeliveryStatus() != NotificationDeliveryStatus.PENDING + && notification.getDeliveryStatus() != NotificationDeliveryStatus.FAILED) { + return false; + } + return notification.getNextRetryAt() == null + || !notification.getNextRetryAt().isAfter(LocalDateTime.now(clock)); + } + + private LocalDateTime nextRetryAt(Notification notification) { + long delayMinutes = 1L << Math.min(notification.getAttemptCount(), 5); + return LocalDateTime.now(clock).plusMinutes(delayMinutes); + } +} diff --git a/src/main/java/com/daytodo/domain/user/service/NotificationDispatchService.java b/src/main/java/com/daytodo/domain/user/service/NotificationDispatchService.java new file mode 100644 index 0000000..80fe7c7 --- /dev/null +++ b/src/main/java/com/daytodo/domain/user/service/NotificationDispatchService.java @@ -0,0 +1,37 @@ +package com.daytodo.domain.user.service; + +import com.daytodo.domain.user.enums.NotificationDeliveryStatus; +import com.daytodo.domain.user.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.EnumSet; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationDispatchService { + + private static final int MAX_ATTEMPTS = 5; + private static final int BATCH_SIZE = 100; + + private final NotificationRepository notificationRepository; + private final NotificationDeliveryWorker deliveryWorker; + private final Clock clock; + + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public void dispatchRetryable() { + notificationRepository.findRetryableIds( + EnumSet.of(NotificationDeliveryStatus.PENDING, NotificationDeliveryStatus.FAILED), + MAX_ATTEMPTS, + LocalDateTime.now(clock), + PageRequest.of(0, BATCH_SIZE) + ) + .forEach(deliveryWorker::deliver); + } +} diff --git a/src/main/java/com/daytodo/domain/user/service/NotificationEventCreator.java b/src/main/java/com/daytodo/domain/user/service/NotificationEventCreator.java new file mode 100644 index 0000000..5285ca8 --- /dev/null +++ b/src/main/java/com/daytodo/domain/user/service/NotificationEventCreator.java @@ -0,0 +1,32 @@ +package com.daytodo.domain.user.service; + +import com.daytodo.domain.user.entity.Notification; +import com.daytodo.domain.user.entity.User; +import com.daytodo.domain.user.enums.NotificationType; +import com.daytodo.domain.user.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class NotificationEventCreator { + + private final NotificationRepository notificationRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void create( + User user, + Long courseId, + NotificationType type, + String title, + String body + ) { + if (notificationRepository.existsByUser_IdAndCourseIdAndNotificationType( + user.getId(), courseId, type)) { + return; + } + notificationRepository.saveAndFlush(new Notification(user, courseId, type, title, body)); + } +} diff --git a/src/main/java/com/daytodo/domain/user/service/UserNotificationService.java b/src/main/java/com/daytodo/domain/user/service/UserNotificationService.java index 578f364..0bdb318 100644 --- a/src/main/java/com/daytodo/domain/user/service/UserNotificationService.java +++ b/src/main/java/com/daytodo/domain/user/service/UserNotificationService.java @@ -25,7 +25,7 @@ public UserResponse.NotificationSettings getSettings(Long userId) { requireActiveUser(userId); return settingRepository.findByUser_Id(userId) .map(this::toResponse) - .orElseGet(() -> new UserResponse.NotificationSettings(true, true, true)); + .orElseGet(() -> new UserResponse.NotificationSettings(true)); } @Transactional @@ -37,11 +37,7 @@ public UserResponse.NotificationSettings updateSettings( .orElseThrow(() -> new ProjectException(UserErrorCode.USER_NOT_FOUND)); UserNotificationSetting setting = settingRepository.findByUser_Id(userId) .orElseGet(() -> new UserNotificationSetting(user)); - setting.updateSettings( - request.pushEnabled(), - request.courseD1Enabled(), - request.courseD0Enabled() - ); + setting.updatePushEnabled(request.pushEnabled()); if (setting.getId() == null) { settingRepository.save(setting); } @@ -55,9 +51,7 @@ private void requireActiveUser(Long userId) { private UserResponse.NotificationSettings toResponse(UserNotificationSetting setting) { return new UserResponse.NotificationSettings( - setting.isPushEnabled(), - setting.isCourseD1Enabled(), - setting.isCourseD0Enabled() + setting.isPushEnabled() ); } } diff --git a/src/main/java/com/daytodo/global/config/FirebaseConfig.java b/src/main/java/com/daytodo/global/config/FirebaseConfig.java new file mode 100644 index 0000000..3b2bad4 --- /dev/null +++ b/src/main/java/com/daytodo/global/config/FirebaseConfig.java @@ -0,0 +1,34 @@ +package com.daytodo.global.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.IOException; + +@Configuration +@ConditionalOnProperty(prefix = "firebase", name = "enabled", havingValue = "true") +public class FirebaseConfig { + + @Bean + public FirebaseApp firebaseApp( + @Value("${firebase.project-id:}") String projectId + ) throws IOException { + FirebaseOptions.Builder builder = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.getApplicationDefault()); + if (!projectId.isBlank()) { + builder.setProjectId(projectId); + } + return FirebaseApp.initializeApp(builder.build()); + } + + @Bean + public FirebaseMessaging firebaseMessaging(FirebaseApp firebaseApp) { + return FirebaseMessaging.getInstance(firebaseApp); + } +} diff --git a/src/main/java/com/daytodo/global/config/SecurityConfig.java b/src/main/java/com/daytodo/global/config/SecurityConfig.java index 6b0260c..44d0345 100644 --- a/src/main/java/com/daytodo/global/config/SecurityConfig.java +++ b/src/main/java/com/daytodo/global/config/SecurityConfig.java @@ -99,8 +99,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/users/notifications" ).authenticated() .requestMatchers(HttpMethod.DELETE, "/users/me").authenticated() + .requestMatchers(HttpMethod.DELETE, "/users/fcm-token").authenticated() .requestMatchers(HttpMethod.POST, "/users/feedback", + "/users/fcm-token", "/courses", "/courses/join" ).authenticated() diff --git a/src/main/java/com/daytodo/global/config/TimeConfig.java b/src/main/java/com/daytodo/global/config/TimeConfig.java index 73ef29e..19a4412 100644 --- a/src/main/java/com/daytodo/global/config/TimeConfig.java +++ b/src/main/java/com/daytodo/global/config/TimeConfig.java @@ -2,13 +2,15 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.beans.factory.annotation.Value; import java.time.Clock; +import java.time.ZoneId; @Configuration public class TimeConfig { @Bean - public Clock applicationClock() { - return Clock.systemDefaultZone(); + public Clock applicationClock(@Value("${notification.zone:Asia/Seoul}") String zone) { + return Clock.system(ZoneId.of(zone)); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4dc8761..7847d6e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -67,3 +67,12 @@ aws: s3: bucket: ${AWS_S3_BUCKET:} region: ${AWS_REGION:} + +firebase: + enabled: ${FIREBASE_ENABLED:false} + project-id: ${FIREBASE_PROJECT_ID:} + +notification: + zone: ${NOTIFICATION_ZONE:Asia/Seoul} + course-reminder-cron: ${COURSE_REMINDER_CRON:0 0 9 * * *} + dispatch-delay-ms: ${NOTIFICATION_DISPATCH_DELAY_MS:60000} diff --git a/src/test/java/com/daytodo/DayTodoApplicationTests.java b/src/test/java/com/daytodo/DayTodoApplicationTests.java index ecf8f2f..a9638f6 100644 --- a/src/test/java/com/daytodo/DayTodoApplicationTests.java +++ b/src/test/java/com/daytodo/DayTodoApplicationTests.java @@ -2,10 +2,14 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.context.bean.override.mockito.MockitoBean; @SpringBootTest class DayTodoApplicationTests { + @MockitoBean JavaMailSender javaMailSender; + @Test void contextLoads() { } diff --git a/src/test/java/com/daytodo/domain/course/service/CourseServiceTest.java b/src/test/java/com/daytodo/domain/course/service/CourseServiceTest.java index c5144e0..6fb92bd 100644 --- a/src/test/java/com/daytodo/domain/course/service/CourseServiceTest.java +++ b/src/test/java/com/daytodo/domain/course/service/CourseServiceTest.java @@ -25,10 +25,6 @@ import com.daytodo.domain.user.entity.User; import com.daytodo.domain.user.enums.LoginType; import com.daytodo.domain.user.service.UserService; -import com.daytodo.domain.place.repository.PlaceRecommendationRepository; -import com.daytodo.domain.place.repository.RecommendationLikeRepository; -import com.daytodo.domain.place.repository.PlaceRepository; -import com.daytodo.domain.place.repository.RecommendationCommentRepository; import com.daytodo.global.apiPayload.exception.ProjectException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/daytodo/domain/user/controller/UserControllerTest.java b/src/test/java/com/daytodo/domain/user/controller/UserControllerTest.java index b1eba8e..fc2c9d6 100644 --- a/src/test/java/com/daytodo/domain/user/controller/UserControllerTest.java +++ b/src/test/java/com/daytodo/domain/user/controller/UserControllerTest.java @@ -5,6 +5,7 @@ import com.daytodo.domain.user.service.UserNotificationService; import com.daytodo.domain.user.service.FeedbackService; import com.daytodo.domain.user.service.PolicyService; +import com.daytodo.domain.user.service.FcmTokenService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -13,11 +14,14 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.mockito.Mockito.verify; @@ -28,6 +32,7 @@ class UserControllerTest { @Mock UserNotificationService notificationService; @Mock FeedbackService feedbackService; @Mock PolicyService policyService; + @Mock FcmTokenService fcmTokenService; MockMvc mockMvc; @BeforeEach @@ -37,7 +42,8 @@ void setUp() { profileService, notificationService, feedbackService, - policyService + policyService, + fcmTokenService )) .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) .build(); @@ -57,4 +63,41 @@ void delegatesUsingAuthenticatedPrincipal() throws Exception { .andExpect(status().isOk()); verify(userService).getProfile(1L); } + + @Test + void registersFcmTokenUsingAuthenticatedPrincipal() throws Exception { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(1L, null) + ); + + mockMvc.perform(post("/users/fcm-token") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"device-token\",\"platform\":\"ANDROID\"}")) + .andExpect(status().isNoContent()); + + verify(fcmTokenService).register( + 1L, + new com.daytodo.domain.user.dto.UserRequest.RegisterFcmToken( + "device-token", + com.daytodo.domain.user.enums.DevicePlatform.ANDROID + ) + ); + } + + @Test + void deletesFcmTokenUsingAuthenticatedPrincipal() throws Exception { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(1L, null) + ); + + mockMvc.perform(delete("/users/fcm-token") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"device-token\"}")) + .andExpect(status().isNoContent()); + + verify(fcmTokenService).delete( + 1L, + new com.daytodo.domain.user.dto.UserRequest.DeleteFcmToken("device-token") + ); + } } diff --git a/src/test/java/com/daytodo/domain/user/repository/FcmTokenRepositoryTest.java b/src/test/java/com/daytodo/domain/user/repository/FcmTokenRepositoryTest.java new file mode 100644 index 0000000..bc9269e --- /dev/null +++ b/src/test/java/com/daytodo/domain/user/repository/FcmTokenRepositoryTest.java @@ -0,0 +1,36 @@ +package com.daytodo.domain.user.repository; + +import com.daytodo.domain.user.entity.User; +import com.daytodo.domain.user.enums.LoginType; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest(properties = "spring.test.database.replace=none") +class FcmTokenRepositoryTest { + + @Autowired UserRepository userRepository; + @Autowired FcmTokenRepository fcmTokenRepository; + @Autowired EntityManager entityManager; + + @Test + void upsertMovesExistingTokenToCurrentUser() { + User firstUser = userRepository.save(new User( + "first@example.com", "password", "first", null, LoginType.LOCAL)); + User secondUser = userRepository.save(new User( + "second@example.com", "password", "second", null, LoginType.LOCAL)); + userRepository.flush(); + + fcmTokenRepository.upsert(firstUser.getId(), "token", "ANDROID"); + fcmTokenRepository.upsert(secondUser.getId(), "token", "IOS"); + entityManager.clear(); + + var token = fcmTokenRepository.findByToken("token").orElseThrow(); + assertThat(token.getUser().getId()).isEqualTo(secondUser.getId()); + assertThat(token.getPlatform().name()).isEqualTo("IOS"); + assertThat(fcmTokenRepository.count()).isEqualTo(1); + } +} diff --git a/src/test/java/com/daytodo/domain/user/service/CourseReminderServiceTest.java b/src/test/java/com/daytodo/domain/user/service/CourseReminderServiceTest.java new file mode 100644 index 0000000..017b294 --- /dev/null +++ b/src/test/java/com/daytodo/domain/user/service/CourseReminderServiceTest.java @@ -0,0 +1,105 @@ +package com.daytodo.domain.user.service; + +import com.daytodo.domain.course.entity.Course; +import com.daytodo.domain.course.entity.CourseMember; +import com.daytodo.domain.course.enums.CourseStatus; +import com.daytodo.domain.course.enums.MemberRole; +import com.daytodo.domain.course.enums.MemberStatus; +import com.daytodo.domain.course.enums.ParticipantType; +import com.daytodo.domain.course.repository.CourseMemberRepository; +import com.daytodo.domain.user.entity.FcmToken; +import com.daytodo.domain.user.entity.User; +import com.daytodo.domain.user.entity.UserNotificationSetting; +import com.daytodo.domain.user.enums.DevicePlatform; +import com.daytodo.domain.user.enums.LoginType; +import com.daytodo.domain.user.enums.NotificationType; +import com.daytodo.domain.user.enums.UserStatus; +import com.daytodo.domain.user.repository.FcmTokenRepository; +import com.daytodo.domain.user.repository.UserNotificationSettingRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.Collection; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CourseReminderServiceTest { + + private static final LocalDate TODAY = LocalDate.of(2026, 8, 6); + + @Mock CourseMemberRepository courseMemberRepository; + @Mock UserNotificationSettingRepository settingRepository; + @Mock FcmTokenRepository fcmTokenRepository; + @Mock NotificationEventCreator eventCreator; + + @Test + void createsD1ReminderForUserWithDefaultSettingsAndToken() { + User user = user(); + Course course = course(user, TODAY.plusDays(1)); + CourseMember member = new CourseMember(course, user, MemberRole.OWNER, MemberStatus.JOINED); + when(courseMemberRepository.findReminderCandidates( + eq(TODAY.plusDays(1)), any(Collection.class), eq(MemberStatus.JOINED), eq(UserStatus.ACTIVE))) + .thenReturn(List.of(member)); + when(courseMemberRepository.findReminderCandidates( + eq(TODAY), any(Collection.class), eq(MemberStatus.JOINED), eq(UserStatus.ACTIVE))) + .thenReturn(List.of()); + when(settingRepository.findAllByUser_IdIn(any())).thenReturn(List.of()); + when(fcmTokenRepository.findAllByUser_IdIn(any())) + .thenReturn(List.of(new FcmToken(user, "token", DevicePlatform.ANDROID))); + + service().createDailyReminders(TODAY); + + verify(eventCreator).create( + eq(user), eq(10L), eq(NotificationType.COURSE_D1), any(String.class), any(String.class)); + } + + @Test + void skipsReminderWhenMasterPushIsDisabled() { + User user = user(); + Course course = course(user, TODAY.plusDays(1)); + CourseMember member = new CourseMember(course, user, MemberRole.OWNER, MemberStatus.JOINED); + UserNotificationSetting setting = new UserNotificationSetting(user); + setting.updatePushEnabled(false); + when(courseMemberRepository.findReminderCandidates( + eq(TODAY.plusDays(1)), any(Collection.class), eq(MemberStatus.JOINED), eq(UserStatus.ACTIVE))) + .thenReturn(List.of(member)); + when(courseMemberRepository.findReminderCandidates( + eq(TODAY), any(Collection.class), eq(MemberStatus.JOINED), eq(UserStatus.ACTIVE))) + .thenReturn(List.of()); + when(settingRepository.findAllByUser_IdIn(any())).thenReturn(List.of(setting)); + when(fcmTokenRepository.findAllByUser_IdIn(any())) + .thenReturn(List.of(new FcmToken(user, "token", DevicePlatform.ANDROID))); + + service().createDailyReminders(TODAY); + + verify(eventCreator, never()).create(any(), any(), any(), any(), any()); + } + + private CourseReminderService service() { + return new CourseReminderService( + courseMemberRepository, settingRepository, fcmTokenRepository, eventCreator); + } + + private User user() { + User user = new User("user@example.com", "password", "user", null, LoginType.LOCAL); + ReflectionTestUtils.setField(user, "id", 1L); + return user; + } + + private Course course(User user, LocalDate date) { + Course course = new Course(user, null, "데이트", date, 10000, 20000, ParticipantType.COUPLE); + ReflectionTestUtils.setField(course, "courseId", 10L); + ReflectionTestUtils.setField(course, "courseStatus", CourseStatus.PLANNING); + return course; + } +} diff --git a/src/test/java/com/daytodo/domain/user/service/FcmTokenServiceTest.java b/src/test/java/com/daytodo/domain/user/service/FcmTokenServiceTest.java new file mode 100644 index 0000000..f983b95 --- /dev/null +++ b/src/test/java/com/daytodo/domain/user/service/FcmTokenServiceTest.java @@ -0,0 +1,71 @@ +package com.daytodo.domain.user.service; + +import com.daytodo.domain.user.dto.UserRequest; +import com.daytodo.domain.user.entity.User; +import com.daytodo.domain.user.enums.DevicePlatform; +import com.daytodo.domain.user.enums.LoginType; +import com.daytodo.domain.user.enums.UserStatus; +import com.daytodo.domain.user.repository.FcmTokenRepository; +import com.daytodo.domain.user.repository.UserNotificationSettingRepository; +import com.daytodo.domain.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FcmTokenServiceTest { + + @Mock UserRepository userRepository; + @Mock UserNotificationSettingRepository settingRepository; + @Mock FcmTokenRepository fcmTokenRepository; + + @Test + void registersNewTokenAndCreatesDefaultSettings() { + User user = user(1L); + when(userRepository.findActiveUserForUpdate(1L, UserStatus.ACTIVE)).thenReturn(Optional.of(user)); + when(settingRepository.findByUser_Id(1L)).thenReturn(Optional.empty()); + + FcmTokenService service = new FcmTokenService(userRepository, settingRepository, fcmTokenRepository); + service.register(1L, new UserRequest.RegisterFcmToken("token", DevicePlatform.ANDROID)); + + verify(settingRepository).save(org.mockito.ArgumentMatchers.any()); + verify(fcmTokenRepository).upsert(1L, "token", "ANDROID"); + } + + @Test + void movesExistingTokenToCurrentUser() { + User currentUser = user(1L); + when(userRepository.findActiveUserForUpdate(1L, UserStatus.ACTIVE)).thenReturn(Optional.of(currentUser)); + when(settingRepository.findByUser_Id(1L)) + .thenReturn(Optional.of(new com.daytodo.domain.user.entity.UserNotificationSetting(currentUser))); + + FcmTokenService service = new FcmTokenService(userRepository, settingRepository, fcmTokenRepository); + service.register(1L, new UserRequest.RegisterFcmToken("token", DevicePlatform.IOS)); + + verify(fcmTokenRepository).upsert(1L, "token", "IOS"); + } + + @Test + void deletesOnlyTokenOwnedByCurrentUser() { + User user = user(1L); + when(userRepository.findByIdAndUserStatus(1L, UserStatus.ACTIVE)).thenReturn(Optional.of(user)); + + FcmTokenService service = new FcmTokenService(userRepository, settingRepository, fcmTokenRepository); + service.delete(1L, new UserRequest.DeleteFcmToken("token")); + + verify(fcmTokenRepository).deleteByTokenAndUser_Id("token", 1L); + } + + private User user(Long id) { + User user = new User("user" + id + "@example.com", "password", "user" + id, null, LoginType.LOCAL); + ReflectionTestUtils.setField(user, "id", id); + return user; + } +} diff --git a/src/test/java/com/daytodo/domain/user/service/NotificationDeliveryWorkerTest.java b/src/test/java/com/daytodo/domain/user/service/NotificationDeliveryWorkerTest.java new file mode 100644 index 0000000..436162d --- /dev/null +++ b/src/test/java/com/daytodo/domain/user/service/NotificationDeliveryWorkerTest.java @@ -0,0 +1,86 @@ +package com.daytodo.domain.user.service; + +import com.daytodo.domain.user.entity.FcmToken; +import com.daytodo.domain.user.entity.Notification; +import com.daytodo.domain.user.entity.User; +import com.daytodo.domain.user.enums.DevicePlatform; +import com.daytodo.domain.user.enums.LoginType; +import com.daytodo.domain.user.enums.NotificationDeliveryStatus; +import com.daytodo.domain.user.enums.NotificationType; +import com.daytodo.domain.user.push.PushNotificationGateway; +import com.daytodo.domain.user.repository.FcmTokenRepository; +import com.daytodo.domain.user.repository.NotificationRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +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.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NotificationDeliveryWorkerTest { + + private static final Clock CLOCK = Clock.fixed( + Instant.parse("2026-08-06T00:00:00Z"), ZoneId.of("Asia/Seoul")); + + @Mock NotificationRepository notificationRepository; + @Mock FcmTokenRepository fcmTokenRepository; + @Mock PushNotificationGateway pushGateway; + + @Test + void marksNotificationSentWhenAtLeastOneDeviceSucceeds() { + Notification notification = notification(); + FcmToken token = new FcmToken(notification.getUser(), "token", DevicePlatform.ANDROID); + when(notificationRepository.findByIdForDelivery(100L)).thenReturn(Optional.of(notification)); + when(fcmTokenRepository.findAllByUser_Id(1L)).thenReturn(List.of(token)); + when(pushGateway.send(eq("token"), any(), any(), any())) + .thenReturn(PushNotificationGateway.PushResult.sent()); + + worker().deliver(100L); + + assertThat(notification.getDeliveryStatus()).isEqualTo(NotificationDeliveryStatus.SENT); + assertThat(notification.getAttemptCount()).isEqualTo(1); + assertThat(notification.getSentAt()).isNotNull(); + } + + @Test + void removesInvalidTokenAndSchedulesRetry() { + Notification notification = notification(); + FcmToken token = new FcmToken(notification.getUser(), "invalid", DevicePlatform.IOS); + when(notificationRepository.findByIdForDelivery(100L)).thenReturn(Optional.of(notification)); + when(fcmTokenRepository.findAllByUser_Id(1L)).thenReturn(List.of(token)); + when(pushGateway.send(eq("invalid"), any(), any(), any())) + .thenReturn(PushNotificationGateway.PushResult.failed(true, "unregistered")); + + worker().deliver(100L); + + verify(fcmTokenRepository).delete(token); + assertThat(notification.getDeliveryStatus()).isEqualTo(NotificationDeliveryStatus.FAILED); + assertThat(notification.getNextRetryAt()).isNotNull(); + } + + private NotificationDeliveryWorker worker() { + return new NotificationDeliveryWorker( + notificationRepository, fcmTokenRepository, pushGateway, CLOCK); + } + + private Notification notification() { + User user = new User("user@example.com", "password", "user", null, LoginType.LOCAL); + ReflectionTestUtils.setField(user, "id", 1L); + Notification notification = new Notification( + user, 10L, NotificationType.COURSE_D0, "title", "body"); + ReflectionTestUtils.setField(notification, "id", 100L); + return notification; + } +} diff --git a/src/test/java/com/daytodo/domain/user/service/NotificationDispatchServiceTest.java b/src/test/java/com/daytodo/domain/user/service/NotificationDispatchServiceTest.java new file mode 100644 index 0000000..c01fe00 --- /dev/null +++ b/src/test/java/com/daytodo/domain/user/service/NotificationDispatchServiceTest.java @@ -0,0 +1,21 @@ +package com.daytodo.domain.user.service; + +import org.junit.jupiter.api.Test; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.TransactionDefinition; + +import static org.assertj.core.api.Assertions.assertThat; + +class NotificationDispatchServiceTest { + + @Test + void dispatchDoesNotExposeReadOnlyTransactionToDeliveryWorker() throws Exception { + var method = NotificationDispatchService.class.getMethod("dispatchRetryable"); + var attribute = new AnnotationTransactionAttributeSource() + .getTransactionAttribute(method, NotificationDispatchService.class); + + assertThat(attribute).isNotNull(); + assertThat(attribute.getPropagationBehavior()) + .isEqualTo(TransactionDefinition.PROPAGATION_NOT_SUPPORTED); + } +} diff --git a/src/test/java/com/daytodo/domain/user/service/UserNotificationServiceTest.java b/src/test/java/com/daytodo/domain/user/service/UserNotificationServiceTest.java index 1648c37..caf910a 100644 --- a/src/test/java/com/daytodo/domain/user/service/UserNotificationServiceTest.java +++ b/src/test/java/com/daytodo/domain/user/service/UserNotificationServiceTest.java @@ -37,7 +37,7 @@ void getReturnsDefaultsWithoutSavingWhenSettingDoesNotExist() { UserNotificationService service = new UserNotificationService(userRepository, settingRepository); UserResponse.NotificationSettings response = service.getSettings(1L); - assertThat(response).isEqualTo(new UserResponse.NotificationSettings(true, true, true)); + assertThat(response).isEqualTo(new UserResponse.NotificationSettings(true)); verify(settingRepository, never()).save(org.mockito.ArgumentMatchers.any()); } @@ -51,13 +51,32 @@ void patchCreatesMissingSettingWithRequestedValues() { UserNotificationService service = new UserNotificationService(userRepository, settingRepository); UserResponse.NotificationSettings response = service.updateSettings( 1L, - new UserRequest.UpdateNotificationSettings(false, true, false) + new UserRequest.UpdateNotificationSettings(false) ); - assertThat(response).isEqualTo(new UserResponse.NotificationSettings(false, true, false)); + assertThat(response).isEqualTo(new UserResponse.NotificationSettings(false)); verify(settingRepository).save(org.mockito.ArgumentMatchers.any(UserNotificationSetting.class)); } + @Test + void patchUpdatesMasterPushSetting() { + User user = user(); + UserNotificationSetting setting = new UserNotificationSetting(user); + ReflectionTestUtils.setField(setting, "id", 10L); + when(userRepository.findActiveUserForUpdate(1L, UserStatus.ACTIVE)) + .thenReturn(Optional.of(user)); + when(settingRepository.findByUser_Id(1L)).thenReturn(Optional.of(setting)); + + UserNotificationService service = new UserNotificationService(userRepository, settingRepository); + UserResponse.NotificationSettings response = service.updateSettings( + 1L, + new UserRequest.UpdateNotificationSettings(false) + ); + + assertThat(response).isEqualTo(new UserResponse.NotificationSettings(false)); + verify(settingRepository, never()).save(org.mockito.ArgumentMatchers.any()); + } + private User user() { User user = new User("user@example.com", "password", "daytodo", null, LoginType.LOCAL); ReflectionTestUtils.setField(user, "id", 1L); diff --git a/src/test/java/com/daytodo/domain/user/service/WithdrawnUserDeletionIntegrationTest.java b/src/test/java/com/daytodo/domain/user/service/WithdrawnUserDeletionIntegrationTest.java index 1729c80..095970c 100644 --- a/src/test/java/com/daytodo/domain/user/service/WithdrawnUserDeletionIntegrationTest.java +++ b/src/test/java/com/daytodo/domain/user/service/WithdrawnUserDeletionIntegrationTest.java @@ -13,14 +13,19 @@ import com.daytodo.domain.region.enums.RegionLevel; import com.daytodo.domain.region.repository.RegionRepository; import com.daytodo.domain.user.entity.User; +import com.daytodo.domain.user.entity.FcmToken; +import com.daytodo.domain.user.enums.DevicePlatform; import com.daytodo.domain.user.enums.LoginType; import com.daytodo.domain.user.repository.UserRepository; +import com.daytodo.domain.user.repository.FcmTokenRepository; import com.daytodo.domain.user.repository.WithdrawnUserCleanupRepository; import jakarta.persistence.EntityManager; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.transaction.annotation.Transactional; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.time.Clock; import java.time.Instant; @@ -39,7 +44,9 @@ class WithdrawnUserDeletionIntegrationTest { @Autowired CourseMemberRepository courseMemberRepository; @Autowired InviteCodeRepository inviteCodeRepository; @Autowired WithdrawnUserCleanupRepository cleanupRepository; + @Autowired FcmTokenRepository fcmTokenRepository; @Autowired EntityManager entityManager; + @MockitoBean JavaMailSender javaMailSender; @Test void permanentlyDeletesUserWithoutDeletingSharedCourse() { @@ -58,6 +65,7 @@ void permanentlyDeletesUserWithoutDeletingSharedCourse() { )); courseMemberRepository.save(new CourseMember(course, owner, MemberRole.OWNER, MemberStatus.JOINED)); courseMemberRepository.save(new CourseMember(course, member, MemberRole.MEMBER, MemberStatus.JOINED)); + fcmTokenRepository.save(new FcmToken(owner, "owner-token", DevicePlatform.ANDROID)); inviteCodeRepository.save(new InviteCode( course, owner, @@ -80,6 +88,7 @@ void permanentlyDeletesUserWithoutDeletingSharedCourse() { assertThat(deleted).isOne(); assertThat(userRepository.findById(owner.getId())).isEmpty(); + assertThat(fcmTokenRepository.findByToken("owner-token")).isEmpty(); Course preserved = courseRepository.findById(course.getCourseId()).orElseThrow(); assertThat(preserved.getOwner()).isNull(); assertThat(courseMemberRepository.findByCourseCourseIdAndUserId(course.getCourseId(), member.getId())) diff --git a/src/test/java/com/daytodo/global/config/EnvironmentExampleTest.java b/src/test/java/com/daytodo/global/config/EnvironmentExampleTest.java new file mode 100644 index 0000000..9e46ff9 --- /dev/null +++ b/src/test/java/com/daytodo/global/config/EnvironmentExampleTest.java @@ -0,0 +1,20 @@ +package com.daytodo.global.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.env.PropertiesPropertySourceLoader; +import org.springframework.core.io.FileSystemResource; + +import static org.assertj.core.api.Assertions.assertThat; + +class EnvironmentExampleTest { + + @Test + void courseReminderCronIsLoadedAsAPropertiesValue() throws Exception { + var propertySource = new PropertiesPropertySourceLoader() + .load("env", new FileSystemResource(".env.example")) + .get(0); + + assertThat(propertySource.getProperty("COURSE_REMINDER_CRON")) + .isEqualTo("0 0 9 * * *"); + } +} diff --git a/src/test/java/com/daytodo/global/config/TargetApiSecurityTest.java b/src/test/java/com/daytodo/global/config/TargetApiSecurityTest.java index fedab92..d827585 100644 --- a/src/test/java/com/daytodo/global/config/TargetApiSecurityTest.java +++ b/src/test/java/com/daytodo/global/config/TargetApiSecurityTest.java @@ -11,6 +11,7 @@ import com.daytodo.domain.user.service.ProfileService; import com.daytodo.domain.user.service.UserNotificationService; import com.daytodo.domain.user.service.UserService; +import com.daytodo.domain.user.service.FcmTokenService; import com.daytodo.global.security.JwtTokenProvider; import tools.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; @@ -56,6 +57,7 @@ class TargetApiSecurityTest { @MockitoBean UserNotificationService notificationService; @MockitoBean FeedbackService feedbackService; @MockitoBean PolicyService policyService; + @MockitoBean FcmTokenService fcmTokenService; @MockitoBean CourseService courseService; @MockitoBean CourseAiRecommendationService courseAiRecommendationService; @MockitoBean JpaMetamodelMappingContext jpaMetamodelMappingContext;