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/fcm/FcmIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java new file mode 100644 index 000000000..2b73693d3 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/notification/fcm/FcmIntegrationTest.java @@ -0,0 +1,134 @@ +package umc.cockple.demo.domain.notification.fcm; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.firebase.messaging.FirebaseMessaging; +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.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.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 ObjectMapper objectMapper; + + 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()); + } + + @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 new file mode 100644 index 000000000..42ebb5554 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/notification/integration/NotificationIntegrationTest.java @@ -0,0 +1,297 @@ +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))) + .andExpect(jsonPath("$.data[0].imgUrl").value(nullValue())); + } + } + } + + + @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())); + } + } + } +} 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)); + } + } + } +}