diff --git a/src/main/java/com/dnd/gongmuin/answer/service/AnswerService.java b/src/main/java/com/dnd/gongmuin/answer/service/AnswerService.java index f6176df..2ee605e 100644 --- a/src/main/java/com/dnd/gongmuin/answer/service/AnswerService.java +++ b/src/main/java/com/dnd/gongmuin/answer/service/AnswerService.java @@ -1,5 +1,7 @@ package com.dnd.gongmuin.answer.service; +import static com.dnd.gongmuin.notification.domain.NotificationType.*; + import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,6 +18,7 @@ import com.dnd.gongmuin.common.exception.runtime.ValidationException; import com.dnd.gongmuin.credit_history.service.CreditHistoryService; import com.dnd.gongmuin.member.domain.Member; +import com.dnd.gongmuin.notification.service.NotificationService; import com.dnd.gongmuin.question_post.domain.QuestionPost; import com.dnd.gongmuin.question_post.exception.QuestionPostErrorCode; import com.dnd.gongmuin.question_post.repository.QuestionPostRepository; @@ -29,6 +32,7 @@ public class AnswerService { private final QuestionPostRepository questionPostRepository; private final AnswerRepository answerRepository; private final CreditHistoryService creditHistoryService; + private final NotificationService notificationService; private static void validateIfQuestioner(Member member, QuestionPost questionPost) { if (!questionPost.isQuestioner(member.getId())) { @@ -45,7 +49,12 @@ public AnswerDetailResponse registerAnswer( QuestionPost questionPost = findQuestionPostById(questionPostId); Answer answer = AnswerMapper.toAnswer(questionPostId, questionPost.isQuestioner(member.getId()), request, member); - return AnswerMapper.toAnswerDetailResponse(answerRepository.save(answer)); + Answer savedAnswer = answerRepository.save(answer); + + notificationService.saveNotificationFromTarget( + ANSWER, questionPost.getId(), member.getId(), questionPost.getMember() + ); + return AnswerMapper.toAnswerDetailResponse(savedAnswer); } @Transactional(readOnly = true) @@ -66,6 +75,9 @@ public AnswerDetailResponse chooseAnswer( QuestionPost questionPost = findQuestionPostById(answer.getQuestionPostId()); validateIfQuestioner(member, questionPost); chooseAnswer(questionPost, answer); + notificationService.saveNotificationFromTarget( + CHOSEN, questionPost.getId(), member.getId(), answer.getMember() + ); return AnswerMapper.toAnswerDetailResponse(answer); } diff --git a/src/main/java/com/dnd/gongmuin/member/repository/MemberCustomImpl.java b/src/main/java/com/dnd/gongmuin/member/repository/MemberCustomImpl.java index fd63d28..3d7d6ed 100644 --- a/src/main/java/com/dnd/gongmuin/member/repository/MemberCustomImpl.java +++ b/src/main/java/com/dnd/gongmuin/member/repository/MemberCustomImpl.java @@ -43,8 +43,8 @@ public Slice getQuestionPostsByMember(Member member, Page List content = queryFactory .select(new QQuestionPostsResponse( qp, - saved.count.coalesce(0).as("savedTotalCount"), - recommend.count.coalesce(0).as("recommendTotalCount") + saved.count.coalesce(0).as("bookmarkCount"), + recommend.count.coalesce(0).as("recommendCount") )) .from(qp) .leftJoin(saved) @@ -75,8 +75,8 @@ public Slice getAnsweredQuestionPostsByMember( queryFactory .select(new QAnsweredQuestionPostsResponse( qp, - saved.count.coalesce(0).as("savedTotalCount"), - recommend.count.coalesce(0).as("recommendTotalCount"), + saved.count.coalesce(0).as("bookmarkCount"), + recommend.count.coalesce(0).as("recommendCount"), aw1 )) .from(qp) @@ -119,8 +119,8 @@ public Slice getBookmarksByMember(Member member, Pageable pag List content = queryFactory .select(new QBookmarksResponse( qp, - saved.count.coalesce(0).as("savedTotalCount"), - recommend.count.coalesce(0).as("recommendTotalCount") + saved.count.coalesce(0).as("bookmarkCount"), + recommend.count.coalesce(0).as("recommendCount") )) .from(qp) .join(ir) diff --git a/src/main/java/com/dnd/gongmuin/notification/controller/NotificationController.java b/src/main/java/com/dnd/gongmuin/notification/controller/NotificationController.java new file mode 100644 index 0000000..24ed7bf --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/notification/controller/NotificationController.java @@ -0,0 +1,51 @@ +package com.dnd.gongmuin.notification.controller; + +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.dnd.gongmuin.common.dto.PageResponse; +import com.dnd.gongmuin.member.domain.Member; +import com.dnd.gongmuin.notification.dto.request.readNotificationRequest; +import com.dnd.gongmuin.notification.dto.response.NotificationsResponse; +import com.dnd.gongmuin.notification.dto.response.readNotificationResponse; +import com.dnd.gongmuin.notification.service.NotificationService; + +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "알림 API", description = "알림 API") +@RestController +@RequiredArgsConstructor +public class NotificationController { + + private final NotificationService notificationService; + + @GetMapping("/api/notifications") + public ResponseEntity> getNotificationsByMember( + @RequestParam("type") String type, + @AuthenticationPrincipal Member member, + Pageable pageable) { + + PageResponse response = + notificationService.getNotificationsByMember(type, member, pageable); + + return ResponseEntity.ok(response); + } + + @PatchMapping("/api/notification/read") + public ResponseEntity readNotification( + @RequestBody @Valid readNotificationRequest request, + @AuthenticationPrincipal Member member + ) { + readNotificationResponse response = notificationService.readNotification(request, member); + + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/dnd/gongmuin/notification/domain/Notification.java b/src/main/java/com/dnd/gongmuin/notification/domain/Notification.java index c1bfb21..216e227 100644 --- a/src/main/java/com/dnd/gongmuin/notification/domain/Notification.java +++ b/src/main/java/com/dnd/gongmuin/notification/domain/Notification.java @@ -2,6 +2,7 @@ import static jakarta.persistence.ConstraintMode.*; import static jakarta.persistence.FetchType.*; +import static lombok.AccessLevel.*; import com.dnd.gongmuin.common.entity.TimeBaseEntity; import com.dnd.gongmuin.member.domain.Member; @@ -38,8 +39,11 @@ public class Notification extends TimeBaseEntity { @Column(name = "is_read", nullable = false) private Boolean isRead; - @Column(name = "target_url") - private String targetUrl; + @Column(name = "target_id") + private Long targetId; + + @Column(name = "trigger_member_id") + private Long triggerMemberId; @ManyToOne(fetch = LAZY) @JoinColumn(name = "member_id", @@ -47,11 +51,35 @@ public class Notification extends TimeBaseEntity { foreignKey = @ForeignKey(NO_CONSTRAINT)) private Member member; - @Builder - public Notification(NotificationType type, Boolean isRead, String targetUrl, Member member) { + @Builder(access = PRIVATE) + private Notification( + NotificationType type, + Boolean isRead, + Long targetId, + Long triggerMemberId, + Member member) { this.type = type; this.isRead = isRead; - this.targetUrl = targetUrl; + this.targetId = targetId; + this.triggerMemberId = triggerMemberId; this.member = member; } + + public static Notification of( + NotificationType type, + Long targetId, + Long triggerMemberId, + Member member) { + return Notification.builder() + .type(type) + .isRead(false) + .targetId(targetId) + .triggerMemberId(triggerMemberId) + .member(member) + .build(); + } + + public void updateIsReadTrue() { + this.isRead = Boolean.TRUE; + } } diff --git a/src/main/java/com/dnd/gongmuin/notification/domain/NotificationType.java b/src/main/java/com/dnd/gongmuin/notification/domain/NotificationType.java index 63931d1..66ce4f6 100644 --- a/src/main/java/com/dnd/gongmuin/notification/domain/NotificationType.java +++ b/src/main/java/com/dnd/gongmuin/notification/domain/NotificationType.java @@ -2,6 +2,9 @@ import java.util.Arrays; +import com.dnd.gongmuin.common.exception.runtime.NotFoundException; +import com.dnd.gongmuin.notification.exception.NotificationErrorCode; + import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -9,17 +12,17 @@ @RequiredArgsConstructor public enum NotificationType { - ANSWER("댓글"), + ANSWER("답변"), CHOSEN("채택"), CHAT("채팅"); private final String label; - public static NotificationType of(String input) { + public static NotificationType from(String input) { return Arrays.stream(values()) .filter(type -> type.isEqual(input)) .findAny() - .orElseThrow(IllegalArgumentException::new); + .orElseThrow(() -> new NotFoundException(NotificationErrorCode.NOT_FOUND_NOTIFICATION_TYPE)); } private boolean isEqual(String input) { diff --git a/src/main/java/com/dnd/gongmuin/notification/dto/NotificationMapper.java b/src/main/java/com/dnd/gongmuin/notification/dto/NotificationMapper.java new file mode 100644 index 0000000..ca73e37 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/notification/dto/NotificationMapper.java @@ -0,0 +1,18 @@ +package com.dnd.gongmuin.notification.dto; + +import com.dnd.gongmuin.notification.domain.Notification; +import com.dnd.gongmuin.notification.dto.response.readNotificationResponse; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class NotificationMapper { + + public static readNotificationResponse toIsReadNotificationResponse(Notification notification) { + return new readNotificationResponse( + notification.getId(), + notification.getIsRead() + ); + } +} diff --git a/src/main/java/com/dnd/gongmuin/notification/dto/request/readNotificationRequest.java b/src/main/java/com/dnd/gongmuin/notification/dto/request/readNotificationRequest.java new file mode 100644 index 0000000..08e970c --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/notification/dto/request/readNotificationRequest.java @@ -0,0 +1,10 @@ +package com.dnd.gongmuin.notification.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record readNotificationRequest( + + @NotNull(message = "알림 ID 값은 필수 값 입니다.") + Long notificationId +) { +} diff --git a/src/main/java/com/dnd/gongmuin/notification/dto/response/NotificationsResponse.java b/src/main/java/com/dnd/gongmuin/notification/dto/response/NotificationsResponse.java new file mode 100644 index 0000000..05cecd5 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/notification/dto/response/NotificationsResponse.java @@ -0,0 +1,42 @@ +package com.dnd.gongmuin.notification.dto.response; + +import com.dnd.gongmuin.member.domain.Member; +import com.dnd.gongmuin.notification.domain.Notification; +import com.querydsl.core.annotations.QueryProjection; + +public record NotificationsResponse( + + Long notificationId, + + String type, + + Boolean isRead, + + Long targetId, + + Long triggerMemberId, + + String triggerMemberNickName, + + Long targetMemberId, + + String NotificationCreatedAt +) { + + @QueryProjection + public NotificationsResponse( + Notification notification, + Member triggerMember + ) { + this( + notification.getId(), + notification.getType().getLabel(), + notification.getIsRead(), + notification.getTargetId(), + triggerMember.getId(), + triggerMember.getNickname(), + notification.getMember().getId(), + notification.getCreatedAt().toString() + ); + } +} diff --git a/src/main/java/com/dnd/gongmuin/notification/dto/response/readNotificationResponse.java b/src/main/java/com/dnd/gongmuin/notification/dto/response/readNotificationResponse.java new file mode 100644 index 0000000..599f9af --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/notification/dto/response/readNotificationResponse.java @@ -0,0 +1,7 @@ +package com.dnd.gongmuin.notification.dto.response; + +public record readNotificationResponse( + Long notificationId, + Boolean isRead +) { +} diff --git a/src/main/java/com/dnd/gongmuin/notification/exception/NotificationErrorCode.java b/src/main/java/com/dnd/gongmuin/notification/exception/NotificationErrorCode.java new file mode 100644 index 0000000..0d67bb8 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/notification/exception/NotificationErrorCode.java @@ -0,0 +1,21 @@ +package com.dnd.gongmuin.notification.exception; + +import com.dnd.gongmuin.common.exception.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum NotificationErrorCode implements ErrorCode { + + NOT_FOUND_NOTIFICATION_TYPE("알맞은 알림 타입을 찾을 수 없습니다.", "NOTIFICATION_001"), + SAVE_NOTIFICATION_FAILED("알림 저장을 실패했습니다.", "NOTIFICATION_002"), + NOTIFICATIONS_BY_MEMBER_FAILED("알림 목록을 불러오는데 실패했습니다.", "NOTIFICATION_003"), + NOT_FOUND_NOTIFICATION("해당 알림을 찾을 수 없습니다", "NOTIFICATION_004"), + CHANGE_IS_READ_NOTIFICATION_FAILED("해당 알림의 읽음 여부 변경을 실패했습니다.", "NOTIFICATION_005"), + INVALID_NOTIFICATION_OWNER("해당 알림의 주인이 아닙니다.", "NOTIFICATION_006"); + + private final String message; + private final String code; +} diff --git a/src/main/java/com/dnd/gongmuin/notification/repository/NotificationCustom.java b/src/main/java/com/dnd/gongmuin/notification/repository/NotificationCustom.java new file mode 100644 index 0000000..21c1835 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/notification/repository/NotificationCustom.java @@ -0,0 +1,13 @@ +package com.dnd.gongmuin.notification.repository; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +import com.dnd.gongmuin.member.domain.Member; +import com.dnd.gongmuin.notification.dto.response.NotificationsResponse; + +public interface NotificationCustom { + + Slice getNotificationsByMember(String type, Member member, Pageable pageable); + +} diff --git a/src/main/java/com/dnd/gongmuin/notification/repository/NotificationCustomImpl.java b/src/main/java/com/dnd/gongmuin/notification/repository/NotificationCustomImpl.java new file mode 100644 index 0000000..9ec2229 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/notification/repository/NotificationCustomImpl.java @@ -0,0 +1,72 @@ +package com.dnd.gongmuin.notification.repository; + +import static com.dnd.gongmuin.notification.domain.QNotification.*; + +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +import com.dnd.gongmuin.member.domain.Member; +import com.dnd.gongmuin.member.domain.QMember; +import com.dnd.gongmuin.notification.domain.NotificationType; +import com.dnd.gongmuin.notification.domain.QNotification; +import com.dnd.gongmuin.notification.dto.response.NotificationsResponse; +import com.dnd.gongmuin.notification.dto.response.QNotificationsResponse; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class NotificationCustomImpl implements NotificationCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Slice getNotificationsByMember( + String type, + Member member, + Pageable pageable) { + QNotification nc = notification; + QMember tm = QMember.member; + + List content = queryFactory + .select(new QNotificationsResponse( + nc, + tm + )) + .from(nc) + .join(tm).on(nc.triggerMemberId.eq(tm.id)) + .where( + nc.member.eq(member), + targetTypeEq(type) + ) + .orderBy(nc.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1L) + .fetch(); + + boolean hasNext = hasNext(pageable.getPageSize(), content); + + return new SliceImpl<>(content, pageable, hasNext); + } + + private BooleanExpression targetTypeEq(String type) { + if (type == null || type.isEmpty() || "전체".equals(type)) { + return null; + } + + return notification.type.in(NotificationType.from(type)); + } + + private boolean hasNext(int pageSize, List content) { + if (content.size() <= pageSize) { + return false; + } + content.remove(pageSize); + return true; + } +} + diff --git a/src/main/java/com/dnd/gongmuin/notification/repository/NotificationRepository.java b/src/main/java/com/dnd/gongmuin/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..7b5b963 --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/notification/repository/NotificationRepository.java @@ -0,0 +1,10 @@ +package com.dnd.gongmuin.notification.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.dnd.gongmuin.notification.domain.Notification; + +@Repository +public interface NotificationRepository extends JpaRepository, NotificationCustom { +} diff --git a/src/main/java/com/dnd/gongmuin/notification/service/NotificationService.java b/src/main/java/com/dnd/gongmuin/notification/service/NotificationService.java new file mode 100644 index 0000000..b79aedd --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/notification/service/NotificationService.java @@ -0,0 +1,82 @@ +package com.dnd.gongmuin.notification.service; + +import java.util.Objects; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.gongmuin.common.dto.PageMapper; +import com.dnd.gongmuin.common.dto.PageResponse; +import com.dnd.gongmuin.common.exception.runtime.NotFoundException; +import com.dnd.gongmuin.common.exception.runtime.ValidationException; +import com.dnd.gongmuin.member.domain.Member; +import com.dnd.gongmuin.notification.domain.Notification; +import com.dnd.gongmuin.notification.domain.NotificationType; +import com.dnd.gongmuin.notification.dto.NotificationMapper; +import com.dnd.gongmuin.notification.dto.request.readNotificationRequest; +import com.dnd.gongmuin.notification.dto.response.NotificationsResponse; +import com.dnd.gongmuin.notification.dto.response.readNotificationResponse; +import com.dnd.gongmuin.notification.exception.NotificationErrorCode; +import com.dnd.gongmuin.notification.repository.NotificationRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final NotificationRepository notificationRepository; + + @Transactional + public void saveNotificationFromTarget( + NotificationType type, + Long targetId, + Long triggerMemberId, + Member toMember) { + Notification notification = Notification.of(type, targetId, triggerMemberId, toMember); + try { + notificationRepository.save(notification); + } catch (Exception e) { + throw new ValidationException(NotificationErrorCode.SAVE_NOTIFICATION_FAILED); + } + } + + public PageResponse getNotificationsByMember( + String type, + Member member, + Pageable pageable) { + + try { + Slice responsePage = + notificationRepository.getNotificationsByMember(type, member, pageable); + + return PageMapper.toPageResponse(responsePage); + } catch (Exception e) { + throw new NotFoundException(NotificationErrorCode.NOTIFICATIONS_BY_MEMBER_FAILED); + } + } + + @Transactional + public readNotificationResponse readNotification(readNotificationRequest request, Member member) { + Notification findNotification = notificationRepository.findById(request.notificationId()) + .orElseThrow(() -> new NotFoundException(NotificationErrorCode.NOT_FOUND_NOTIFICATION)); + + if (!isNotificationOwnedByMember(findNotification, member)) { + throw new ValidationException(NotificationErrorCode.INVALID_NOTIFICATION_OWNER); + } + + if (Boolean.TRUE.equals(findNotification.getIsRead())) { + throw new ValidationException(NotificationErrorCode.CHANGE_IS_READ_NOTIFICATION_FAILED); + } + + findNotification.updateIsReadTrue(); + + return NotificationMapper.toIsReadNotificationResponse(findNotification); + } + + private boolean isNotificationOwnedByMember(Notification notification, Member member) { + return Objects.equals(notification.getMember().getId(), member.getId()); + } +} diff --git a/src/test/java/com/dnd/gongmuin/answer/service/AnswerServiceTest.java b/src/test/java/com/dnd/gongmuin/answer/service/AnswerServiceTest.java index 17a8022..520aab5 100644 --- a/src/test/java/com/dnd/gongmuin/answer/service/AnswerServiceTest.java +++ b/src/test/java/com/dnd/gongmuin/answer/service/AnswerServiceTest.java @@ -31,6 +31,7 @@ import com.dnd.gongmuin.credit_history.service.CreditHistoryService; import com.dnd.gongmuin.member.domain.Member; import com.dnd.gongmuin.member.exception.MemberErrorCode; +import com.dnd.gongmuin.notification.service.NotificationService; import com.dnd.gongmuin.question_post.domain.QuestionPost; import com.dnd.gongmuin.question_post.exception.QuestionPostErrorCode; import com.dnd.gongmuin.question_post.repository.QuestionPostRepository; @@ -50,6 +51,9 @@ class AnswerServiceTest { @Mock private CreditHistoryService creditHistoryService; + @Mock + private NotificationService notificationService; + @InjectMocks private AnswerService answerService; diff --git a/src/test/java/com/dnd/gongmuin/common/fixture/NotificationFixture.java b/src/test/java/com/dnd/gongmuin/common/fixture/NotificationFixture.java new file mode 100644 index 0000000..1382d5e --- /dev/null +++ b/src/test/java/com/dnd/gongmuin/common/fixture/NotificationFixture.java @@ -0,0 +1,25 @@ +package com.dnd.gongmuin.common.fixture; + +import com.dnd.gongmuin.member.domain.Member; +import com.dnd.gongmuin.notification.domain.Notification; +import com.dnd.gongmuin.notification.domain.NotificationType; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class NotificationFixture { + + public static Notification notification( + NotificationType type, + Long questionPostId, + Long triggerMemberId, + Member member) { + return Notification.of( + type, + questionPostId, + triggerMemberId, + member + ); + } +} diff --git a/src/test/java/com/dnd/gongmuin/notification/controller/NotificationControllerTest.java b/src/test/java/com/dnd/gongmuin/notification/controller/NotificationControllerTest.java new file mode 100644 index 0000000..37d738e --- /dev/null +++ b/src/test/java/com/dnd/gongmuin/notification/controller/NotificationControllerTest.java @@ -0,0 +1,123 @@ +package com.dnd.gongmuin.notification.controller; + +import static com.dnd.gongmuin.notification.domain.NotificationType.*; +import static org.springframework.http.MediaType.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.web.servlet.result.MockMvcResultHandlers; + +import com.dnd.gongmuin.answer.repository.AnswerRepository; +import com.dnd.gongmuin.common.fixture.MemberFixture; +import com.dnd.gongmuin.common.fixture.NotificationFixture; +import com.dnd.gongmuin.common.fixture.QuestionPostFixture; +import com.dnd.gongmuin.common.support.ApiTestSupport; +import com.dnd.gongmuin.member.domain.Member; +import com.dnd.gongmuin.member.repository.MemberRepository; +import com.dnd.gongmuin.notification.domain.Notification; +import com.dnd.gongmuin.notification.dto.request.readNotificationRequest; +import com.dnd.gongmuin.notification.repository.NotificationRepository; +import com.dnd.gongmuin.question_post.domain.QuestionPost; +import com.dnd.gongmuin.question_post.repository.QuestionPostRepository; + +@DisplayName("[NotificationController] 통합테스트") +class NotificationControllerTest extends ApiTestSupport { + + private final PageRequest pageRequest = PageRequest.of(0, 10); + + @Autowired + MemberRepository memberRepository; + + @Autowired + QuestionPostRepository questionPostRepository; + + @Autowired + AnswerRepository answerRepository; + + @Autowired + NotificationRepository notificationRepository; + + @AfterEach + void tearDown() { + answerRepository.deleteAll(); + notificationRepository.deleteAll(); + questionPostRepository.deleteAll(); + memberRepository.deleteAll(); + } + + @DisplayName("로그인 된 회원의 알림 목록을 조회힌다.") + @Test + void getNotificationsByMember() throws Exception { + // given + Member member2 = MemberFixture.member2(); + Member member3 = MemberFixture.member3(); + memberRepository.saveAll(List.of(member2, member3)); + + QuestionPost questionPost1 = QuestionPostFixture.questionPost(loginMember, "첫 번째 게시글입니다."); + questionPostRepository.save(questionPost1); + + Notification notification1 = NotificationFixture.notification( + ANSWER, questionPost1.getId(), member2.getId(), loginMember + ); + Notification notification2 = NotificationFixture.notification( + ANSWER, questionPost1.getId(), member3.getId(), loginMember + ); + Notification notification3 = NotificationFixture.notification( + CHOSEN, questionPost1.getId(), member3.getId(), loginMember + ); + notificationRepository.saveAll(List.of(notification1, notification2, notification3)); + + // when // then + mockMvc.perform(get("/api/notifications") + .param("type", "전체") + .cookie(accessToken) + ) + .andExpect(status().isOk()) + .andDo(MockMvcResultHandlers.print()) + .andExpect(jsonPath("$.size").value(3)) + .andExpect(jsonPath("$.content[0].type").value(CHOSEN.getLabel())) + .andExpect(jsonPath("$.content[1].type").value(ANSWER.getLabel())) + .andExpect(jsonPath("$.content[2].type").value(ANSWER.getLabel())) + .andExpect(jsonPath("$.content[0].triggerMemberId").value(member3.getId())) + .andExpect(jsonPath("$.content[1].triggerMemberId").value(member3.getId())) + .andExpect(jsonPath("$.content[2].triggerMemberId").value(member2.getId())) + .andExpect(jsonPath("$.content[0].targetMemberId").value(loginMember.getId())) + .andExpect(jsonPath("$.content[1].targetMemberId").value(loginMember.getId())) + .andExpect(jsonPath("$.content[2].targetMemberId").value(loginMember.getId())); + } + + @DisplayName("회원의 특정 알림을 읽음 여부로 변경한다.") + @Test + void isReadNotification() throws Exception { + // given + Member member2 = MemberFixture.member2(); + memberRepository.save(member2); + + QuestionPost questionPost1 = QuestionPostFixture.questionPost(loginMember, "첫 번째 게시글입니다."); + questionPostRepository.save(questionPost1); + + Notification notification = NotificationFixture.notification( + ANSWER, questionPost1.getId(), member2.getId(), loginMember + ); + notificationRepository.save(notification); + + readNotificationRequest request = new readNotificationRequest(notification.getId()); + + // when // then + mockMvc.perform(patch("/api/notification/read") + .content(toJson(request)) + .contentType(APPLICATION_JSON) + .cookie(accessToken) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("notificationId").value(notification.getId())) + .andExpect(jsonPath("isRead").value(Boolean.TRUE)); + } +} diff --git a/src/test/java/com/dnd/gongmuin/notification/repository/NotificationRepositoryTest.java b/src/test/java/com/dnd/gongmuin/notification/repository/NotificationRepositoryTest.java new file mode 100644 index 0000000..637ae33 --- /dev/null +++ b/src/test/java/com/dnd/gongmuin/notification/repository/NotificationRepositoryTest.java @@ -0,0 +1,148 @@ +package com.dnd.gongmuin.notification.repository; + +import static com.dnd.gongmuin.notification.domain.NotificationType.*; +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; + +import com.dnd.gongmuin.answer.repository.AnswerRepository; +import com.dnd.gongmuin.common.fixture.MemberFixture; +import com.dnd.gongmuin.common.fixture.NotificationFixture; +import com.dnd.gongmuin.common.fixture.QuestionPostFixture; +import com.dnd.gongmuin.common.support.DataJpaTestSupport; +import com.dnd.gongmuin.member.domain.Member; +import com.dnd.gongmuin.member.repository.MemberRepository; +import com.dnd.gongmuin.notification.domain.Notification; +import com.dnd.gongmuin.notification.dto.response.NotificationsResponse; +import com.dnd.gongmuin.question_post.domain.QuestionPost; +import com.dnd.gongmuin.question_post.repository.QuestionPostRepository; + +class NotificationRepositoryTest extends DataJpaTestSupport { + + private final PageRequest pageRequest = PageRequest.of(0, 10); + + @Autowired + MemberRepository memberRepository; + + @Autowired + QuestionPostRepository questionPostRepository; + + @Autowired + AnswerRepository answerRepository; + + @Autowired + NotificationRepository notificationRepository; + + @DisplayName("회원의 알림 전체 목록을 불러온다.") + @Test + void getNotificationsByMember() { + // given + Member member1 = MemberFixture.member(); + Member member2 = MemberFixture.member2(); + Member member3 = MemberFixture.member3(); + memberRepository.saveAll(List.of(member1, member2, member3)); + + QuestionPost questionPost1 = QuestionPostFixture.questionPost(member1, "첫 번째 게시글입니다."); + questionPostRepository.save(questionPost1); + + Notification notification1 = NotificationFixture.notification( + ANSWER, questionPost1.getId(), member2.getId(), member1 + ); + Notification notification2 = NotificationFixture.notification( + ANSWER, questionPost1.getId(), member3.getId(), member1 + ); + Notification notification3 = NotificationFixture.notification( + CHOSEN, questionPost1.getId(), member3.getId(), member1 + ); + notificationRepository.saveAll(List.of(notification1, notification2, notification3)); + + // when + Slice notificationsByMember = notificationRepository.getNotificationsByMember("전체", + member1, pageRequest); + + // then + Assertions.assertAll( + () -> assertThat(notificationsByMember).hasSize(3), + () -> assertThat(notificationsByMember).extracting(NotificationsResponse::type) + .containsExactly( + "채택", + "답변", + "답변" + ), + () -> assertThat(notificationsByMember).extracting(NotificationsResponse::triggerMemberId) + .containsExactly( + member3.getId(), + member3.getId(), + member2.getId() + ), + () -> assertThat(notificationsByMember).extracting(NotificationsResponse::triggerMemberNickName) + .containsExactly( + member3.getNickname(), + member3.getNickname(), + member2.getNickname() + ), + () -> assertThat(notificationsByMember).extracting(NotificationsResponse::targetMemberId) + .containsExactly( + member1.getId(), + member1.getId(), + member1.getId() + ) + ); + } + + @DisplayName("회원의 알림 채택 목록을 불러온다.") + @Test + void getNotificationsByMemberWithChosen() { + // given + Member member1 = MemberFixture.member(); + Member member2 = MemberFixture.member2(); + Member member3 = MemberFixture.member3(); + memberRepository.saveAll(List.of(member1, member2, member3)); + + QuestionPost questionPost1 = QuestionPostFixture.questionPost(member1, "첫 번째 게시글입니다."); + questionPostRepository.save(questionPost1); + + Notification notification1 = NotificationFixture.notification( + ANSWER, questionPost1.getId(), member2.getId(), member1 + ); + Notification notification2 = NotificationFixture.notification( + ANSWER, questionPost1.getId(), member3.getId(), member1 + ); + Notification notification3 = NotificationFixture.notification( + CHOSEN, questionPost1.getId(), member3.getId(), member1 + ); + notificationRepository.saveAll(List.of(notification1, notification2, notification3)); + + // when + Slice notificationsByMember = notificationRepository.getNotificationsByMember("채택", + member1, pageRequest); + + // then + Assertions.assertAll( + () -> assertThat(notificationsByMember).hasSize(1), + () -> assertThat(notificationsByMember).extracting(NotificationsResponse::type) + .containsExactly( + "채택" + ), + () -> assertThat(notificationsByMember).extracting(NotificationsResponse::triggerMemberId) + .containsExactly( + member3.getId() + ), + () -> assertThat(notificationsByMember).extracting(NotificationsResponse::triggerMemberNickName) + .containsExactly( + member3.getNickname() + ), + () -> assertThat(notificationsByMember).extracting(NotificationsResponse::targetMemberId) + .containsExactly( + member1.getId() + ) + ); + } +} diff --git a/src/test/java/com/dnd/gongmuin/notification/service/NotificationServiceTest.java b/src/test/java/com/dnd/gongmuin/notification/service/NotificationServiceTest.java new file mode 100644 index 0000000..353aaf4 --- /dev/null +++ b/src/test/java/com/dnd/gongmuin/notification/service/NotificationServiceTest.java @@ -0,0 +1,132 @@ +package com.dnd.gongmuin.notification.service; + +import static com.dnd.gongmuin.notification.domain.NotificationType.*; +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.gongmuin.common.exception.runtime.ValidationException; +import com.dnd.gongmuin.common.fixture.MemberFixture; +import com.dnd.gongmuin.common.fixture.NotificationFixture; +import com.dnd.gongmuin.common.fixture.QuestionPostFixture; +import com.dnd.gongmuin.member.domain.Member; +import com.dnd.gongmuin.notification.domain.Notification; +import com.dnd.gongmuin.notification.dto.request.readNotificationRequest; +import com.dnd.gongmuin.notification.dto.response.readNotificationResponse; +import com.dnd.gongmuin.notification.repository.NotificationRepository; +import com.dnd.gongmuin.question_post.domain.QuestionPost; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock + NotificationRepository notificationRepository; + + @InjectMocks + NotificationService notificationService; + + @DisplayName("타겟 타입에 맞는 알림을 만들고 저장한다.") + @Test + void saveNotificationFromTarget() { + // given + Member member1 = MemberFixture.member(1L); + Member member2 = MemberFixture.member(2L); + + QuestionPost questionPost = QuestionPostFixture.questionPost(1L); + + // when + notificationService.saveNotificationFromTarget(ANSWER, questionPost.getId(), member2.getId(), member1); + + // then + verify(notificationRepository).save(any(Notification.class)); + verify(notificationRepository).save(argThat(notification -> + notification.getType().equals(ANSWER) && + notification.getTargetId().equals(questionPost.getId()) && + notification.getTriggerMemberId().equals(member2.getId()) && + notification.getMember().equals(member1) + )); + } + + @DisplayName("알림의 안읽음 상태를 읽음 상태로 변경한다.") + @Test + void isReadNotification() { + // given + Member member1 = MemberFixture.member(); + Member member2 = MemberFixture.member2(); + + QuestionPost questionPost = QuestionPostFixture.questionPost(member1); + Notification notification = NotificationFixture.notification( + ANSWER, + questionPost.getId(), + member2.getId(), + member1 + ); + readNotificationRequest request = new readNotificationRequest(1L); + + given(notificationRepository.findById(anyLong())).willReturn(Optional.ofNullable(notification)); + + // when + readNotificationResponse response = notificationService.readNotification(request, member1); + + // then + assertAll( + () -> assertThat(response.notificationId()).isEqualTo(notification.getId()), + () -> assertThat(response.isRead()).isTrue() + ); + } + + @DisplayName("요청 회원이 특정 알림의 소유주가 아니라면 예외가 발생한다.") + @Test + void isReadNotificationThrowsExceptionWhenMemberIsNotOwner() { + // given + Member member1 = MemberFixture.member(); + Member member2 = MemberFixture.member2(); + + QuestionPost questionPost = QuestionPostFixture.questionPost(member1); + Notification notification = NotificationFixture.notification( + ANSWER, + questionPost.getId(), + member2.getId(), + member1 + ); + notification.updateIsReadTrue(); + readNotificationRequest request = new readNotificationRequest(1L); + + given(notificationRepository.findById(anyLong())).willReturn(Optional.ofNullable(notification)); + + // when // then + assertThrows(ValidationException.class, () -> notificationService.readNotification(request, member2)); + } + + @DisplayName("읽었던 알림의 읽음 상태 변화를 하면 예외가 발생한다.") + @Test + void isReadNotificationThrowsExceptionWhenAlreadyRead() { + // given + Member member1 = MemberFixture.member(); + Member member2 = MemberFixture.member2(); + + QuestionPost questionPost = QuestionPostFixture.questionPost(member1); + Notification notification = NotificationFixture.notification( + ANSWER, + questionPost.getId(), + member2.getId(), + member1 + ); + notification.updateIsReadTrue(); + readNotificationRequest request = new readNotificationRequest(1L); + + given(notificationRepository.findById(anyLong())).willReturn(Optional.ofNullable(notification)); + + // when // then + assertThrows(ValidationException.class, () -> notificationService.readNotification(request, member1)); + } +}