From b2b4da63e605f2277b219fb257a9a2716e1e9ba3 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 30 Mar 2026 17:16:52 +0900 Subject: [PATCH 1/5] =?UTF-8?q?test:=20=ED=86=B5=ED=95=A9=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/NotificationRepository.java | 3 +- .../service/NotificationCommandService.java | 2 +- .../service/NotificationQueryService.java | 2 +- .../NotificationIntegrationTest.java | 296 ++++++++++++++++++ 4 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 src/test/java/umc/cockple/demo/domain/notification/integration/NotificationIntegrationTest.java diff --git a/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java b/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java index c371e57c0..eb870bec6 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/umc/cockple/demo/domain/notification/repository/NotificationRepository.java @@ -10,8 +10,7 @@ public interface NotificationRepository extends JpaRepository { - List findAllByMember(Member member); + List findAllByMemberOrderByCreatedAtDesc(Member member); Optional findFirstByMemberAndTypeNotOrderByCreatedAtAsc(Member member, NotificationType type); - } diff --git a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java index 6f4c3c594..f62a4d6f5 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java +++ b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationCommandService.java @@ -63,7 +63,7 @@ public void createNotification(CreateNotificationRequestDTO dto) { try { Member member = dto.member(); - List bookmarks = notificationRepository.findAllByMember(member); + List bookmarks = notificationRepository.findAllByMemberOrderByCreatedAtDesc(member); if (bookmarks.size() >= 50) { // INVITE타입이 아니면서 가장 오래된 거 삭제 notificationRepository.findFirstByMemberAndTypeNotOrderByCreatedAtAsc(member, NotificationType.INVITE) diff --git a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationQueryService.java b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationQueryService.java index 22eaa5afd..baa08008d 100644 --- a/src/main/java/umc/cockple/demo/domain/notification/service/NotificationQueryService.java +++ b/src/main/java/umc/cockple/demo/domain/notification/service/NotificationQueryService.java @@ -33,7 +33,7 @@ public List getAllNotifications(Long memberId) { Member member = findByMemberId(memberId); // 회원의 모든 알림 조회 - List notifications = notificationRepository.findAllByMember(member); + List notifications = notificationRepository.findAllByMemberOrderByCreatedAtDesc(member); if (notifications.isEmpty()) { return List.of(); diff --git a/src/test/java/umc/cockple/demo/domain/notification/integration/NotificationIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/notification/integration/NotificationIntegrationTest.java new file mode 100644 index 000000000..19bd680df --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/notification/integration/NotificationIntegrationTest.java @@ -0,0 +1,296 @@ +package umc.cockple.demo.domain.notification.integration; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.notification.domain.Notification; +import umc.cockple.demo.domain.notification.enums.NotificationType; +import umc.cockple.demo.domain.notification.exception.NotificationErrorCode; +import umc.cockple.demo.domain.notification.repository.NotificationRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.SecurityContextHelper; +import umc.cockple.demo.support.fixture.MemberFixture; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class NotificationIntegrationTest extends IntegrationTestBase { + + @Autowired MockMvc mockMvc; + @Autowired MemberRepository memberRepository; + @Autowired NotificationRepository notificationRepository; + + @MockitoBean + FileService fileService; + + private Member member; + private Notification notification; + + @BeforeEach + void setUp() { + member = memberRepository.save(MemberFixture.createMember("테스터", Gender.MALE, Level.A, 1001L)); + + notification = notificationRepository.save(Notification.builder() + .member(member) + .partyId(100L) + .title("테스트 모임") + .content("테스트 알림 내용") + .type(NotificationType.INVITE) + .isRead(false) + .imageKey("test-image-key") + .data("{\"invitationId\":1}") + .build()); + + given(fileService.getUrlFromKey("test-image-key")) + .willReturn("https://test-storage.com/test-image-key"); + given(fileService.getUrlFromKey(null)) + .willReturn(null); + } + + @AfterEach + void tearDown() { + notificationRepository.deleteAll(); + memberRepository.deleteAll(); + SecurityContextHelper.clearAuthentication(); + } + + + @Nested + @DisplayName("GET /api/notifications - 내 알림 전체 조회") + class GetAllNotifications { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - 알림 목록의 모든 필드를 반환한다") + void getAllNotifications_allFields() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/notifications")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(1))) + .andExpect(jsonPath("$.data[0].notificationId").value(notification.getId())) + .andExpect(jsonPath("$.data[0].partyId").value(100)) + .andExpect(jsonPath("$.data[0].title").value("테스트 모임")) + .andExpect(jsonPath("$.data[0].content").value("테스트 알림 내용")) + .andExpect(jsonPath("$.data[0].type").value("INVITE")) + .andExpect(jsonPath("$.data[0].isRead").value(false)) + .andExpect(jsonPath("$.data[0].imgUrl").value("https://test-storage.com/test-image-key")) + .andExpect(jsonPath("$.data[0].data").value("{\"invitationId\":1}")); + } + + @Test + @DisplayName("200 - 알림이 없으면 빈 리스트를 반환한다") + void getAllNotifications_empty() throws Exception { + Member otherMember = memberRepository.save( + MemberFixture.createMember("다른멤버", Gender.FEMALE, Level.B, 2002L)); + SecurityContextHelper.setAuthentication(otherMember.getId(), otherMember.getNickname()); + + mockMvc.perform(get("/api/notifications")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(0))); + } + + @Test + @DisplayName("200 - 알림 목록이 createdAt 기준 내림차순으로 정렬된다") + void getAllNotifications_sortedByCreatedAtDesc() throws Exception { + // 먼저 저장된 알림 (더 오래된) + Notification olderNotification = notificationRepository.save(Notification.builder() + .member(member) + .partyId(200L) + .title("오래된 알림") + .content("먼저 생성된 알림") + .type(NotificationType.SIMPLE) + .isRead(false) + .imageKey(null) + .data("{}") + .build()); + + Thread.sleep(10); // createdAt이 서로 다르도록 대기 + + // 나중에 저장된 알림 (더 최신) + Notification newerNotification = notificationRepository.save(Notification.builder() + .member(member) + .partyId(300L) + .title("최신 알림") + .content("나중에 생성된 알림") + .type(NotificationType.SIMPLE) + .isRead(false) + .imageKey(null) + .data("{}") + .build()); + + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + // 내림차순이면 newerNotification → olderNotification → setUp의 notification 순 + mockMvc.perform(get("/api/notifications")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(3))) + .andExpect(jsonPath("$.data[0].notificationId").value(newerNotification.getId())) + .andExpect(jsonPath("$.data[1].notificationId").value(olderNotification.getId())) + .andExpect(jsonPath("$.data[2].notificationId").value(notification.getId())); + } + + @Test + @DisplayName("200 - imageKey가 없는 알림은 imgUrl이 null로 반환된다") + void getAllNotifications_nullImgUrl() throws Exception { + notificationRepository.save(Notification.builder() + .member(member) + .partyId(200L) + .title("이미지 없는 알림") + .content("모임이 삭제되었어요!") + .type(NotificationType.SIMPLE) + .isRead(false) + .imageKey(null) + .data("{}") + .build()); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/notifications")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(2))); + } + } + } + + + @Nested + @DisplayName("GET /api/notifications/count - 안 읽은 알림 존재여부 조회") + class CheckUnreadNotification { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - 읽지 않은 알림이 있으면 existNewNotification이 true이다") + void checkUnread_hasUnread() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/notifications/count")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.existNewNotification").value(true)); + } + + @Test + @DisplayName("200 - 모든 알림을 읽으면 existNewNotification이 false이다") + void checkUnread_allRead() throws Exception { + notification.read(); + notificationRepository.save(notification); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/notifications/count")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.existNewNotification").value(false)); + } + + @Test + @DisplayName("200 - 알림이 전혀 없으면 existNewNotification이 false이다") + void checkUnread_noNotification() throws Exception { + Member otherMember = memberRepository.save( + MemberFixture.createMember("다른멤버", Gender.FEMALE, Level.B, 2002L)); + SecurityContextHelper.setAuthentication(otherMember.getId(), otherMember.getNickname()); + + mockMvc.perform(get("/api/notifications/count")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.existNewNotification").value(false)); + } + } + } + + + @Nested + @DisplayName("PATCH /api/notifications/{notificationId} - 특정 알림 읽음 처리") + class MarkAsReadNotification { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - INVITE 알림을 INVITE_ACCEPT로 읽음 처리하면 변경된 타입을 반환한다") + void markAsRead_inviteAccept() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/{notificationId}", notification.getId()) + .param("type", NotificationType.INVITE_ACCEPT.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.type").value("INVITE_ACCEPT")); + } + + @Test + @DisplayName("200 - INVITE 알림을 INVITE_REJECT로 읽음 처리하면 변경된 타입을 반환한다") + void markAsRead_inviteReject() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/{notificationId}", notification.getId()) + .param("type", NotificationType.INVITE_REJECT.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.type").value("INVITE_REJECT")); + } + + @Test + @DisplayName("200 - SIMPLE 알림을 읽음 처리하면 SIMPLE 타입을 반환한다") + void markAsRead_simple() throws Exception { + Notification simpleNotification = notificationRepository.save(Notification.builder() + .member(member) + .partyId(100L) + .title("테스트 모임") + .content("모임이 삭제되었어요!") + .type(NotificationType.SIMPLE) + .isRead(false) + .imageKey(null) + .data("{}") + .build()); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/{notificationId}", simpleNotification.getId()) + .param("type", NotificationType.SIMPLE.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.type").value("SIMPLE")); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 알림 ID이면 에러를 반환한다") + void notificationNotFound() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/{notificationId}", 999L) + .param("type", NotificationType.SIMPLE.name())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(NotificationErrorCode.NOTIFICATION_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(NotificationErrorCode.NOTIFICATION_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("401 - 다른 사용자의 알림에 접근하면 에러를 반환한다") + void notificationNotOwned() throws Exception { + Member otherMember = memberRepository.save( + MemberFixture.createMember("다른멤버", Gender.FEMALE, Level.B, 2002L)); + SecurityContextHelper.setAuthentication(otherMember.getId(), otherMember.getNickname()); + + mockMvc.perform(patch("/api/notifications/{notificationId}", notification.getId()) + .param("type", NotificationType.SIMPLE.name())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(NotificationErrorCode.NOTIFICATION_NOT_OWNED.getCode())) + .andExpect(jsonPath("$.message").value(NotificationErrorCode.NOTIFICATION_NOT_OWNED.getMessage())); + } + } + } +} From f0f4f07f494aec700b3cf5e621523c06b78a1c02 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 30 Mar 2026 17:37:04 +0900 Subject: [PATCH 2/5] =?UTF-8?q?test:=20=EB=8B=A8=EC=9C=84=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotificationCommandServiceTest.java | 375 ++++++++++++++++++ .../service/NotificationQueryServiceTest.java | 273 +++++++++++++ 2 files changed, 648 insertions(+) create mode 100644 src/test/java/umc/cockple/demo/domain/notification/service/NotificationCommandServiceTest.java create mode 100644 src/test/java/umc/cockple/demo/domain/notification/service/NotificationQueryServiceTest.java diff --git a/src/test/java/umc/cockple/demo/domain/notification/service/NotificationCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/notification/service/NotificationCommandServiceTest.java new file mode 100644 index 000000000..d117e782b --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/notification/service/NotificationCommandServiceTest.java @@ -0,0 +1,375 @@ +package umc.cockple.demo.domain.notification.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.notification.domain.Notification; +import umc.cockple.demo.domain.notification.dto.CreateNotificationRequestDTO; +import umc.cockple.demo.domain.notification.enums.NotificationTarget; +import umc.cockple.demo.domain.notification.enums.NotificationType; +import umc.cockple.demo.domain.notification.exception.NotificationErrorCode; +import umc.cockple.demo.domain.notification.exception.NotificationException; +import umc.cockple.demo.domain.notification.fcm.FcmService; +import umc.cockple.demo.domain.notification.repository.NotificationRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.exception.PartyErrorCode; +import umc.cockple.demo.domain.party.exception.PartyException; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +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.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; +import static umc.cockple.demo.domain.notification.dto.MarkAsReadDTO.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("NotificationCommandService") +class NotificationCommandServiceTest { + + @InjectMocks + private NotificationCommandService notificationCommandService; + + @Mock private NotificationRepository notificationRepository; + @Mock private MemberRepository memberRepository; + @Mock private PartyRepository partyRepository; + @Mock private NotificationMessageGenerator notificationMessageGenerator; + @Mock private ObjectMapper objectMapper; + @Mock private FcmService fcmService; + + private Member member; + private Party party; + private Notification notification; + + @BeforeEach + void setUp() { + member = MemberFixture.createMember("테스터", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(member, "id", 1L); + + party = PartyFixture.createParty("테스트 모임", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(party, "id", 10L); + + notification = Notification.builder() + .member(member) + .partyId(party.getId()) + .title("테스트 모임") + .content("테스트 알림 내용") + .type(NotificationType.INVITE) + .isRead(false) + .imageKey(null) + .data("{\"invitationId\":1}") + .build(); + ReflectionTestUtils.setField(notification, "id", 100L); + } + + + @Nested + @DisplayName("markAsReadNotification - 알림 읽음 처리") + class MarkAsReadNotification { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("INVITE 알림을 INVITE_ACCEPT로 읽음 처리하면 변경된 타입과 읽음 상태를 반환한다") + void markAsRead_inviteAccept_returnsChangedType() { + // given + given(notificationRepository.findById(notification.getId())) + .willReturn(Optional.of(notification)); + + // when + Response result = notificationCommandService.markAsReadNotification( + member.getId(), notification.getId(), NotificationType.INVITE_ACCEPT); + + // then + assertThat(result.type()).isEqualTo(NotificationType.INVITE_ACCEPT); + assertThat(notification.getIsRead()).isTrue(); + } + + @Test + @DisplayName("INVITE 알림을 INVITE_REJECT로 읽음 처리하면 변경된 타입과 읽음 상태를 반환한다") + void markAsRead_inviteReject_returnsChangedType() { + // given + given(notificationRepository.findById(notification.getId())) + .willReturn(Optional.of(notification)); + + // when + Response result = notificationCommandService.markAsReadNotification( + member.getId(), notification.getId(), NotificationType.INVITE_REJECT); + + // then + assertThat(result.type()).isEqualTo(NotificationType.INVITE_REJECT); + assertThat(notification.getIsRead()).isTrue(); + } + + @Test + @DisplayName("SIMPLE 알림을 읽음 처리하면 SIMPLE 타입과 읽음 상태를 반환한다") + void markAsRead_simple_returnsSameType() { + // given + Notification simpleNotification = Notification.builder() + .member(member) + .partyId(100L) + .title("단순 알림") + .content("모임이 삭제되었어요!") + .type(NotificationType.SIMPLE) + .isRead(false) + .imageKey(null) + .data("{}") + .build(); + ReflectionTestUtils.setField(simpleNotification, "id", 200L); + given(notificationRepository.findById(200L)).willReturn(Optional.of(simpleNotification)); + + // when + Response result = notificationCommandService.markAsReadNotification( + member.getId(), 200L, NotificationType.SIMPLE); + + // then + assertThat(result.type()).isEqualTo(NotificationType.SIMPLE); + assertThat(simpleNotification.getIsRead()).isTrue(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 알림이면 NotificationException(NOTIFICATION_NOT_FOUND)을 던진다") + void notificationNotFound_throwsNotificationException() { + // given + given(notificationRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> notificationCommandService.markAsReadNotification( + member.getId(), 999L, NotificationType.SIMPLE)) + .isInstanceOf(NotificationException.class) + .satisfies(e -> assertThat(((NotificationException) e).getCode()) + .isEqualTo(NotificationErrorCode.NOTIFICATION_NOT_FOUND)); + } + + @Test + @DisplayName("다른 사용자의 알림이면 NotificationException(NOTIFICATION_NOT_OWNED)을 던진다") + void notificationNotOwned_throwsNotificationException() { + // given + given(notificationRepository.findById(notification.getId())) + .willReturn(Optional.of(notification)); + + // when & then + assertThatThrownBy(() -> notificationCommandService.markAsReadNotification( + 999L, notification.getId(), NotificationType.SIMPLE)) + .isInstanceOf(NotificationException.class) + .satisfies(e -> assertThat(((NotificationException) e).getCode()) + .isEqualTo(NotificationErrorCode.NOTIFICATION_NOT_OWNED)); + } + } + } + + + @Nested + @DisplayName("createNotification - 알림 생성") + class CreateNotification { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("PARTY_DELETE 알림을 생성하면 저장 및 FCM 전송이 호출된다") + void createNotification_partyDelete_savesAndSendsFcm() throws Exception { + // given + CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder() + .member(member) + .partyId(party.getId()) + .target(NotificationTarget.PARTY_DELETE) + .build(); + + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(List.of()); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(notificationMessageGenerator.generatePartyDeletedMessage()) + .willReturn("모임이 삭제되었어요!"); + given(objectMapper.writeValueAsString(any())).willReturn("{}"); + + // when + notificationCommandService.createNotification(dto); + + // then + then(notificationRepository).should().save(any(Notification.class)); + then(fcmService).should().sendNotification(eq(member), eq("테스트 모임"), eq("모임이 삭제되었어요!")); + } + + @Test + @DisplayName("PARTY_INVITE 알림을 생성하면 title이 '새로운 모임'으로 FCM이 전송된다") + void createNotification_partyInvite_usesTitleNewParty() throws Exception { + // given + CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder() + .member(member) + .partyId(party.getId()) + .invitationId(1L) + .target(NotificationTarget.PARTY_INVITE) + .build(); + + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(List.of()); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(notificationMessageGenerator.generateInviteMessage(party.getPartyName())) + .willReturn("'테스트 모임' 모임에 초대를 받았습니다."); + given(objectMapper.writeValueAsString(any())).willReturn("{\"invitationId\":1}"); + + // when + notificationCommandService.createNotification(dto); + + // then + then(fcmService).should().sendNotification(eq(member), eq("새로운 모임"), any(String.class)); + } + + @Test + @DisplayName("EXERCISE_DELETE 알림을 생성하면 날짜 포맷을 포함한 메시지로 저장된다") + void createNotification_exerciseDelete_savesWithFormattedDate() throws Exception { + // given + LocalDate exerciseDate = LocalDate.of(2025, 3, 15); + CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder() + .member(member) + .partyId(party.getId()) + .exerciseDate(exerciseDate) + .target(NotificationTarget.EXERCISE_DELETE) + .build(); + + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(List.of()); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(notificationMessageGenerator.generateExerciseDeletedMessage("03.15(토)")) + .willReturn("03.15(토) 운동이 삭제되었어요!"); + given(objectMapper.writeValueAsString(any())).willReturn("{}"); + + // when + notificationCommandService.createNotification(dto); + + // then + then(notificationRepository).should().save(any(Notification.class)); + then(fcmService).should().sendNotification(eq(member), eq("테스트 모임"), eq("03.15(토) 운동이 삭제되었어요!")); + } + + @Test + @DisplayName("알림이 50개 이상이면 INVITE가 아닌 가장 오래된 알림을 삭제 후 저장한다") + void createNotification_over50_deletesOldestNonInvite() throws Exception { + // given + List existingNotifications = new ArrayList<>(); + for (int i = 0; i < 50; i++) { + existingNotifications.add(Notification.builder() + .member(member).partyId(100L).title("t").content("c") + .type(NotificationType.SIMPLE).isRead(true).imageKey(null).data("{}").build()); + } + + Notification oldestNonInvite = Notification.builder() + .member(member).partyId(100L).title("오래된 알림").content("c") + .type(NotificationType.SIMPLE).isRead(true).imageKey(null).data("{}").build(); + + CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder() + .member(member) + .partyId(party.getId()) + .target(NotificationTarget.PARTY_DELETE) + .build(); + + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(existingNotifications); + given(notificationRepository.findFirstByMemberAndTypeNotOrderByCreatedAtAsc( + member, NotificationType.INVITE)) + .willReturn(Optional.of(oldestNonInvite)); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(notificationMessageGenerator.generatePartyDeletedMessage()) + .willReturn("모임이 삭제되었어요!"); + given(objectMapper.writeValueAsString(any())).willReturn("{}"); + + // when + notificationCommandService.createNotification(dto); + + // then + then(notificationRepository).should().delete(oldestNonInvite); + then(notificationRepository).should().save(any(Notification.class)); + } + + @Test + @DisplayName("알림이 49개이면 오래된 알림 삭제 없이 바로 저장한다") + void createNotification_under50_savesWithoutDelete() throws Exception { + // given + List existingNotifications = new ArrayList<>(); + for (int i = 0; i < 49; i++) { + existingNotifications.add(Notification.builder() + .member(member).partyId(100L).title("t").content("c") + .type(NotificationType.SIMPLE).isRead(true).imageKey(null).data("{}").build()); + } + + CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder() + .member(member) + .partyId(party.getId()) + .target(NotificationTarget.PARTY_DELETE) + .build(); + + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(existingNotifications); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(notificationMessageGenerator.generatePartyDeletedMessage()) + .willReturn("모임이 삭제되었어요!"); + given(objectMapper.writeValueAsString(any())).willReturn("{}"); + + // when + notificationCommandService.createNotification(dto); + + // then + then(notificationRepository).should(never()).delete(any()); + then(notificationRepository).should().save(any(Notification.class)); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 모임이면 PartyException(PARTY_NOT_FOUND)을 던지고 저장하지 않는다") + void partyNotFound_throwsPartyException() { + // given + CreateNotificationRequestDTO dto = CreateNotificationRequestDTO.builder() + .member(member) + .partyId(999L) + .target(NotificationTarget.PARTY_DELETE) + .build(); + + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(List.of()); + given(partyRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> notificationCommandService.createNotification(dto)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()) + .isEqualTo(PartyErrorCode.PARTY_NOT_FOUND)); + + then(notificationRepository).should(never()).save(any()); + } + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/notification/service/NotificationQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/notification/service/NotificationQueryServiceTest.java new file mode 100644 index 000000000..8aef6bdc5 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/notification/service/NotificationQueryServiceTest.java @@ -0,0 +1,273 @@ +package umc.cockple.demo.domain.notification.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.notification.domain.Notification; +import umc.cockple.demo.domain.notification.dto.AllNotificationsResponseDTO; +import umc.cockple.demo.domain.notification.dto.ExistNewNotificationResponseDTO; +import umc.cockple.demo.domain.notification.enums.NotificationType; +import umc.cockple.demo.domain.notification.repository.NotificationRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.MemberFixture; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +@DisplayName("NotificationQueryService") +class NotificationQueryServiceTest { + + @InjectMocks + private NotificationQueryService notificationQueryService; + + @Mock private NotificationRepository notificationRepository; + @Mock private MemberRepository memberRepository; + @Mock private FileService fileService; + + private Member member; + private Notification notification; + + @BeforeEach + void setUp() { + member = MemberFixture.createMember("테스터", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(member, "id", 1L); + + notification = Notification.builder() + .member(member) + .partyId(100L) + .title("테스트 모임") + .content("테스트 알림 내용") + .type(NotificationType.INVITE) + .isRead(false) + .imageKey("test-image-key") + .data("{\"invitationId\":1}") + .build(); + ReflectionTestUtils.setField(notification, "id", 10L); + } + + + @Nested + @DisplayName("getAllNotifications - 내 알림 전체 조회") + class GetAllNotifications { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("알림 목록을 DTO로 변환하여 반환한다") + void getAllNotifications_returnsDtoList() { + // given + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(List.of(notification)); + given(fileService.getUrlFromKey("test-image-key")) + .willReturn("https://test-storage.com/test-image-key"); + + // when + List result = notificationQueryService.getAllNotifications(member.getId()); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).notificationId()).isEqualTo(10L); + assertThat(result.get(0).partyId()).isEqualTo(100L); + assertThat(result.get(0).title()).isEqualTo("테스트 모임"); + assertThat(result.get(0).content()).isEqualTo("테스트 알림 내용"); + assertThat(result.get(0).type()).isEqualTo(NotificationType.INVITE); + assertThat(result.get(0).isRead()).isFalse(); + assertThat(result.get(0).imgUrl()).isEqualTo("https://test-storage.com/test-image-key"); + assertThat(result.get(0).data()).isEqualTo("{\"invitationId\":1}"); + } + + @Test + @DisplayName("imageKey가 없는 알림은 imgUrl이 null로 반환된다") + void getAllNotifications_nullImageKey_returnsNullImgUrl() { + // given + Notification noImageNotification = Notification.builder() + .member(member) + .partyId(200L) + .title("이미지 없는 알림") + .content("모임이 삭제되었어요!") + .type(NotificationType.SIMPLE) + .isRead(false) + .imageKey(null) + .data("{}") + .build(); + ReflectionTestUtils.setField(noImageNotification, "id", 20L); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(List.of(noImageNotification)); + given(fileService.getUrlFromKey(null)).willReturn(null); + + // when + List result = notificationQueryService.getAllNotifications(member.getId()); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).imgUrl()).isNull(); + } + + @Test + @DisplayName("레포지토리가 반환한 createdAt 내림차순 순서를 그대로 유지하여 반환한다") + void getAllNotifications_preservesDescOrderFromRepository() { + // given + Notification oldest = Notification.builder() + .member(member).partyId(100L).title("오래된 알림").content("c1") + .type(NotificationType.SIMPLE).isRead(true).imageKey(null).data("{}").build(); + ReflectionTestUtils.setField(oldest, "id", 1L); + ReflectionTestUtils.setField(oldest, "createdAt", LocalDateTime.of(2025, 1, 1, 9, 0)); + + Notification middle = Notification.builder() + .member(member).partyId(100L).title("중간 알림").content("c2") + .type(NotificationType.CHANGE).isRead(false).imageKey(null).data("{}").build(); + ReflectionTestUtils.setField(middle, "id", 2L); + ReflectionTestUtils.setField(middle, "createdAt", LocalDateTime.of(2025, 6, 1, 9, 0)); + + Notification newest = Notification.builder() + .member(member).partyId(100L).title("최신 알림").content("c3") + .type(NotificationType.INVITE).isRead(false).imageKey(null).data("{}").build(); + ReflectionTestUtils.setField(newest, "id", 3L); + ReflectionTestUtils.setField(newest, "createdAt", LocalDateTime.of(2025, 12, 1, 9, 0)); + + // 레포지토리는 createdAt DESC 순으로 반환 (newest → middle → oldest) + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(List.of(newest, middle, oldest)); + given(fileService.getUrlFromKey(null)).willReturn(null); + + // when + List result = notificationQueryService.getAllNotifications(member.getId()); + + // then + assertThat(result).hasSize(3); + assertThat(result.get(0).notificationId()).isEqualTo(3L); // newest + assertThat(result.get(1).notificationId()).isEqualTo(2L); // middle + assertThat(result.get(2).notificationId()).isEqualTo(1L); // oldest + } + + @Test + @DisplayName("알림이 없으면 빈 리스트를 반환한다") + void getAllNotifications_noNotifications_returnsEmptyList() { + // given + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(notificationRepository.findAllByMemberOrderByCreatedAtDesc(member)) + .willReturn(List.of()); + + // when + List result = notificationQueryService.getAllNotifications(member.getId()); + + // then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsMemberException() { + // given + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> notificationQueryService.getAllNotifications(999L)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + } + } + + + @Nested + @DisplayName("checkUnreadNotification - 읽지 않은 알림 존재여부 조회") + class CheckUnreadNotification { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("읽지 않은 알림이 있으면 existNewNotification이 true이다") + void hasUnreadNotification_returnsTrue() { + // given + ReflectionTestUtils.setField(member, "notifications", List.of(notification)); + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + + // when + ExistNewNotificationResponseDTO result = notificationQueryService.checkUnreadNotification(member.getId()); + + // then + assertThat(result.existNewNotification()).isTrue(); + } + + @Test + @DisplayName("모든 알림이 읽힌 상태이면 existNewNotification이 false이다") + void allNotificationsRead_returnsFalse() { + // given + notification.read(); + ReflectionTestUtils.setField(member, "notifications", List.of(notification)); + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + + // when + ExistNewNotificationResponseDTO result = notificationQueryService.checkUnreadNotification(member.getId()); + + // then + assertThat(result.existNewNotification()).isFalse(); + } + + @Test + @DisplayName("알림이 없으면 existNewNotification이 false이다") + void noNotifications_returnsFalse() { + // given + ReflectionTestUtils.setField(member, "notifications", List.of()); + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + + // when + ExistNewNotificationResponseDTO result = notificationQueryService.checkUnreadNotification(member.getId()); + + // then + assertThat(result.existNewNotification()).isFalse(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsMemberException() { + // given + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> notificationQueryService.checkUnreadNotification(999L)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + } + } +} From e67c05d57cc60cee44902e44f8dc820589ee22e8 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Mon, 30 Mar 2026 17:45:47 +0900 Subject: [PATCH 3/5] =?UTF-8?q?test:=20fcm=20=ED=86=B5=ED=95=A9=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/fcm/FcmIntegrationTest.java | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java diff --git a/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java new file mode 100644 index 000000000..606a0d5d3 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java @@ -0,0 +1,181 @@ +package umc.cockple.demo.domain.notification.fcm; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.notification.dto.FcmTokenRequestDTO; +import umc.cockple.demo.domain.notification.fcm.FcmService; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.SecurityContextHelper; +import umc.cockple.demo.support.fixture.MemberFixture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class FcmIntegrationTest extends IntegrationTestBase { + + @Autowired MockMvc mockMvc; + @Autowired MemberRepository memberRepository; + @Autowired FcmService fcmService; + @Autowired ObjectMapper objectMapper; + + @MockitoBean + FirebaseMessaging firebaseMessaging; + + private Member member; + + @BeforeEach + void setUp() { + member = memberRepository.save( + MemberFixture.createMember("테스터", Gender.MALE, Level.A, 1001L)); + } + + @AfterEach + void tearDown() { + memberRepository.deleteAll(); + SecurityContextHelper.clearAuthentication(); + } + + + @Nested + @DisplayName("PATCH /api/notifications/fcm-token - FCM 토큰 등록/갱신") + class RegisterFcmToken { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - FCM 토큰이 DB에 저장된다") + void registerFcmToken_savedInDb() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/fcm-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new FcmTokenRequestDTO("new-fcm-token")))) + .andExpect(status().isOk()); + + Member updated = memberRepository.findById(member.getId()).orElseThrow(); + assertThat(updated.getFcmToken()).isEqualTo("new-fcm-token"); + } + + @Test + @DisplayName("200 - 기존 토큰이 새 토큰으로 교체된다") + void registerFcmToken_updatesExistingToken() throws Exception { + ReflectionTestUtils.setField(member, "fcmToken", "old-fcm-token"); + memberRepository.save(member); + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/fcm-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new FcmTokenRequestDTO("updated-fcm-token")))) + .andExpect(status().isOk()); + + Member updated = memberRepository.findById(member.getId()).orElseThrow(); + assertThat(updated.getFcmToken()).isEqualTo("updated-fcm-token"); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("400 - 빈 문자열 토큰은 @NotBlank 검증에서 거부된다") + void registerFcmToken_blankToken_returns400() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/fcm-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new FcmTokenRequestDTO("")))) + .andExpect(status().isBadRequest()); + } + } + } + + + @Nested + @DisplayName("sendNotification - FCM 푸시 알림 전송") + class SendNotification { + + @Nested + @DisplayName("전송 성공") + class Success { + + @Test + @DisplayName("FCM 토큰이 있는 회원에게 알림 전송 시 firebaseMessaging.send()가 호출된다") + void sendNotification_withToken_callsFirebase() throws Exception { + ReflectionTestUtils.setField(member, "fcmToken", "valid-fcm-token"); + + fcmService.sendNotification(member, "테스트 제목", "테스트 내용"); + + then(firebaseMessaging).should().send(any()); + } + } + + @Nested + @DisplayName("전송 생략") + class Skip { + + @Test + @DisplayName("FCM 토큰이 null이면 firebaseMessaging.send()가 호출되지 않는다") + void sendNotification_nullToken_skipsFirebase() { + // member의 fcmToken은 기본값 null + + fcmService.sendNotification(member, "제목", "내용"); + + try { + then(firebaseMessaging).should(never()).send(any()); + } catch (FirebaseMessagingException e) { + System.out.println("FirebaseMessagingException이 발생했지만, sendNotification 메서드는 예외를 전파하지 않아야 합니다."); + } + } + + @Test + @DisplayName("FCM 토큰이 빈 문자열이면 firebaseMessaging.send()가 호출되지 않는다") + void sendNotification_blankToken_skipsFirebase() { + ReflectionTestUtils.setField(member, "fcmToken", ""); + + fcmService.sendNotification(member, "제목", "내용"); + + try { + then(firebaseMessaging).should(never()).send(any()); + } catch (FirebaseMessagingException e) { + System.out.println("FirebaseMessagingException이 발생했지만, sendNotification 메서드는 예외를 전파하지 않아야 합니다."); + } + } + } + + @Nested + @DisplayName("전송 실패") + class Failure { + + @Test + @DisplayName("Firebase 전송 실패 시 예외를 전파하지 않는다") + void sendNotification_firebaseException_doesNotThrow() throws FirebaseMessagingException { + ReflectionTestUtils.setField(member, "fcmToken", "valid-fcm-token"); + given(firebaseMessaging.send(any())).willThrow(mock(FirebaseMessagingException.class)); + + assertThatCode(() -> fcmService.sendNotification(member, "제목", "내용")) + .doesNotThrowAnyException(); + } + } + } +} From 155e36c672bfb924273ac0e048d26d7748d91ca1 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Tue, 31 Mar 2026 19:21:06 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=ED=86=B5=ED=95=A9=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/fcm/FcmIntegrationTest.java | 33 +++++++++++++++++++ .../NotificationIntegrationTest.java | 3 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java index 606a0d5d3..5ea15c6e5 100644 --- a/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java @@ -107,6 +107,39 @@ void registerFcmToken_blankToken_returns400() throws Exception { .content(objectMapper.writeValueAsString(new FcmTokenRequestDTO("")))) .andExpect(status().isBadRequest()); } + + @Test + @DisplayName("400 - null 토큰은 @NotBlank 검증에서 거부된다") + void registerFcmToken_nullToken_returns400() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/fcm-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new FcmTokenRequestDTO(null)))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("400 - 공백 문자열 토큰은 @NotBlank 검증에서 거부된다") + void registerFcmToken_whitespaceToken_returns400() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/fcm-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new FcmTokenRequestDTO(" ")))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("400 - fcmToken 필드 누락 시 @NotBlank 검증에서 거부된다") + void registerFcmToken_missingField_returns400() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(patch("/api/notifications/fcm-token") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } } } diff --git a/src/test/java/umc/cockple/demo/domain/notification/integration/NotificationIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/notification/integration/NotificationIntegrationTest.java index 19bd680df..42ebb5554 100644 --- a/src/test/java/umc/cockple/demo/domain/notification/integration/NotificationIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/notification/integration/NotificationIntegrationTest.java @@ -159,7 +159,8 @@ void getAllNotifications_nullImgUrl() throws Exception { mockMvc.perform(get("/api/notifications")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data", hasSize(2))); + .andExpect(jsonPath("$.data", hasSize(2))) + .andExpect(jsonPath("$.data[0].imgUrl").value(nullValue())); } } } From 282a82d2d5622a73f0308d8cbf39bc9a52faece8 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Tue, 31 Mar 2026 19:26:36 +0900 Subject: [PATCH 5/5] =?UTF-8?q?chore:=20=EC=A4=91=EB=B3=B5=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/fcm/FcmIntegrationTest.java | 80 ------------------- 1 file changed, 80 deletions(-) diff --git a/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java index 5ea15c6e5..2b73693d3 100644 --- a/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java @@ -2,7 +2,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.firebase.messaging.FirebaseMessaging; -import com.google.firebase.messaging.FirebaseMessagingException; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; @@ -12,7 +11,6 @@ import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.repository.MemberRepository; import umc.cockple.demo.domain.notification.dto.FcmTokenRequestDTO; -import umc.cockple.demo.domain.notification.fcm.FcmService; import umc.cockple.demo.global.enums.Gender; import umc.cockple.demo.global.enums.Level; import umc.cockple.demo.support.IntegrationTestBase; @@ -20,12 +18,6 @@ import umc.cockple.demo.support.fixture.MemberFixture; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.then; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -33,12 +25,8 @@ class FcmIntegrationTest extends IntegrationTestBase { @Autowired MockMvc mockMvc; @Autowired MemberRepository memberRepository; - @Autowired FcmService fcmService; @Autowired ObjectMapper objectMapper; - @MockitoBean - FirebaseMessaging firebaseMessaging; - private Member member; @BeforeEach @@ -143,72 +131,4 @@ void registerFcmToken_missingField_returns400() throws Exception { } } - - @Nested - @DisplayName("sendNotification - FCM 푸시 알림 전송") - class SendNotification { - - @Nested - @DisplayName("전송 성공") - class Success { - - @Test - @DisplayName("FCM 토큰이 있는 회원에게 알림 전송 시 firebaseMessaging.send()가 호출된다") - void sendNotification_withToken_callsFirebase() throws Exception { - ReflectionTestUtils.setField(member, "fcmToken", "valid-fcm-token"); - - fcmService.sendNotification(member, "테스트 제목", "테스트 내용"); - - then(firebaseMessaging).should().send(any()); - } - } - - @Nested - @DisplayName("전송 생략") - class Skip { - - @Test - @DisplayName("FCM 토큰이 null이면 firebaseMessaging.send()가 호출되지 않는다") - void sendNotification_nullToken_skipsFirebase() { - // member의 fcmToken은 기본값 null - - fcmService.sendNotification(member, "제목", "내용"); - - try { - then(firebaseMessaging).should(never()).send(any()); - } catch (FirebaseMessagingException e) { - System.out.println("FirebaseMessagingException이 발생했지만, sendNotification 메서드는 예외를 전파하지 않아야 합니다."); - } - } - - @Test - @DisplayName("FCM 토큰이 빈 문자열이면 firebaseMessaging.send()가 호출되지 않는다") - void sendNotification_blankToken_skipsFirebase() { - ReflectionTestUtils.setField(member, "fcmToken", ""); - - fcmService.sendNotification(member, "제목", "내용"); - - try { - then(firebaseMessaging).should(never()).send(any()); - } catch (FirebaseMessagingException e) { - System.out.println("FirebaseMessagingException이 발생했지만, sendNotification 메서드는 예외를 전파하지 않아야 합니다."); - } - } - } - - @Nested - @DisplayName("전송 실패") - class Failure { - - @Test - @DisplayName("Firebase 전송 실패 시 예외를 전파하지 않는다") - void sendNotification_firebaseException_doesNotThrow() throws FirebaseMessagingException { - ReflectionTestUtils.setField(member, "fcmToken", "valid-fcm-token"); - given(firebaseMessaging.send(any())).willThrow(mock(FirebaseMessagingException.class)); - - assertThatCode(() -> fcmService.sendNotification(member, "제목", "내용")) - .doesNotThrowAnyException(); - } - } - } }