diff --git a/src/main/java/run/backend/domain/event/repository/JoinEventRepository.java b/src/main/java/run/backend/domain/event/repository/JoinEventRepository.java index 28e3982..e3d5f58 100644 --- a/src/main/java/run/backend/domain/event/repository/JoinEventRepository.java +++ b/src/main/java/run/backend/domain/event/repository/JoinEventRepository.java @@ -9,6 +9,7 @@ import run.backend.domain.crew.dto.response.EventProfileResponse; import run.backend.domain.event.entity.Event; import run.backend.domain.event.entity.JoinEvent; +import run.backend.domain.event.enums.EventStatus; import run.backend.domain.member.entity.Member; public interface JoinEventRepository extends JpaRepository { @@ -19,15 +20,16 @@ public interface JoinEventRepository extends JpaRepository { @Query("SELECT j FROM JoinEvent j WHERE j.event = :event") List findByEvent(@Param("event") Event event); - @Query("SELECT j FROM JoinEvent j WHERE j.event = :event AND j.event.status = 'COMPLETED'") - List findActualParticipantsByEvent(@Param("event") Event event); + @Query("SELECT j FROM JoinEvent j WHERE j.event = :event AND j.event.status = :status") + List findActualParticipantsByEvent(@Param("event") Event event, @Param("status") EventStatus status); @Query("SELECT j FROM JoinEvent j WHERE j.member = :member " + "AND j.event.date >= :startDate AND j.event.date <= :endDate " + - "AND j.event.status = 'COMPLETED'") + "AND j.event.status = :status") List findMonthlyParticipatedEvents(@Param("member") Member member, @Param("startDate") LocalDate startDate, - @Param("endDate") LocalDate endDate); + @Param("endDate") LocalDate endDate, + @Param("status") EventStatus status); @Query(""" SELECT new run.backend.domain.crew.dto.response.EventProfileResponse(\ @@ -35,10 +37,11 @@ List findMonthlyParticipatedEvents(@Param("member") Member member, FROM JoinEvent j JOIN j.event e \ WHERE j.member = :member \ AND e.date >= :startDate AND e.date <= :endDate \ - AND e.status = 'COMPLETED' ORDER BY e.date DESC""") + AND e.status = :status ORDER BY e.date DESC""") List findMonthlyCompletedEvents(@Param("member") Member member, @Param("startDate") LocalDate startDate, - @Param("endDate") LocalDate endDate); + @Param("endDate") LocalDate endDate, + @Param("status") EventStatus status); boolean existsByEventAndMember(Event event, Member member); } diff --git a/src/main/java/run/backend/domain/event/service/EventService.java b/src/main/java/run/backend/domain/event/service/EventService.java index 39b4190..609e0f4 100644 --- a/src/main/java/run/backend/domain/event/service/EventService.java +++ b/src/main/java/run/backend/domain/event/service/EventService.java @@ -172,7 +172,7 @@ public EventDetailResponse getEventDetail(Long eventId) { private List getParticipants(Event event, EventStatus status) { return status == EventStatus.COMPLETED - ? joinEventRepository.findActualParticipantsByEvent(event) + ? joinEventRepository.findActualParticipantsByEvent(event, EventStatus.COMPLETED) : joinEventRepository.findByEvent(event); } diff --git a/src/main/java/run/backend/domain/member/service/MemberService.java b/src/main/java/run/backend/domain/member/service/MemberService.java index 725165f..3fc480d 100644 --- a/src/main/java/run/backend/domain/member/service/MemberService.java +++ b/src/main/java/run/backend/domain/member/service/MemberService.java @@ -14,6 +14,7 @@ import run.backend.domain.crew.enums.JoinStatus; import run.backend.domain.crew.repository.JoinCrewRepository; import run.backend.domain.event.entity.JoinEvent; +import run.backend.domain.event.enums.EventStatus; import run.backend.domain.event.repository.JoinEventRepository; import run.backend.domain.file.service.FileService; import run.backend.domain.member.dto.request.MemberInfoRequest; @@ -76,7 +77,7 @@ public MemberParticipatedCountResponse getParticipatedEventCount(Member member) DateRange monthRange = dateRangeUtil.getMonthRange(today.getYear(), today.getMonthValue()); List monthlyJoinEvents = joinEventRepository.findMonthlyParticipatedEvents( - member, monthRange.start(), monthRange.end()); + member, monthRange.start(), monthRange.end(), EventStatus.COMPLETED); Long participatedCount = (long) monthlyJoinEvents.size(); return new MemberParticipatedCountResponse(participatedCount); @@ -88,7 +89,7 @@ public EventResponseDto getParticipatedEvent(Member member) { DateRange monthRange = dateRangeUtil.getMonthRange(today.getYear(), today.getMonthValue()); List eventProfiles = joinEventRepository.findMonthlyCompletedEvents( - member, monthRange.start(), monthRange.end()); + member, monthRange.start(), monthRange.end(), EventStatus.COMPLETED); return new EventResponseDto(eventProfiles); } diff --git a/src/main/java/run/backend/domain/notification/controller/NotificationController.java b/src/main/java/run/backend/domain/notification/controller/NotificationController.java new file mode 100644 index 0000000..2512f0d --- /dev/null +++ b/src/main/java/run/backend/domain/notification/controller/NotificationController.java @@ -0,0 +1,53 @@ +package run.backend.domain.notification.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import run.backend.domain.member.entity.Member; +import run.backend.domain.notification.dto.NotificationResponse; +import run.backend.domain.notification.service.NotificationService; +import run.backend.global.annotation.member.Login; +import run.backend.global.common.response.CommonResponse; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/notifications") +@Tag(name = "알림 API", description = "알림 관련 API") +public class NotificationController { + + private final NotificationService notificationService; + + @GetMapping + @Operation(summary = "알림 조회", description = "사용자의 알림 목록을 조회합니다. type 파라미터로 전체, 크루, 대결 알림을 필터링할 수 있습니다.") + public ResponseEntity> getNotifications( + @Parameter(description = "알림 타입 (all, crew, battle)", example = "all") @RequestParam(value = "type", defaultValue = "all") String type, + @Login Member member) { + NotificationResponse response = notificationService.getNotifications(member, type); + return ResponseEntity.ok(new CommonResponse<>("알림 조회 성공", response)); + } + + @PostMapping("/{notificationId}/read") + @Operation(summary = "알림 읽기 요청", description = "특정 알림을 읽음 상태로 변경합니다.") + public ResponseEntity> markAsRead( + @Parameter(description = "알림 ID", example = "1") @PathVariable Long notificationId, + @Login Member member) { + notificationService.markAsRead(notificationId, member); + return ResponseEntity.ok(new CommonResponse<>("알림 읽기 처리 완료")); + } + + @PostMapping("/read") + @Operation(summary = "알림 모두 읽기 요청", description = "모든 읽지 않은 알림을 읽음 상태로 변경합니다.") + public ResponseEntity> markAllAsRead(@Login Member member) { + notificationService.markAllAsRead(member); + return ResponseEntity.ok(new CommonResponse<>("모든 알림 읽기 처리 완료")); + } + +} diff --git a/src/main/java/run/backend/domain/notification/dto/NotificationItem.java b/src/main/java/run/backend/domain/notification/dto/NotificationItem.java new file mode 100644 index 0000000..a1a111e --- /dev/null +++ b/src/main/java/run/backend/domain/notification/dto/NotificationItem.java @@ -0,0 +1,5 @@ +package run.backend.domain.notification.dto; + +public record NotificationItem(String notificationId, String type, String message, String timeAgo) { + +} \ No newline at end of file diff --git a/src/main/java/run/backend/domain/notification/dto/NotificationResponse.java b/src/main/java/run/backend/domain/notification/dto/NotificationResponse.java new file mode 100644 index 0000000..1403079 --- /dev/null +++ b/src/main/java/run/backend/domain/notification/dto/NotificationResponse.java @@ -0,0 +1,7 @@ +package run.backend.domain.notification.dto; + +import java.util.List; + +public record NotificationResponse(List read, List unread) { + +} diff --git a/src/main/java/run/backend/domain/notification/entity/Notification.java b/src/main/java/run/backend/domain/notification/entity/Notification.java index 7f1db66..4f5e198 100644 --- a/src/main/java/run/backend/domain/notification/entity/Notification.java +++ b/src/main/java/run/backend/domain/notification/entity/Notification.java @@ -5,6 +5,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; import run.backend.domain.member.entity.Member; import run.backend.domain.notification.enums.MessageType; import run.backend.global.common.BaseEntity; @@ -13,6 +14,7 @@ @Getter @Table(name = "notifications") @NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE notifications SET deleted_at = NOW() WHERE id = ?") public class Notification extends BaseEntity { @Id @@ -54,4 +56,8 @@ public Notification( this.sender = sender; this.receiver = receiver; } + + public void markAsRead() { + this.isRead = true; + } } diff --git a/src/main/java/run/backend/domain/notification/enums/MessageType.java b/src/main/java/run/backend/domain/notification/enums/MessageType.java index d93357a..13bca5f 100644 --- a/src/main/java/run/backend/domain/notification/enums/MessageType.java +++ b/src/main/java/run/backend/domain/notification/enums/MessageType.java @@ -5,6 +5,7 @@ @Getter public enum MessageType { + ALL("전체"), BATTLE("대결"), CREW("크루"); diff --git a/src/main/java/run/backend/domain/notification/exception/NotificationErrorCode.java b/src/main/java/run/backend/domain/notification/exception/NotificationErrorCode.java new file mode 100644 index 0000000..e68e97b --- /dev/null +++ b/src/main/java/run/backend/domain/notification/exception/NotificationErrorCode.java @@ -0,0 +1,16 @@ +package run.backend.domain.notification.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import run.backend.global.exception.ErrorCode; + +@Getter +@AllArgsConstructor +public enum NotificationErrorCode implements ErrorCode { + + INVALID_NOTIFICATION_TYPE(6001, "유효하지 않은 알림 타입입니다."), + NOTIFICATION_NOT_FOUND(6002, "존재하지 않는 알림입니다."); + + private final int errorCode; + private final String errorMessage; +} diff --git a/src/main/java/run/backend/domain/notification/exception/NotificationException.java b/src/main/java/run/backend/domain/notification/exception/NotificationException.java new file mode 100644 index 0000000..1531e0d --- /dev/null +++ b/src/main/java/run/backend/domain/notification/exception/NotificationException.java @@ -0,0 +1,22 @@ +package run.backend.domain.notification.exception; + +import run.backend.global.exception.CustomException; + +public class NotificationException extends CustomException { + + public NotificationException(final NotificationErrorCode notificationErrorCode) { + super(notificationErrorCode); + } + + public static class InvalidNotificationType extends NotificationException { + public InvalidNotificationType() { + super(NotificationErrorCode.INVALID_NOTIFICATION_TYPE); + } + } + + public static class NotificationNotFound extends NotificationException { + public NotificationNotFound() { + super(NotificationErrorCode.NOTIFICATION_NOT_FOUND); + } + } +} diff --git a/src/main/java/run/backend/domain/notification/mapper/NotificationMapper.java b/src/main/java/run/backend/domain/notification/mapper/NotificationMapper.java new file mode 100644 index 0000000..f8c14e3 --- /dev/null +++ b/src/main/java/run/backend/domain/notification/mapper/NotificationMapper.java @@ -0,0 +1,39 @@ +package run.backend.domain.notification.mapper; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; +import run.backend.domain.notification.dto.NotificationItem; +import run.backend.domain.notification.entity.Notification; + +@Mapper( + componentModel = "spring", + unmappedTargetPolicy = ReportingPolicy.IGNORE +) +public interface NotificationMapper { + + @Mapping(target = "notificationId", source = "id") + @Mapping(target = "type", source = "messageType.description") + @Mapping(target = "timeAgo", expression = "java(calculateTimeAgo(notification.getCreatedAt()))") + NotificationItem toNotificationItem(Notification notification); + + List toNotificationItemList(List notifications); + + default String calculateTimeAgo(LocalDateTime createdAt) { + LocalDateTime now = LocalDateTime.now(); + long minutes = ChronoUnit.MINUTES.between(createdAt, now); + long hours = ChronoUnit.HOURS.between(createdAt, now); + long days = ChronoUnit.DAYS.between(createdAt, now); + + if (minutes < 60) { + return minutes + "분 전"; + } else if (hours < 24) { + return hours + "시간 전"; + } else { + return days + "일 전"; + } + } +} diff --git a/src/main/java/run/backend/domain/notification/repository/NotificationRepository.java b/src/main/java/run/backend/domain/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..1ffe235 --- /dev/null +++ b/src/main/java/run/backend/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,28 @@ +package run.backend.domain.notification.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import run.backend.domain.member.entity.Member; +import run.backend.domain.notification.entity.Notification; +import run.backend.domain.notification.enums.MessageType; + +import java.util.List; +import java.util.Optional; + +public interface NotificationRepository extends JpaRepository { + + Optional findByIdAndReceiver(Long id, Member receiver); + + @Query("SELECT n FROM Notification n WHERE n.receiver = :member AND n.isRead = true ORDER BY n.createdAt DESC") + List findReadNotificationsByMember(@Param("member") Member member); + + @Query("SELECT n FROM Notification n WHERE n.receiver = :member AND n.isRead = false ORDER BY n.createdAt DESC") + List findUnreadNotificationsByMember(@Param("member") Member member); + + @Query("SELECT n FROM Notification n WHERE n.receiver = :member AND n.messageType = :messageType AND n.isRead = true ORDER BY n.createdAt DESC") + List findReadNotificationsByMemberAndType(@Param("member") Member member, @Param("messageType") MessageType messageType); + + @Query("SELECT n FROM Notification n WHERE n.receiver = :member AND n.messageType = :messageType AND n.isRead = false ORDER BY n.createdAt DESC") + List findUnreadNotificationsByMemberAndType(@Param("member") Member member, @Param("messageType") MessageType messageType); +} diff --git a/src/main/java/run/backend/domain/notification/service/NotificationService.java b/src/main/java/run/backend/domain/notification/service/NotificationService.java new file mode 100644 index 0000000..cd78326 --- /dev/null +++ b/src/main/java/run/backend/domain/notification/service/NotificationService.java @@ -0,0 +1,67 @@ +package run.backend.domain.notification.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import run.backend.domain.member.entity.Member; +import run.backend.domain.notification.dto.NotificationItem; +import run.backend.domain.notification.dto.NotificationResponse; +import run.backend.domain.notification.entity.Notification; +import run.backend.domain.notification.enums.MessageType; +import run.backend.domain.notification.exception.NotificationException; +import run.backend.domain.notification.exception.NotificationException.NotificationNotFound; +import run.backend.domain.notification.mapper.NotificationMapper; +import run.backend.domain.notification.repository.NotificationRepository; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final NotificationMapper notificationMapper; + + public NotificationResponse getNotifications(Member member, String type) { + MessageType messageType = convertStringToMessageType(type); + + List readNotifications; + List unreadNotifications; + + if (messageType == MessageType.ALL) { + readNotifications = notificationRepository.findReadNotificationsByMember(member); + unreadNotifications = notificationRepository.findUnreadNotificationsByMember(member); + } else { + readNotifications = notificationRepository.findReadNotificationsByMemberAndType(member, messageType); + unreadNotifications = notificationRepository.findUnreadNotificationsByMemberAndType(member, messageType); + } + + List readItems = notificationMapper.toNotificationItemList(readNotifications); + List unreadItems = notificationMapper.toNotificationItemList(unreadNotifications); + + return new NotificationResponse(readItems, unreadItems); + } + + @Transactional + public void markAsRead(Long notificationId, Member member) { + Notification notification = notificationRepository.findByIdAndReceiver(notificationId, member) + .orElseThrow(NotificationNotFound::new); + + notification.markAsRead(); + } + + @Transactional + public void markAllAsRead(Member member) { + List unreadNotifications = notificationRepository.findUnreadNotificationsByMember(member); + unreadNotifications.forEach(Notification::markAsRead); + } + + private MessageType convertStringToMessageType(String type) { + return switch (type.toLowerCase()) { + case "all" -> MessageType.ALL; + case "crew" -> MessageType.CREW; + case "battle" -> MessageType.BATTLE; + default -> throw new NotificationException.InvalidNotificationType(); + }; + } +} diff --git a/src/main/java/run/backend/global/exception/GlobalExceptionHandler.java b/src/main/java/run/backend/global/exception/GlobalExceptionHandler.java index 88bcf75..4cbb869 100644 --- a/src/main/java/run/backend/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/run/backend/global/exception/GlobalExceptionHandler.java @@ -11,6 +11,7 @@ import run.backend.domain.event.exception.EventException; import run.backend.domain.file.exception.FileException; import run.backend.domain.member.exception.MemberException; +import run.backend.domain.notification.exception.NotificationException; import run.backend.global.common.response.CommonResponse; import run.backend.global.exception.httpError.HttpErrorCode; @@ -26,7 +27,8 @@ public class GlobalExceptionHandler { MemberException.MemberNotFound.class, CrewException.NotFoundCrew.class, EventException.EventNotFound.class, - EventException.JoinEventNotFound.class + EventException.JoinEventNotFound.class, + NotificationException.NotificationNotFound.class }) public ResponseEntity> handleNotFound(final CustomException e) { @@ -62,7 +64,8 @@ public ResponseEntity> handleConflict(final CustomException AuthException.InvalidRefreshToken.class, AuthException.RefreshTokenExpired.class, FileException.FileUploadFailed.class, - EventException.InvalidEventCreationRequest.class + EventException.InvalidEventCreationRequest.class, + NotificationException.InvalidNotificationType.class }) public ResponseEntity> handleBadRequest(final CustomException e) { diff --git a/src/main/java/run/backend/global/security/SecurityConfig.java b/src/main/java/run/backend/global/security/SecurityConfig.java index da01c0a..c40198c 100644 --- a/src/main/java/run/backend/global/security/SecurityConfig.java +++ b/src/main/java/run/backend/global/security/SecurityConfig.java @@ -50,7 +50,8 @@ public class SecurityConfig { "/api/v1/members/**", "/api/v1/auth/**", "/api/v1/events/**", - "/api/v1/crews/**" + "/api/v1/crews/**", + "/api/v1/notifications" }; @Bean diff --git a/src/test/java/run/backend/domain/event/service/EventServiceTest.java b/src/test/java/run/backend/domain/event/service/EventServiceTest.java index 0237d8c..22bdbc7 100644 --- a/src/test/java/run/backend/domain/event/service/EventServiceTest.java +++ b/src/test/java/run/backend/domain/event/service/EventServiceTest.java @@ -488,7 +488,7 @@ void shouldReturnExpectedParticipantsBeforeEvent() { //given given(eventRepository.findById(1L)).willReturn(Optional.of(savedEvent)); given(joinEventRepository.findByEvent(any())).willReturn(List.of(savedJoinEvent)); - given(joinEventRepository.findActualParticipantsByEvent(any())).willReturn(List.of(savedJoinEvent)); + given(joinEventRepository.findActualParticipantsByEvent(any(), any())).willReturn(List.of(savedJoinEvent)); //when sut.getEventDetail(1L); @@ -496,7 +496,7 @@ void shouldReturnExpectedParticipantsBeforeEvent() { //then then(eventRepository).should().findById(1L); then(joinEventRepository).should().findByEvent(any()); - then(joinEventRepository).should(never()).findActualParticipantsByEvent(any()); + then(joinEventRepository).should(never()).findActualParticipantsByEvent(any(), any()); } @Test @@ -505,7 +505,7 @@ void shouldReturnActualParticipantsAfterEvent() { //given given(eventRepository.findById(1L)).willReturn(Optional.of(completedEvent)); given(joinEventRepository.findByEvent(any())).willReturn(List.of(savedJoinEvent)); - given(joinEventRepository.findActualParticipantsByEvent(any())).willReturn(List.of(savedJoinEvent)); + given(joinEventRepository.findActualParticipantsByEvent(any(), any())).willReturn(List.of(savedJoinEvent)); //when sut.getEventDetail(1L); @@ -513,7 +513,7 @@ void shouldReturnActualParticipantsAfterEvent() { //then then(eventRepository).should().findById(1L); then(joinEventRepository).should(never()).findByEvent(any()); - then(joinEventRepository).should().findActualParticipantsByEvent(any()); + then(joinEventRepository).should().findActualParticipantsByEvent(any(), any()); } @Test @@ -529,7 +529,7 @@ void shouldThrowExceptionWhenEventNotFound() { //then then(eventRepository).should().findById(1L); then(joinEventRepository).should(never()).findByEvent(any()); - then(joinEventRepository).should(never()).findActualParticipantsByEvent(any()); + then(joinEventRepository).should(never()).findActualParticipantsByEvent(any(), any()); } } diff --git a/src/test/java/run/backend/domain/member/service/MemberServiceTest.java b/src/test/java/run/backend/domain/member/service/MemberServiceTest.java index 084729b..9c6a6d3 100644 --- a/src/test/java/run/backend/domain/member/service/MemberServiceTest.java +++ b/src/test/java/run/backend/domain/member/service/MemberServiceTest.java @@ -26,6 +26,7 @@ import run.backend.domain.crew.enums.JoinStatus; import run.backend.domain.crew.repository.JoinCrewRepository; import run.backend.domain.event.entity.JoinEvent; +import run.backend.domain.event.enums.EventStatus; import run.backend.domain.event.repository.JoinEventRepository; import run.backend.domain.member.dto.response.MemberCrewStatusResponse; import run.backend.domain.member.dto.response.MemberInfoResponse; @@ -162,7 +163,7 @@ void shouldReturnMonthlyParticipatedEventCount() { given(dateRangeUtil.getMonthRange(today.getYear(), today.getMonthValue())) .willReturn(monthRange); given(joinEventRepository.findMonthlyParticipatedEvents( - testMember, monthRange.start(), monthRange.end())) + testMember, monthRange.start(), monthRange.end(), EventStatus.COMPLETED)) .willReturn(monthlyJoinEvents); // when @@ -185,7 +186,7 @@ void shouldReturnZeroWhenNoParticipatedEvents() { given(dateRangeUtil.getMonthRange(today.getYear(), today.getMonthValue())) .willReturn(monthRange); given(joinEventRepository.findMonthlyParticipatedEvents( - testMember, monthRange.start(), monthRange.end())) + testMember, monthRange.start(), monthRange.end(), EventStatus.COMPLETED)) .willReturn(List.of()); // when @@ -217,7 +218,7 @@ void shouldReturnMonthlyCompletedEvents() { given(dateRangeUtil.getMonthRange(today.getYear(), today.getMonthValue())) .willReturn(monthRange); given(joinEventRepository.findMonthlyCompletedEvents( - testMember, monthRange.start(), monthRange.end())) + testMember, monthRange.start(), monthRange.end(), EventStatus.COMPLETED)) .willReturn(eventProfiles); // when @@ -241,7 +242,7 @@ void shouldReturnEmptyListWhenNoCompletedEvents() { given(dateRangeUtil.getMonthRange(today.getYear(), today.getMonthValue())) .willReturn(monthRange); given(joinEventRepository.findMonthlyCompletedEvents( - testMember, monthRange.start(), monthRange.end())) + testMember, monthRange.start(), monthRange.end(), EventStatus.COMPLETED)) .willReturn(List.of()); // when diff --git a/src/test/java/run/backend/domain/notification/service/NotificationServiceTest.java b/src/test/java/run/backend/domain/notification/service/NotificationServiceTest.java new file mode 100644 index 0000000..7988de1 --- /dev/null +++ b/src/test/java/run/backend/domain/notification/service/NotificationServiceTest.java @@ -0,0 +1,328 @@ +package run.backend.domain.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +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.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; +import run.backend.domain.member.entity.Member; +import run.backend.domain.member.enums.Gender; +import run.backend.domain.member.enums.OAuthType; +import run.backend.domain.notification.dto.NotificationItem; +import run.backend.domain.notification.dto.NotificationResponse; +import run.backend.domain.notification.entity.Notification; +import run.backend.domain.notification.enums.MessageType; +import run.backend.domain.notification.exception.NotificationException.InvalidNotificationType; +import run.backend.domain.notification.exception.NotificationException.NotificationNotFound; +import run.backend.domain.notification.mapper.NotificationMapper; +import run.backend.domain.notification.repository.NotificationRepository; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("NotificationService") +class NotificationServiceTest { + + @Mock + private NotificationRepository notificationRepository; + + @Mock + private NotificationMapper notificationMapper; + + @InjectMocks + private NotificationService sut; // System Under Test + + private Member receiver; + private Member sender; + private Notification crewNotification; + private Notification battleNotification; + private Notification readNotification; + private NotificationItem crewNotificationItem; + private NotificationItem battleNotificationItem; + private NotificationItem readNotificationItem; + + @BeforeEach + void setUp() { + receiver = createMemberWithId(1L, "수신자"); + sender = createMemberWithId(2L, "발신자"); + + crewNotification = createNotification(MessageType.CREW, "크루 가입 요청이 왔습니다.", false); + battleNotification = createNotification(MessageType.BATTLE, "대결 요청이 왔습니다.", false); + readNotification = createNotification(MessageType.CREW, "읽은 알림입니다.", true); + + crewNotificationItem = new NotificationItem("1", "크루", "크루 가입 요청이 왔습니다.", "10분 전"); + battleNotificationItem = new NotificationItem("2", "대결", "대결 요청이 왔습니다.", "30분 전"); + readNotificationItem = new NotificationItem("3", "크루", "읽은 알림입니다.", "1시간 전"); + } + + @Nested + @DisplayName("getNotifications 메서드는") + class GetNotificationsTest { + + @Test + @DisplayName("전체 타입으로 조회 시 모든 읽음/읽지 않음 알림을 반환한다") + void shouldReturnAllNotificationsWhenTypeIsAll() { + // given + List readNotifications = List.of(readNotification); + List unreadNotifications = List.of(crewNotification, battleNotification); + List readItems = List.of(readNotificationItem); + List unreadItems = List.of(crewNotificationItem, battleNotificationItem); + + given(notificationRepository.findReadNotificationsByMember(receiver)) + .willReturn(readNotifications); + given(notificationRepository.findUnreadNotificationsByMember(receiver)) + .willReturn(unreadNotifications); + given(notificationMapper.toNotificationItemList(readNotifications)) + .willReturn(readItems); + given(notificationMapper.toNotificationItemList(unreadNotifications)) + .willReturn(unreadItems); + + // when + NotificationResponse response = sut.getNotifications(receiver, "all"); + + // then + assertThat(response.read()).hasSize(1); + assertThat(response.unread()).hasSize(2); + assertThat(response.read()).containsExactly(readNotificationItem); + assertThat(response.unread()).containsExactly(crewNotificationItem, battleNotificationItem); + + then(notificationRepository).should().findReadNotificationsByMember(receiver); + then(notificationRepository).should().findUnreadNotificationsByMember(receiver); + then(notificationMapper).should().toNotificationItemList(readNotifications); + then(notificationMapper).should().toNotificationItemList(unreadNotifications); + } + + @Test + @DisplayName("크루 타입으로 조회 시 크루 관련 알림만 반환한다") + void shouldReturnCrewNotificationsWhenTypeIsCrew() { + // given + List readNotifications = List.of(readNotification); + List unreadNotifications = List.of(crewNotification); + List readItems = List.of(readNotificationItem); + List unreadItems = List.of(crewNotificationItem); + + given(notificationRepository.findReadNotificationsByMemberAndType(receiver, MessageType.CREW)) + .willReturn(readNotifications); + given(notificationRepository.findUnreadNotificationsByMemberAndType(receiver, MessageType.CREW)) + .willReturn(unreadNotifications); + given(notificationMapper.toNotificationItemList(readNotifications)) + .willReturn(readItems); + given(notificationMapper.toNotificationItemList(unreadNotifications)) + .willReturn(unreadItems); + + // when + NotificationResponse response = sut.getNotifications(receiver, "crew"); + + // then + assertThat(response.read()).hasSize(1); + assertThat(response.unread()).hasSize(1); + assertThat(response.read()).containsExactly(readNotificationItem); + assertThat(response.unread()).containsExactly(crewNotificationItem); + + then(notificationRepository).should().findReadNotificationsByMemberAndType(receiver, MessageType.CREW); + then(notificationRepository).should().findUnreadNotificationsByMemberAndType(receiver, MessageType.CREW); + } + + @Test + @DisplayName("대결 타입으로 조회 시 대결 관련 알림만 반환한다") + void shouldReturnBattleNotificationsWhenTypeIsBattle() { + // given + List readNotifications = List.of(); + List unreadNotifications = List.of(battleNotification); + List readItems = List.of(); + List unreadItems = List.of(battleNotificationItem); + + given(notificationRepository.findReadNotificationsByMemberAndType(receiver, MessageType.BATTLE)) + .willReturn(readNotifications); + given(notificationRepository.findUnreadNotificationsByMemberAndType(receiver, MessageType.BATTLE)) + .willReturn(unreadNotifications); + given(notificationMapper.toNotificationItemList(readNotifications)) + .willReturn(readItems); + given(notificationMapper.toNotificationItemList(unreadNotifications)) + .willReturn(unreadItems); + + // when + NotificationResponse response = sut.getNotifications(receiver, "battle"); + + // then + assertThat(response.read()).isEmpty(); + assertThat(response.unread()).hasSize(1); + assertThat(response.unread()).containsExactly(battleNotificationItem); + + then(notificationRepository).should().findReadNotificationsByMemberAndType(receiver, MessageType.BATTLE); + then(notificationRepository).should().findUnreadNotificationsByMemberAndType(receiver, MessageType.BATTLE); + } + + @Test + @DisplayName("대소문자 구분 없이 타입을 처리한다") + void shouldHandleTypesCaseInsensitively() { + // given + List readNotifications = List.of(); + List unreadNotifications = List.of(crewNotification); + List readItems = List.of(); + List unreadItems = List.of(crewNotificationItem); + + given(notificationRepository.findReadNotificationsByMemberAndType(receiver, MessageType.CREW)) + .willReturn(readNotifications); + given(notificationRepository.findUnreadNotificationsByMemberAndType(receiver, MessageType.CREW)) + .willReturn(unreadNotifications); + given(notificationMapper.toNotificationItemList(readNotifications)) + .willReturn(readItems); + given(notificationMapper.toNotificationItemList(unreadNotifications)) + .willReturn(unreadItems); + + // when + NotificationResponse response = sut.getNotifications(receiver, "CREW"); + + // then + assertThat(response.unread()).hasSize(1); + then(notificationRepository).should().findReadNotificationsByMemberAndType(receiver, MessageType.CREW); + then(notificationRepository).should().findUnreadNotificationsByMemberAndType(receiver, MessageType.CREW); + } + + @Test + @DisplayName("빈 결과를 올바르게 처리한다") + void shouldHandleEmptyResultsCorrectly() { + // given + List emptyNotifications = List.of(); + List emptyItems = List.of(); + + given(notificationRepository.findReadNotificationsByMember(receiver)) + .willReturn(emptyNotifications); + given(notificationRepository.findUnreadNotificationsByMember(receiver)) + .willReturn(emptyNotifications); + given(notificationMapper.toNotificationItemList(emptyNotifications)) + .willReturn(emptyItems); + + // when + NotificationResponse response = sut.getNotifications(receiver, "all"); + + // then + assertThat(response.read()).isEmpty(); + assertThat(response.unread()).isEmpty(); + } + + @Test + @DisplayName("유효하지 않은 타입일 때 InvalidNotificationType 예외를 발생시킨다") + void shouldThrowInvalidNotificationTypeWhenTypeIsInvalid() { + // when & then + assertThatThrownBy(() -> sut.getNotifications(receiver, "invalid")) + .isInstanceOf(InvalidNotificationType.class); + + then(notificationRepository).shouldHaveNoInteractions(); + then(notificationMapper).shouldHaveNoInteractions(); + } + } + + @Nested + @DisplayName("markAsRead 메서드는") + class MarkAsReadTest { + + @Test + @DisplayName("알림을 읽음 상태로 변경한다") + void shouldMarkNotificationAsRead() { + // given + given(notificationRepository.findByIdAndReceiver(1L, receiver)) + .willReturn(Optional.of(crewNotification)); + + // when + sut.markAsRead(1L, receiver); + + // then + then(notificationRepository).should().findByIdAndReceiver(1L, receiver); + } + + @Test + @DisplayName("존재하지 않는 알림이거나 다른 사용자의 알림일 때 NotificationNotFound 예외를 발생시킨다") + void shouldThrowNotificationNotFoundWhenNotificationDoesNotExistOrNotOwned() { + // given + given(notificationRepository.findByIdAndReceiver(999L, receiver)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> sut.markAsRead(999L, receiver)) + .isInstanceOf(NotificationNotFound.class); + } + } + + @Nested + @DisplayName("markAllAsRead 메서드는") + class MarkAllAsReadTest { + + @Test + @DisplayName("모든 읽지 않은 알림을 읽음 상태로 변경한다") + void shouldMarkAllUnreadNotificationsAsRead() { + // given + List unreadNotifications = List.of(crewNotification, battleNotification); + + given(notificationRepository.findUnreadNotificationsByMember(receiver)) + .willReturn(unreadNotifications); + + // when + sut.markAllAsRead(receiver); + + // then + then(notificationRepository).should().findUnreadNotificationsByMember(receiver); + } + + @Test + @DisplayName("읽지 않은 알림이 없을 때도 정상 처리된다") + void shouldHandleEmptyUnreadNotifications() { + // given + List emptyNotifications = List.of(); + + given(notificationRepository.findUnreadNotificationsByMember(receiver)) + .willReturn(emptyNotifications); + + // when + sut.markAllAsRead(receiver); + + // then + then(notificationRepository).should().findUnreadNotificationsByMember(receiver); + } + } + + private Member createMemberWithId(Long id, String nickname) { + Member member = Member.builder() + .username("test_user") + .nickname(nickname) + .gender(Gender.MALE) + .age(25) + .oauthId("oauth_id_" + id) + .oauthType(OAuthType.GOOGLE) + .profileImage("profile.jpg") + .build(); + + ReflectionTestUtils.setField(member, "id", id); + return member; + } + + private Notification createNotification(MessageType messageType, String message, boolean isRead) { + Notification notification = Notification.builder() + .message(message) + .messageType(messageType) + .targetId(1L) + .sender(sender) + .receiver(receiver) + .build(); + + ReflectionTestUtils.setField(notification, "id", System.currentTimeMillis()); + ReflectionTestUtils.setField(notification, "isRead", isRead); + ReflectionTestUtils.setField(notification, "createdAt", LocalDateTime.now().minusMinutes(10)); + + return notification; + } +}