From 0196c5e9fa882840c4725111b2aaf15beffc0bfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9C=A0=EC=A0=95?= Date: Fri, 20 Dec 2024 21:27:20 +0900 Subject: [PATCH] =?UTF-8?q?=08fix:=20fcm=20=ED=91=B8=EC=8B=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 빈 토큰 리스트 호출 오류 수정 * fix: 로그인 시 현재 계정으로 미션 구독 * refactor: fetch join 변경 * rename: 함수명 변경 * fix: fetch join 적용 --- .../device/DeviceSubscriptionService.java | 41 ++++++-- .../DeviceSubscriptionEventHandler.java | 1 + .../firebase/TopicSubscriberImpl.java | 8 ++ .../goalpanzi/domain/device/Devices.java | 8 ++ .../device/repository/DeviceRepository.java | 4 +- .../MissionVerificationRepository.java | 8 +- .../device/DeviceSubscriptionServiceTest.java | 99 +++++++++++++++++-- .../firebase/TopicSubscriberImplTest.java | 37 +++++++ .../repository/DeviceRepositoryTest.java | 39 ++++++++ 9 files changed, 227 insertions(+), 18 deletions(-) create mode 100644 src/test/java/com/nexters/goalpanzi/application/firebase/TopicSubscriberImplTest.java create mode 100644 src/test/java/com/nexters/goalpanzi/domain/device/repository/DeviceRepositoryTest.java diff --git a/src/main/java/com/nexters/goalpanzi/application/device/DeviceSubscriptionService.java b/src/main/java/com/nexters/goalpanzi/application/device/DeviceSubscriptionService.java index fbf99e9e..6f1fa9d3 100644 --- a/src/main/java/com/nexters/goalpanzi/application/device/DeviceSubscriptionService.java +++ b/src/main/java/com/nexters/goalpanzi/application/device/DeviceSubscriptionService.java @@ -87,6 +87,31 @@ private List findTopicSubscribers(final Long missionId) { .toList(); } + /** + * 로그인 시 내 미션 구독 시작 + * + * @param memberId 멤버 아이디 + * @param deviceIdentifier 디바이스 식별자 + */ + @Transactional + public void subscribeToMyMissions(final Long memberId, final String deviceIdentifier) { + Device device = deviceRepository.getDevice(memberId, deviceIdentifier); + List topics = findMySubscribedTopics(device.getId()); + List missions = missionRepository.findAllById( + findMySubscribableMission(memberId, topics) + ); + + topics.forEach(topic -> + topicSubscriber.subscribeToTopic(List.of(device.getDeviceToken()), topic) + ); + missions.forEach(mission -> { + deviceSubscriptionRepository.save(new DeviceSubscription(device, mission)); + + String topic = TopicGenerator.getTopic(mission.getId()); + topicSubscriber.subscribeToTopic(List.of(device.getDeviceToken()), topic); + }); + } + /** * 디바이스 토큰을 갱신하거나 푸시 알림 활성화 시 새로운 디바이스 토큰으로 내 미션 구독 시작
* + UpdateMissionRetryPushMessageEvent를 통해 예약된 메시지의 디바이스 토큰 갱신 @@ -120,13 +145,13 @@ public void subscribeToMyMissions(final Long memberId, final Long deviceId) { * 로그인 시 기존 디바이스가 구독한 미션 구독 취소 * + CancelMissionRetryPushMessageEvent를 통해 예약된 메시지 취소 * - * @param memberId 멤버 아이디 + * @param memberId (현재 로그인한) 멤버 아이디 * @param deviceIdentifier 디바이스 식별자 */ @Transactional public void unsubscribeFromMyMissions(final Long memberId, final String deviceIdentifier) { Devices devices = new Devices( - deviceRepository.findAllByDeviceIdentifier(deviceIdentifier) + deviceRepository.findAllWithMemberByDeviceIdentifier(deviceIdentifier) ); List topics = devices.getActivatedDevices().stream() .flatMap(device -> findMySubscribedTopics(device.getId()).stream()) @@ -135,9 +160,13 @@ public void unsubscribeFromMyMissions(final Long memberId, final String deviceId topics.forEach(topic -> topicSubscriber.unsubscribeFromTopic(devices.getActivatedDeviceTokens(), topic) ); - eventPublisher.publishEvent( - new CancelMissionRetryPushMessageEvent(memberId) - ); + + devices.getFilteredMemberIds(memberId) + .forEach(it -> + eventPublisher.publishEvent( + new CancelMissionRetryPushMessageEvent(it) + ) + ); } /** @@ -172,7 +201,7 @@ private List findMySubscribableMission(final Long memberId, List t List missionMembers = missionMemberRepository.findAllWithMissionByMemberId(memberId); List filteredMissionMembers = missionMembers.stream() .filter(this::isSubscribableMission) - .filter(it -> isAlreadySubscribedMission(topicFilter, it.getMission().getId())) + .filter(it -> !isAlreadySubscribedMission(topicFilter, it.getMission().getId())) .toList(); return filteredMissionMembers.stream() diff --git a/src/main/java/com/nexters/goalpanzi/application/device/event/handler/DeviceSubscriptionEventHandler.java b/src/main/java/com/nexters/goalpanzi/application/device/event/handler/DeviceSubscriptionEventHandler.java index 42d49280..628e25c0 100644 --- a/src/main/java/com/nexters/goalpanzi/application/device/event/handler/DeviceSubscriptionEventHandler.java +++ b/src/main/java/com/nexters/goalpanzi/application/device/event/handler/DeviceSubscriptionEventHandler.java @@ -48,6 +48,7 @@ void handleUpdatePushActivationStatusEvent(final UpdatePushActivationStatusEvent @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) void handleLoginEvent(final LoginEvent event) { deviceSubscriptionService.unsubscribeFromMyMissions(event.memberId(), event.deviceIdentifier()); + deviceSubscriptionService.subscribeToMyMissions(event.memberId(), event.deviceIdentifier()); log.info("Handled LoginEvent for memberId: {}", event.memberId()); } diff --git a/src/main/java/com/nexters/goalpanzi/application/firebase/TopicSubscriberImpl.java b/src/main/java/com/nexters/goalpanzi/application/firebase/TopicSubscriberImpl.java index a3fe0bae..72a76ee1 100644 --- a/src/main/java/com/nexters/goalpanzi/application/firebase/TopicSubscriberImpl.java +++ b/src/main/java/com/nexters/goalpanzi/application/firebase/TopicSubscriberImpl.java @@ -13,6 +13,10 @@ public class TopicSubscriberImpl implements TopicSubscriber { public void subscribeToTopic(final List registrationTokens, final String topic) { + if (registrationTokens.isEmpty()) { + return; + } + try { FirebaseMessaging.getInstance().subscribeToTopic(registrationTokens, topic); } catch (FirebaseMessagingException e) { @@ -21,6 +25,10 @@ public void subscribeToTopic(final List registrationTokens, final String } public void unsubscribeFromTopic(final List registrationTokens, final String topic) { + if (registrationTokens.isEmpty()) { + return; + } + try { FirebaseMessaging.getInstance().unsubscribeFromTopic(registrationTokens, topic); } catch (FirebaseMessagingException e) { diff --git a/src/main/java/com/nexters/goalpanzi/domain/device/Devices.java b/src/main/java/com/nexters/goalpanzi/domain/device/Devices.java index eb654079..8a74ec87 100644 --- a/src/main/java/com/nexters/goalpanzi/domain/device/Devices.java +++ b/src/main/java/com/nexters/goalpanzi/domain/device/Devices.java @@ -21,10 +21,18 @@ public List getActivatedDeviceTokens() { .toList(); } + // TODO: 추후 불필요하면 삭제 public List getDeactivatedDeviceTokens() { return devices.stream() .filter(it -> !it.getPushActivationStatus()) .map(Device::getDeviceToken) .toList(); } + + public List getFilteredMemberIds(final Long excludedMemberId) { + return devices.stream() + .filter(it -> it.getMember().getId() != excludedMemberId) + .map(it -> it.getMember().getId()) + .toList(); + } } diff --git a/src/main/java/com/nexters/goalpanzi/domain/device/repository/DeviceRepository.java b/src/main/java/com/nexters/goalpanzi/domain/device/repository/DeviceRepository.java index 9785a92c..5e2c037d 100644 --- a/src/main/java/com/nexters/goalpanzi/domain/device/repository/DeviceRepository.java +++ b/src/main/java/com/nexters/goalpanzi/domain/device/repository/DeviceRepository.java @@ -4,6 +4,7 @@ import com.nexters.goalpanzi.exception.ErrorCode; import com.nexters.goalpanzi.exception.NotFoundException; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import java.util.List; import java.util.Optional; @@ -14,7 +15,8 @@ public interface DeviceRepository extends JpaRepository { List findAllByMemberId(final Long memberId); - List findAllByDeviceIdentifier(final String deviceIdentifier); + @Query("SELECT d FROM Device d JOIN FETCH d.member WHERE d.deviceIdentifier = :deviceIdentifier") + List findAllWithMemberByDeviceIdentifier(final String deviceIdentifier); boolean existsByDeviceIdentifier(final String deviceIdentifier); diff --git a/src/main/java/com/nexters/goalpanzi/domain/mission/repository/MissionVerificationRepository.java b/src/main/java/com/nexters/goalpanzi/domain/mission/repository/MissionVerificationRepository.java index f3220543..cf9ee4cc 100644 --- a/src/main/java/com/nexters/goalpanzi/domain/mission/repository/MissionVerificationRepository.java +++ b/src/main/java/com/nexters/goalpanzi/domain/mission/repository/MissionVerificationRepository.java @@ -18,10 +18,14 @@ public interface MissionVerificationRepository extends JpaRepository findByMemberIdAndMissionIdAndBoardNumber(final Long memberId, final Long missionId, final Integer boardNumber); - @Query("SELECT mv FROM MissionVerification mv WHERE mv.mission.id = :missionId AND DATE(mv.createdAt) = :date") + @Query("SELECT mv FROM MissionVerification mv" + + " JOIN FETCH mv.mission ms" + + " WHERE ms.id = :missionId AND DATE(mv.createdAt) = :date") List findAllByMissionIdAndDate(final Long missionId, final LocalDate date); - @Query("SELECT mv FROM MissionVerification mv WHERE mv.member.id = :memberId AND mv.mission.id = :missionId AND DATE(mv.createdAt) = :date") + @Query("SELECT mv FROM MissionVerification mv" + + " JOIN FETCH mv.member mb JOIN FETCH mv.mission ms" + + " WHERE mb.id = :memberId AND ms.id = :missionId AND DATE(mv.createdAt) = :date") Optional findByMemberIdAndMissionIdAndDate(Long memberId, Long missionId, LocalDate date); default MissionVerification getMyVerification(final Long memberId, final Long missionId, final Integer boardNumber) { diff --git a/src/test/java/com/nexters/goalpanzi/application/device/DeviceSubscriptionServiceTest.java b/src/test/java/com/nexters/goalpanzi/application/device/DeviceSubscriptionServiceTest.java index 0f8af457..4ce52555 100644 --- a/src/test/java/com/nexters/goalpanzi/application/device/DeviceSubscriptionServiceTest.java +++ b/src/test/java/com/nexters/goalpanzi/application/device/DeviceSubscriptionServiceTest.java @@ -6,8 +6,13 @@ import com.nexters.goalpanzi.domain.device.DeviceSubscription; import com.nexters.goalpanzi.domain.device.repository.DeviceRepository; import com.nexters.goalpanzi.domain.device.repository.DeviceSubscriptionRepository; +import com.nexters.goalpanzi.domain.member.Member; import com.nexters.goalpanzi.domain.mission.Mission; +import com.nexters.goalpanzi.domain.mission.MissionMember; +import com.nexters.goalpanzi.domain.mission.repository.MissionMemberRepository; +import com.nexters.goalpanzi.domain.mission.repository.MissionRepository; import com.nexters.goalpanzi.infrastructure.firebase.TopicSubscriber; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -16,6 +21,8 @@ import java.util.List; +import static com.nexters.goalpanzi.domain.mission.MissionStatus.CREATED; +import static com.nexters.goalpanzi.fixture.DeviceFixture.DEVICE_IDENTIFIER; import static com.nexters.goalpanzi.fixture.DeviceFixture.DEVICE_TOKEN; import static com.nexters.goalpanzi.fixture.MemberFixture.MEMBER_ID; import static org.mockito.Mockito.*; @@ -35,23 +42,39 @@ class DeviceSubscriptionServiceTest { @MockBean private DeviceSubscriptionRepository deviceSubscriptionRepository; + @MockBean + private MissionRepository missionRepository; + + @MockBean + private MissionMemberRepository missionMemberRepository; + @MockBean private TopicSubscriber topicSubscriber; - private static Long MISSION_ID = 1L; + private static final Long MISSION_ID = 1L; + private static final Long DEVICE_ID = 1L; + + private Mission MOCK_MISSION; + private Member MOCK_MEMBER; + + @BeforeEach + void setUp() { + MOCK_MISSION = mock(Mission.class); + when(MOCK_MISSION.getId()).thenReturn(MISSION_ID); + + MOCK_MEMBER = mock(Member.class); + when(MOCK_MEMBER.getId()).thenReturn(MEMBER_ID); + } @Test void 멤버의_디바이스_중_알림이_활성화된_디바이스_토큰은_미션을_구독한다() { - Mission mockMission = mock(Mission.class); - when(mockMission.getId()).thenReturn(MISSION_ID); - Device mockDevice = mock(Device.class); when(mockDevice.getDeviceToken()).thenReturn(DEVICE_TOKEN); when(mockDevice.getPushActivationStatus()).thenReturn(true); when(deviceRepository.findAllByMemberId(MEMBER_ID)).thenReturn(List.of(mockDevice)); - deviceSubscriptionService.subscribeToMission(MEMBER_ID, mockMission); + deviceSubscriptionService.subscribeToMission(MEMBER_ID, MOCK_MISSION); verify(topicSubscriber) .subscribeToTopic(List.of(DEVICE_TOKEN), TopicGenerator.getTopic(MISSION_ID)); @@ -59,15 +82,12 @@ class DeviceSubscriptionServiceTest { @Test void 멤버의_디바이스_중_알림이_비활성화된_디바이스_토큰은_미션을_구독하지_않는다() { - Mission mockMission = mock(Mission.class); - when(mockMission.getId()).thenReturn(MISSION_ID); - Device mockDevice = mock(Device.class); when(mockDevice.getPushActivationStatus()).thenReturn(false); when(deviceRepository.findAllByMemberId(MEMBER_ID)).thenReturn(List.of(mockDevice)); - deviceSubscriptionService.subscribeToMission(MEMBER_ID, mockMission); + deviceSubscriptionService.subscribeToMission(MEMBER_ID, MOCK_MISSION); verify(topicSubscriber) .subscribeToTopic(List.of(), TopicGenerator.getTopic(MISSION_ID)); @@ -89,4 +109,65 @@ class DeviceSubscriptionServiceTest { verify(topicSubscriber) .unsubscribeFromTopic(List.of(DEVICE_TOKEN), TopicGenerator.getTopic(MISSION_ID)); } + + @Test + void 구독했거나_구독_가능한_미션을_찾아_구독을_시작한다() { + Long SUBSCRIBED_MISSION_ID = 1L; + Long UNSUBSCRIBED_MISSION_ID = 2L; + + Mission mockSubscribedMission = mock(Mission.class); + when(mockSubscribedMission.getId()).thenReturn(SUBSCRIBED_MISSION_ID); + + Mission mockUnsubscribedMission = mock(Mission.class); + when(mockUnsubscribedMission.getId()).thenReturn(UNSUBSCRIBED_MISSION_ID); + + MissionMember mockMissionMember = mock(MissionMember.class); + when(mockMissionMember.getMissionStatus()).thenReturn(CREATED); + when(mockMissionMember.getMission()).thenReturn(mockUnsubscribedMission); + + Device mockDevice = mock(Device.class); + when(mockDevice.getId()).thenReturn(DEVICE_ID); + when(mockDevice.getDeviceToken()).thenReturn(DEVICE_TOKEN); + + DeviceSubscription mockDeviceSubscription = mock(DeviceSubscription.class); + when(mockDeviceSubscription.getMission()).thenReturn(mockSubscribedMission); + + when(deviceRepository.getDevice(MEMBER_ID, DEVICE_IDENTIFIER)).thenReturn(mockDevice); + when(deviceSubscriptionRepository.findAllWithMissionAndDeviceByDeviceId(DEVICE_ID)) + .thenReturn(List.of(mockDeviceSubscription)); + + when(missionMemberRepository.findAllWithMissionByMemberId(MEMBER_ID)) + .thenReturn(List.of(mockMissionMember)); + when(missionRepository.findAllById(List.of(UNSUBSCRIBED_MISSION_ID))) + .thenReturn(List.of(mockUnsubscribedMission)); + + deviceSubscriptionService.subscribeToMyMissions(MEMBER_ID, DEVICE_IDENTIFIER); + + verify(topicSubscriber) + .subscribeToTopic(List.of(DEVICE_TOKEN), TopicGenerator.getTopic(SUBSCRIBED_MISSION_ID)); + verify(topicSubscriber) + .subscribeToTopic(List.of(DEVICE_TOKEN), TopicGenerator.getTopic(UNSUBSCRIBED_MISSION_ID)); + } + + @Test + void 구독한_미션을_모두_구독_해제한다() { + Device mockDevice = mock(Device.class); + when(mockDevice.getId()).thenReturn(DEVICE_ID); + when(mockDevice.getMember()).thenReturn(MOCK_MEMBER); + when(mockDevice.getDeviceToken()).thenReturn(DEVICE_TOKEN); + when(mockDevice.getPushActivationStatus()).thenReturn(true); + + DeviceSubscription mockDeviceSubscription = mock(DeviceSubscription.class); + when(mockDeviceSubscription.getMission()).thenReturn(MOCK_MISSION); + + when(deviceRepository.findAllWithMemberByDeviceIdentifier(DEVICE_IDENTIFIER)) + .thenReturn(List.of(mockDevice)); + when(deviceSubscriptionRepository.findAllWithMissionAndDeviceByDeviceId(DEVICE_ID)) + .thenReturn(List.of(mockDeviceSubscription)); + + deviceSubscriptionService.unsubscribeFromMyMissions(MEMBER_ID, DEVICE_IDENTIFIER); + + verify(topicSubscriber) + .unsubscribeFromTopic(List.of(DEVICE_TOKEN), TopicGenerator.getTopic(MISSION_ID)); + } } \ No newline at end of file diff --git a/src/test/java/com/nexters/goalpanzi/application/firebase/TopicSubscriberImplTest.java b/src/test/java/com/nexters/goalpanzi/application/firebase/TopicSubscriberImplTest.java new file mode 100644 index 00000000..9823baa4 --- /dev/null +++ b/src/test/java/com/nexters/goalpanzi/application/firebase/TopicSubscriberImplTest.java @@ -0,0 +1,37 @@ +package com.nexters.goalpanzi.application.firebase; + +import com.google.firebase.messaging.FirebaseMessaging; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.List; + +import static org.mockito.Mockito.*; + +class TopicSubscriberImplTest { + + private TopicSubscriberImpl topicSubscriber; + + @Mock + private FirebaseMessaging firebaseMessaging; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + + topicSubscriber = new TopicSubscriberImpl(); + + mockStatic(FirebaseMessaging.class); + when(FirebaseMessaging.getInstance()).thenReturn(firebaseMessaging); + } + + @Test + void 비어있는_토큰_리스트를_전달하는_경우_FirebaseMessaging을_호출하지_않는다() { + topicSubscriber.subscribeToTopic(List.of(), "topic"); + topicSubscriber.unsubscribeFromTopic(List.of(), "topic"); + + verifyNoInteractions(firebaseMessaging); + } +} \ No newline at end of file diff --git a/src/test/java/com/nexters/goalpanzi/domain/device/repository/DeviceRepositoryTest.java b/src/test/java/com/nexters/goalpanzi/domain/device/repository/DeviceRepositoryTest.java new file mode 100644 index 00000000..480e030e --- /dev/null +++ b/src/test/java/com/nexters/goalpanzi/domain/device/repository/DeviceRepositoryTest.java @@ -0,0 +1,39 @@ +package com.nexters.goalpanzi.domain.device.repository; + +import com.nexters.goalpanzi.domain.device.Device; +import com.nexters.goalpanzi.domain.device.OsType; +import com.nexters.goalpanzi.domain.member.Member; +import com.nexters.goalpanzi.domain.member.SocialType; +import com.nexters.goalpanzi.domain.member.repository.MemberRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.List; + +import static com.nexters.goalpanzi.fixture.DeviceFixture.DEVICE_IDENTIFIER; +import static com.nexters.goalpanzi.fixture.DeviceFixture.DEVICE_TOKEN; +import static com.nexters.goalpanzi.fixture.MemberFixture.EMAIL_HOST; +import static com.nexters.goalpanzi.fixture.MemberFixture.SOCIAL_ID; +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +class DeviceRepositoryTest { + + @Autowired + private DeviceRepository deviceRepository; + + @Autowired + private MemberRepository memberRepository; + + @Test + void 특정_디바이스를_멤버와_함께_조회한다() { + Member member = memberRepository.save( + Member.socialLogin(SOCIAL_ID, EMAIL_HOST, SocialType.GOOGLE) + ); + Device device = deviceRepository.save(new Device(member, DEVICE_IDENTIFIER, DEVICE_TOKEN, OsType.AOS)); + + List devices = deviceRepository.findAllWithMemberByDeviceIdentifier(device.getDeviceIdentifier()); + assertThat(devices.getFirst().getMember()).isEqualTo(member); + } +} \ No newline at end of file