diff --git a/be/src/docs/asciidoc/index.adoc b/be/src/docs/asciidoc/index.adoc index 6a6f53d5d..068acf010 100644 --- a/be/src/docs/asciidoc/index.adoc +++ b/be/src/docs/asciidoc/index.adoc @@ -49,45 +49,45 @@ operation::login_failedByWrongPassword[snippets='http-response'] ==== 성공 -operation::registerComment_success[snippets='http-request,http-response'] +operation::comment_register_success[snippets='http-request,http-response'] ==== 바디가 없을 때 -operation::registerComment_failed_by_request_body_not_exists[snippets='http-response'] +operation::comment_register_failed_by_request_body_not_exists[snippets='http-response'] ==== 댓글이 없을 때 -operation::registerComment_failed_by_content_not_exists[snippets='http-response'] +operation::comment_register_failed_by_content_not_exists[snippets='http-response'] ==== 댓글이 비여 있으면 -operation::registerComment_failed_by_content_is_empty[snippets='http-response'] +operation::comment_register_failed_by_content_is_empty[snippets='http-response'] ==== 댓글이 공백 일때 -operation::registerComment_failed_by_content_is_blank[snippets='http-response'] +operation::comment_register_failed_by_content_is_blank[snippets='http-response'] ==== 댓글이 200자를 넘을 때 -operation::registerComment_failed_by_content_is_larger_than_200[snippets='http-response'] +operation::comment_register_failed_by_content_is_larger_than_200[snippets='http-response'] ==== 피드가 존재하지 않을 때 -operation::registerComment_failed_by_feed_not_exists[snippets='http-response'] +operation::comment_register_failed_by_feed_not_exists[snippets='http-response'] ==== 피드 아이디가 없을 때 -operation::registerComment_failed_by_feed_id_not_exists[snippets='http-response'] +operation::comment_register_failed_by_feed_id_not_exists[snippets='http-response'] === 댓글 수정 ==== 성공 -operation::editComment_success[snippets='http-request,http-response'] +operation::comment_edit_success[snippets='http-request,http-response'] ==== 바디가 없을 때 -operation::editComment_failed_by_request_body_not_exists[snippets='http-response'] +operation::comment_edit_failed_by_request_body_not_exists[snippets='http-response'] ==== 댓글이 비여 있으면 @@ -95,47 +95,47 @@ operation::editComment_failed_by_content_is_empty[snippets='http-response'] ==== 댓글이 공백 일때 -operation::editComment_failed_by_content_is_blank[snippets='http-response'] +operation::comment_edit_failed_by_content_is_blank[snippets='http-response'] ==== 댓글이 200자를 넘을 때 -operation::editComment_failed_by_content_is_larger_than_200[snippets='http-response'] +operation::comment_edit_failed_by_content_is_larger_than_200[snippets='http-response'] ==== 댓글이 존재하지 않을 때 -operation::editComment_failed_by_comment_not_exists[snippets='http-response'] +operation::comment_edit_failed_by_comment_not_exists[snippets='http-response'] ==== 댓글이 이미 삭제되었을 때 -operation::editComment_failed_by_comment_is_deleted[snippets='http-response'] +operation::comment_edit_failed_by_comment_is_deleted[snippets='http-response'] === 댓글 삭제 ==== 성공 -operation::deleteComment_success[snippets='http-request,http-response'] +operation::comment_delete_success[snippets='http-request,http-response'] ==== 댓글이 존재하지 않을 때 -operation::deleteComment_failed_by_comment_not_exists[snippets='http-response'] +operation::comment_delete_failed_by_comment_not_exists[snippets='http-response'] ==== 댓글이 이미 삭제되었을 때 -operation::deleteComment_failed_by_comment_is_deleted[snippets='http-response'] +operation::comment_delete_failed_by_comment_is_deleted[snippets='http-response'] === 피드별 댓글 조회 ==== 성공 -operation::fetchComments_success[snippets='http-request,http-response'] +operation::comments_fetch_success[snippets='http-request,http-response'] ==== 성공 - 페이징 -operation::fetchComments_with_page_success[snippets='http-request,http-response'] +operation::comments_fetch_with_page_success[snippets='http-request,http-response'] ==== 피드가 존재하지 않을 때 -operation::fetchComments_failed_by_feed_id_not_exists[snippets='http-response'] +operation::comments_fetch_failed_by_feed_id_not_exists[snippets='http-response'] [[member]] == 회원 @@ -222,3 +222,48 @@ operation::findMoodyById_success[snippets='http-request,http-response'] + +[[notification]] +== 알람 + +=== 알람 전체 조회 + +==== 성공 - 페이징 + +operation::notification_request_all_success[snippets='http-request,http-response'] + +=== 개별 알람 조회 + +==== 성공 (예시: 읽음으로 변경 ) + +operation::notification_request_single_success[snippets='http-request,http-response'] + +=== 알람 개별 상태 변경 + +==== 성공 - 읽음 + +operation::notification_change_status_success[snippets='http-request,http-response'] + +=== 알람 일괄 상태 변경 + +==== 성공 - 읽음 + +operation::notification_change_all_status_success[snippets='http-request,http-response'] + +=== 알람 개별 삭제 + +==== 성공 + +operation::notification_delete_success[snippets='http-request,http-response'] + +=== 알람 전체 삭제 + +==== 성공 + +operation::notification_delete_all_success[snippets='http-request,http-response'] + +=== 알람 일괄적으로 삭제 + +==== 성공 + +operation::notification_delete_notification_list_success[snippets='http-request,http-response'] diff --git a/be/src/main/java/com/foodymoody/be/DocumentController.java b/be/src/main/java/com/foodymoody/be/DocumentController.java index 065cfe251..706865878 100644 --- a/be/src/main/java/com/foodymoody/be/DocumentController.java +++ b/be/src/main/java/com/foodymoody/be/DocumentController.java @@ -1,10 +1,8 @@ package com.foodymoody.be; -import java.util.Map; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; diff --git a/be/src/main/java/com/foodymoody/be/auth/util/JwtUtil.java b/be/src/main/java/com/foodymoody/be/auth/util/JwtUtil.java index 4895b7c83..d4a04f99a 100644 --- a/be/src/main/java/com/foodymoody/be/auth/util/JwtUtil.java +++ b/be/src/main/java/com/foodymoody/be/auth/util/JwtUtil.java @@ -17,20 +17,18 @@ public class JwtUtil { private long accessTokenExp; private long refreshTokenExp; - private String secret; private String issuer; private SecretKey secretKey; private ClaimUtil claimUtil; public JwtUtil( @Value("${jwt.token.exp.access}") long accessTokenExp, - @Value("${jwt.token.exp.refresh}")long refreshTokenExp, + @Value("${jwt.token.exp.refresh}") long refreshTokenExp, @Value("${jwt.token.secret}") String secret, @Value("${jwt.token.issuer}") String issuer, ClaimUtil claimUtil) { this.accessTokenExp = accessTokenExp; this.refreshTokenExp = refreshTokenExp; - this.secret = secret; this.issuer = issuer; this.secretKey = Keys.hmacShaKeyFor(secret.getBytes()); this.claimUtil = claimUtil; diff --git a/be/src/main/java/com/foodymoody/be/comment/domain/Comment.java b/be/src/main/java/com/foodymoody/be/comment/domain/Comment.java index 7b856f57d..15c420b2a 100644 --- a/be/src/main/java/com/foodymoody/be/comment/domain/Comment.java +++ b/be/src/main/java/com/foodymoody/be/comment/domain/Comment.java @@ -1,5 +1,8 @@ package com.foodymoody.be.comment.domain; +import static com.foodymoody.be.comment.domain.CommentDomainMapper.mapperToNotificationEvent; + +import com.foodymoody.be.common.event.NotificationEvents; import com.foodymoody.be.common.exception.CommentDeletedException; import java.time.LocalDateTime; import javax.persistence.EmbeddedId; @@ -30,6 +33,7 @@ public Comment(CommentId id, String content, String feedId, boolean deleted, this.memberId = memberId; this.createdAt = createdAt; this.updatedAt = createdAt; + NotificationEvents.publish(mapperToNotificationEvent(feedId)); } public CommentId getId() { diff --git a/be/src/main/java/com/foodymoody/be/comment/domain/CommentAddNotificationEvent.java b/be/src/main/java/com/foodymoody/be/comment/domain/CommentAddNotificationEvent.java new file mode 100644 index 000000000..35e7c6f39 --- /dev/null +++ b/be/src/main/java/com/foodymoody/be/comment/domain/CommentAddNotificationEvent.java @@ -0,0 +1,46 @@ +package com.foodymoody.be.comment.domain; + +import com.foodymoody.be.common.event.NotificationEvent; +import com.foodymoody.be.common.event.NotificationType; +import java.time.LocalDateTime; + +public class CommentAddNotificationEvent implements NotificationEvent { + + private String memberId; + private String message; + private NotificationType notificationType; + private LocalDateTime createdAt; + + private CommentAddNotificationEvent(String memberId, String message, NotificationType notificationType, + LocalDateTime createdAt) { + this.memberId = memberId; + this.message = message; + this.notificationType = notificationType; + this.createdAt = createdAt; + } + + public static CommentAddNotificationEvent of(String memberId, String message, NotificationType notificationType, + LocalDateTime createdAt) { + return new CommentAddNotificationEvent(memberId, message, notificationType, createdAt); + } + + @Override + public NotificationType getNotificationType() { + return notificationType; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public String getMemberId() { + return memberId; + } + + @Override + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/be/src/main/java/com/foodymoody/be/comment/domain/CommentDomainMapper.java b/be/src/main/java/com/foodymoody/be/comment/domain/CommentDomainMapper.java new file mode 100644 index 000000000..81bb39178 --- /dev/null +++ b/be/src/main/java/com/foodymoody/be/comment/domain/CommentDomainMapper.java @@ -0,0 +1,19 @@ +package com.foodymoody.be.comment.domain; + +import com.foodymoody.be.common.event.NotificationType; +import java.time.LocalDateTime; + +public class CommentDomainMapper { + + private CommentDomainMapper() { + throw new IllegalStateException("Utility class"); + } + + public static CommentAddNotificationEvent mapperToNotificationEvent(String feedId) { + return CommentAddNotificationEvent.of("1", + feedId + "에 댓글이 추가되였습니다.", + NotificationType.COMMENT_ADDED, + LocalDateTime.now()); + } + +} diff --git a/be/src/main/java/com/foodymoody/be/common/event/EventsConfiguration.java b/be/src/main/java/com/foodymoody/be/common/event/EventsConfiguration.java new file mode 100644 index 000000000..2c284ab6c --- /dev/null +++ b/be/src/main/java/com/foodymoody/be/common/event/EventsConfiguration.java @@ -0,0 +1,19 @@ +package com.foodymoody.be.common.event; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@RequiredArgsConstructor +@Configuration +public class EventsConfiguration { + + private final ApplicationContext applicationContext; + + @Bean + public InitializingBean notificationEventsInitializer() { + return () -> NotificationEvents.setPublisher(applicationContext); + } +} diff --git a/be/src/main/java/com/foodymoody/be/common/event/NotificationEvent.java b/be/src/main/java/com/foodymoody/be/common/event/NotificationEvent.java new file mode 100644 index 000000000..dfb766caf --- /dev/null +++ b/be/src/main/java/com/foodymoody/be/common/event/NotificationEvent.java @@ -0,0 +1,14 @@ +package com.foodymoody.be.common.event; + +import java.time.LocalDateTime; + +public interface NotificationEvent { + + NotificationType getNotificationType(); + + String getMessage(); + + String getMemberId(); + + LocalDateTime getCreatedAt(); +} diff --git a/be/src/main/java/com/foodymoody/be/common/event/NotificationEvents.java b/be/src/main/java/com/foodymoody/be/common/event/NotificationEvents.java new file mode 100644 index 000000000..afe4e988f --- /dev/null +++ b/be/src/main/java/com/foodymoody/be/common/event/NotificationEvents.java @@ -0,0 +1,21 @@ +package com.foodymoody.be.common.event; + +import org.springframework.context.ApplicationEventPublisher; + + +public class NotificationEvents { + + private static ApplicationEventPublisher publisher; + + private NotificationEvents() { + throw new IllegalStateException("Utility class"); + } + + public static void publish(NotificationEvent event) { + publisher.publishEvent(event); + } + + static void setPublisher(ApplicationEventPublisher publisher) { + NotificationEvents.publisher = publisher; + } +} diff --git a/be/src/main/java/com/foodymoody/be/common/event/NotificationType.java b/be/src/main/java/com/foodymoody/be/common/event/NotificationType.java new file mode 100644 index 000000000..e8ed9f528 --- /dev/null +++ b/be/src/main/java/com/foodymoody/be/common/event/NotificationType.java @@ -0,0 +1,5 @@ +package com.foodymoody.be.common.event; + +public enum NotificationType { + COMMENT_ADDED, +} diff --git a/be/src/main/java/com/foodymoody/be/common/filter/AccessTokenFilter.java b/be/src/main/java/com/foodymoody/be/common/filter/AccessTokenFilter.java index bf368192e..a92b04c5c 100644 --- a/be/src/main/java/com/foodymoody/be/common/filter/AccessTokenFilter.java +++ b/be/src/main/java/com/foodymoody/be/common/filter/AccessTokenFilter.java @@ -1,11 +1,11 @@ package com.foodymoody.be.common.filter; import com.fasterxml.jackson.databind.ObjectMapper; +import com.foodymoody.be.auth.util.JwtUtil; import com.foodymoody.be.common.exception.ErrorMessage; import com.foodymoody.be.common.exception.ErrorResponse; -import com.foodymoody.be.common.util.HttpHeaderType; import com.foodymoody.be.common.util.HttpHeaderParser; -import com.foodymoody.be.auth.util.JwtUtil; +import com.foodymoody.be.common.util.HttpHeaderType; import io.jsonwebtoken.JwtException; import java.io.IOException; import java.io.PrintWriter; @@ -46,7 +46,7 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha try { String customUri = extractCustomUri(httpRequest); if (!isInWhiteList(customUri)) { - String header = httpRequest.getHeader(HttpHeaderType.AUTHORIZATION.NAME); + String header = httpRequest.getHeader(HttpHeaderType.AUTHORIZATION.headerName); String token = HttpHeaderParser.parse(header, HttpHeaderType.AUTHORIZATION); Map parsed = jwtUtil.parseAccessToken(token); request.setAttribute("id", parsed.get("id")); @@ -86,4 +86,4 @@ private boolean isInWhiteList(String uri) { return whiteList.stream().anyMatch(whitelist -> Pattern.matches(whitelist, uri)); } -} \ No newline at end of file +} diff --git a/be/src/main/java/com/foodymoody/be/common/util/HttpHeaderParser.java b/be/src/main/java/com/foodymoody/be/common/util/HttpHeaderParser.java index 2cf664f7d..949e6d508 100644 --- a/be/src/main/java/com/foodymoody/be/common/util/HttpHeaderParser.java +++ b/be/src/main/java/com/foodymoody/be/common/util/HttpHeaderParser.java @@ -12,7 +12,7 @@ private HttpHeaderParser() { } public static String parse(String header, HttpHeaderType type) { - return parse(header, type.SKIM); + return parse(header, type.skim); } private static String parse(String header, String skim) { diff --git a/be/src/main/java/com/foodymoody/be/common/util/HttpHeaderType.java b/be/src/main/java/com/foodymoody/be/common/util/HttpHeaderType.java index bf9c9cf3c..f51a99fdb 100644 --- a/be/src/main/java/com/foodymoody/be/common/util/HttpHeaderType.java +++ b/be/src/main/java/com/foodymoody/be/common/util/HttpHeaderType.java @@ -4,12 +4,12 @@ public enum HttpHeaderType { AUTHORIZATION("Authorization", "Bearer"); - public final String NAME; - public final String SKIM; + public final String headerName; + public final String skim; HttpHeaderType(String headerName, String skim) { - this.NAME = headerName; - this.SKIM = skim; + this.headerName = headerName; + this.skim = skim; } } diff --git a/be/src/main/java/com/foodymoody/be/feed/domain/ImageMenus.java b/be/src/main/java/com/foodymoody/be/feed/domain/ImageMenus.java index fb353d7eb..d9e3b8554 100644 --- a/be/src/main/java/com/foodymoody/be/feed/domain/ImageMenus.java +++ b/be/src/main/java/com/foodymoody/be/feed/domain/ImageMenus.java @@ -20,22 +20,22 @@ public class ImageMenus { @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) - private List imageMenus = new ArrayList<>(); + private List imageMenusList = new ArrayList<>(); public ImageMenus(List newImages, List newMenus) { - this.imageMenus.addAll(ImageMenuMapper.toImageMenu(newImages, newMenus)); + this.imageMenusList.addAll(ImageMenuMapper.toImageMenu(newImages, newMenus)); } public void replaceWith(List newImages, List newMenus) { - imageMenus.clear(); - imageMenus.addAll(ImageMenuMapper.toImageMenu(newImages, newMenus)); + imageMenusList.clear(); + imageMenusList.addAll(ImageMenuMapper.toImageMenu(newImages, newMenus)); } /** * 밖에서 참조하지 못하도록 새로운 변경 불가능한 리스트로 만든 후 리턴 */ public List getNewUnmodifiedImageMenus() { - return Collections.unmodifiableList(imageMenus); + return Collections.unmodifiableList(imageMenusList); } } diff --git a/be/src/main/java/com/foodymoody/be/feed/domain/Menus.java b/be/src/main/java/com/foodymoody/be/feed/domain/Menus.java index 486694c55..7caead2eb 100644 --- a/be/src/main/java/com/foodymoody/be/feed/domain/Menus.java +++ b/be/src/main/java/com/foodymoody/be/feed/domain/Menus.java @@ -18,22 +18,22 @@ public class Menus { @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) - private List menus = new ArrayList<>(); + private List menusList = new ArrayList<>(); - public Menus(List menus) { - this.menus.addAll(menus); + public Menus(List menusList) { + this.menusList.addAll(menusList); } public void replaceWith(List newMenus) { - menus.clear(); - menus.addAll(newMenus); + menusList.clear(); + menusList.addAll(newMenus); } /** * 밖에서 참조하지 못하도록 새로운 변경 불가능한 리스트로 만든 후 리턴 */ public List getNewUnmodifiedMenus() { - return Collections.unmodifiableList(menus); + return Collections.unmodifiableList(menusList); } } diff --git a/be/src/main/java/com/foodymoody/be/feed/service/FeedService.java b/be/src/main/java/com/foodymoody/be/feed/service/FeedService.java index 4dd4bc0cd..c75f8b0a9 100644 --- a/be/src/main/java/com/foodymoody/be/feed/service/FeedService.java +++ b/be/src/main/java/com/foodymoody/be/feed/service/FeedService.java @@ -104,14 +104,6 @@ public FeedRegisterResponse register(FeedServiceRegisterRequest request) { return FeedMapper.toFeedRegisterResponse(feedRepository.save(feed)); } - // TODO: JPA에서 제공하는 메서드인지 찾아보기 (속도 개선) - private List findMoodIds(List storeMoodNames) { - return storeMoodNames.stream() - .map(moodService::findMoodByName) - .map(Mood::getId) - .collect(Collectors.toUnmodifiableList()); - } - // TODO: JPA에서 제공하는 메서드인지 찾아보기 (속도 개선) public List findMoodNames(List moodIds) { return moodIds.stream() diff --git a/be/src/main/java/com/foodymoody/be/feed/util/FeedMapper.java b/be/src/main/java/com/foodymoody/be/feed/util/FeedMapper.java index 92818f1dd..3902cbbc9 100644 --- a/be/src/main/java/com/foodymoody/be/feed/util/FeedMapper.java +++ b/be/src/main/java/com/foodymoody/be/feed/util/FeedMapper.java @@ -12,7 +12,6 @@ import com.foodymoody.be.feed.dto.response.FeedReadResponse; import com.foodymoody.be.feed.dto.response.FeedRegisterResponse; import com.foodymoody.be.feed.dto.response.FeedStoreMoodResponse; -import com.foodymoody.be.feed.dto.response.FeedTasteMoodResponse; import com.foodymoody.be.image.domain.Image; import com.foodymoody.be.menu.domain.Menu; import java.util.List; @@ -20,8 +19,13 @@ public class FeedMapper { - public static Feed toFeed(String id, String memberId, FeedServiceRegisterRequest request, List moodIds, List images, - List menus) { + private FeedMapper() { + throw new IllegalStateException("Utility class"); + } + + public static Feed toFeed(String id, String memberId, FeedServiceRegisterRequest request, List moodIds, + List images, + List menus) { return new Feed(id, memberId, request.getLocation(), request.getReview(), moodIds, images, menus); } @@ -29,8 +33,9 @@ public static FeedRegisterResponse toFeedRegisterResponse(Feed savedFeed) { return new FeedRegisterResponse(savedFeed.getId()); } - public static FeedReadResponse toFeedReadResponse(FeedMemberResponse feedMemberResponse, Feed feed, List images, - List moodNames) { + public static FeedReadResponse toFeedReadResponse(FeedMemberResponse feedMemberResponse, Feed feed, + List images, + List moodNames) { return FeedReadResponse.builder() .id(feed.getId()) .member(feedMemberResponse) diff --git a/be/src/main/java/com/foodymoody/be/member/controller/dto/MemberSignupRequest.java b/be/src/main/java/com/foodymoody/be/member/controller/dto/MemberSignupRequest.java index dce95f9a7..aa6fc94a8 100644 --- a/be/src/main/java/com/foodymoody/be/member/controller/dto/MemberSignupRequest.java +++ b/be/src/main/java/com/foodymoody/be/member/controller/dto/MemberSignupRequest.java @@ -17,7 +17,4 @@ public class MemberSignupRequest { private String password; private String reconfirmPassword; private String mood; - - public MemberSignupRequest() { - } -} \ No newline at end of file +} diff --git a/be/src/main/java/com/foodymoody/be/mood/controller/dto/MoodRegisterRequest.java b/be/src/main/java/com/foodymoody/be/mood/controller/dto/MoodRegisterRequest.java index 7fa7a6c5e..ee3f20b2c 100644 --- a/be/src/main/java/com/foodymoody/be/mood/controller/dto/MoodRegisterRequest.java +++ b/be/src/main/java/com/foodymoody/be/mood/controller/dto/MoodRegisterRequest.java @@ -4,11 +4,11 @@ public class MoodRegisterRequest { private String mood; - public MoodRegisterRequest() { - } - public String getMood() { return this.mood; } + public void setMood(String mood) { + this.mood = mood; + } } diff --git a/be/src/main/java/com/foodymoody/be/notification/controller/NotificationController.java b/be/src/main/java/com/foodymoody/be/notification/controller/NotificationController.java new file mode 100644 index 000000000..e1c7fa4f2 --- /dev/null +++ b/be/src/main/java/com/foodymoody/be/notification/controller/NotificationController.java @@ -0,0 +1,71 @@ +package com.foodymoody.be.notification.controller; + +import com.foodymoody.be.common.annotation.MemberId; +import com.foodymoody.be.notification.controller.dto.ChangeAllNotificationStatusRequest; +import com.foodymoody.be.notification.controller.dto.DeleteNotificationsRequest; +import com.foodymoody.be.notification.controller.dto.NotificationResponse; +import com.foodymoody.be.notification.controller.dto.NotificationStatus; +import com.foodymoody.be.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +public class NotificationController { + + private final NotificationService notificationService; + + @GetMapping("/api/notifications") + public ResponseEntity> requestAll(@MemberId String memberId, + @PageableDefault Pageable pageable) { + Slice notifications = notificationService.requestAll(memberId, pageable); + return ResponseEntity.ok(notifications); + } + + @GetMapping("/api/notifications/{notificationId}") + public ResponseEntity requestOne(@MemberId String memberId, + @PathVariable String notificationId) { + NotificationResponse notification = notificationService.requestOne(memberId, notificationId); + return ResponseEntity.ok(notification); + } + + @PutMapping("/api/notifications/{notificationId}") + public ResponseEntity changeStatus(@MemberId String memberId, @PathVariable String notificationId, + @RequestBody NotificationStatus status) { + notificationService.changeStatus(memberId, notificationId, status.isRead()); + return ResponseEntity.noContent().build(); + } + + @PutMapping("/api/notifications") + public ResponseEntity changeAllStatus(@MemberId String memberId, + @RequestBody ChangeAllNotificationStatusRequest status) { + notificationService.changeAllStatus(memberId, status.getNotificationIds(), status.isRead()); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/api/notifications/{notificationId}") + public ResponseEntity delete(@MemberId String memberId, @PathVariable String notificationId) { + notificationService.delete(memberId, notificationId); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/api/notifications") + public ResponseEntity deleteAll(@MemberId String memberId, @RequestBody(required = false) + DeleteNotificationsRequest request) { + if (request != null) { + notificationService.deleteAll(memberId, request.getNotificationIds()); + return ResponseEntity.noContent().build(); + } + notificationService.deleteAll(memberId); + return ResponseEntity.noContent().build(); + } +} diff --git a/be/src/main/java/com/foodymoody/be/notification/controller/dto/ChangeAllNotificationStatusRequest.java b/be/src/main/java/com/foodymoody/be/notification/controller/dto/ChangeAllNotificationStatusRequest.java new file mode 100644 index 000000000..28a40b387 --- /dev/null +++ b/be/src/main/java/com/foodymoody/be/notification/controller/dto/ChangeAllNotificationStatusRequest.java @@ -0,0 +1,25 @@ +package com.foodymoody.be.notification.controller.dto; + +import java.util.List; + +public class ChangeAllNotificationStatusRequest { + + private List notificationIds; + private boolean isRead; + + public List getNotificationIds() { + return notificationIds; + } + + public void setNotificationIds(List notificationIds) { + this.notificationIds = notificationIds; + } + + public boolean isRead() { + return isRead; + } + + public void setRead(boolean read) { + isRead = read; + } +} diff --git a/be/src/main/java/com/foodymoody/be/notification/controller/dto/DeleteNotificationsRequest.java b/be/src/main/java/com/foodymoody/be/notification/controller/dto/DeleteNotificationsRequest.java new file mode 100644 index 000000000..bf93ce027 --- /dev/null +++ b/be/src/main/java/com/foodymoody/be/notification/controller/dto/DeleteNotificationsRequest.java @@ -0,0 +1,16 @@ +package com.foodymoody.be.notification.controller.dto; + +import java.util.List; + +public class DeleteNotificationsRequest { + + private List notificationIds; + + public List getNotificationIds() { + return notificationIds; + } + + public void setNotificationIds(List notificationIds) { + this.notificationIds = notificationIds; + } +} diff --git a/be/src/main/java/com/foodymoody/be/notification/controller/dto/NotificationResponse.java b/be/src/main/java/com/foodymoody/be/notification/controller/dto/NotificationResponse.java new file mode 100644 index 000000000..7c5a4cc52 --- /dev/null +++ b/be/src/main/java/com/foodymoody/be/notification/controller/dto/NotificationResponse.java @@ -0,0 +1,34 @@ +package com.foodymoody.be.notification.controller.dto; + +import com.foodymoody.be.common.event.NotificationType; + +public class NotificationResponse { + + private String notificationId; + private String message; + private NotificationType type; + private boolean isRead; + + public NotificationResponse(String notificationId, String message, NotificationType type, boolean isRead) { + this.notificationId = notificationId; + this.message = message; + this.type = type; + this.isRead = isRead; + } + + public String getId() { + return notificationId; + } + + public String getMessage() { + return message; + } + + public NotificationType getType() { + return type; + } + + public boolean isRead() { + return isRead; + } +} diff --git a/be/src/main/java/com/foodymoody/be/notification/controller/dto/NotificationStatus.java b/be/src/main/java/com/foodymoody/be/notification/controller/dto/NotificationStatus.java new file mode 100644 index 000000000..6cc71e2aa --- /dev/null +++ b/be/src/main/java/com/foodymoody/be/notification/controller/dto/NotificationStatus.java @@ -0,0 +1,21 @@ +package com.foodymoody.be.notification.controller.dto; + +public class NotificationStatus { + + private boolean isRead; + + public NotificationStatus() { + } + + public NotificationStatus(boolean isRead) { + this.isRead = isRead; + } + + public boolean isRead() { + return isRead; + } + + public void setRead(boolean read) { + isRead = read; + } +} diff --git a/be/src/main/java/com/foodymoody/be/notification/domain/Notification.java b/be/src/main/java/com/foodymoody/be/notification/domain/Notification.java new file mode 100644 index 000000000..c48ebd1f4 --- /dev/null +++ b/be/src/main/java/com/foodymoody/be/notification/domain/Notification.java @@ -0,0 +1,89 @@ +package com.foodymoody.be.notification.domain; + +import com.foodymoody.be.common.event.NotificationType; +import java.time.LocalDateTime; +import javax.persistence.EmbeddedId; +import javax.persistence.Entity; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Notification { + + @EmbeddedId + private NotificationId id; + private String memberId; + private String message; + private NotificationType type; + private boolean isRead; + private boolean isDeleted; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public Notification(NotificationId id, String memberId, String message, NotificationType type, boolean isRead, + boolean isDeleted, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.memberId = memberId; + this.message = message; + this.type = type; + this.isRead = isRead; + this.isDeleted = isDeleted; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public boolean isSameMember(String memberId) { + return this.memberId.equals(memberId); + } + + public NotificationId getId() { + return id; + } + + public String getMessage() { + return message; + } + + public NotificationType getType() { + return type; + } + + public boolean isRead() { + return isRead; + } + + public String getMemberId() { + return memberId; + } + + public boolean isDeleted() { + return isDeleted; + } + + public void changeStatus(boolean isRead, String memberId, LocalDateTime updatedAt) { + checkMemberId(memberId); + this.updatedAt = updatedAt; + this.isRead = isRead; + } + + public void delete(String memberId, LocalDateTime updatedAt) { + checkMemberId(memberId); + this.isDeleted = true; + this.updatedAt = updatedAt; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + private void checkMemberId(String memberId) { + if (!isSameMember(memberId)) { + throw new IllegalArgumentException("해당 알림을 수정할 수 없습니다."); + } + } +} diff --git a/be/src/main/java/com/foodymoody/be/notification/domain/NotificationId.java b/be/src/main/java/com/foodymoody/be/notification/domain/NotificationId.java new file mode 100644 index 000000000..df33657cc --- /dev/null +++ b/be/src/main/java/com/foodymoody/be/notification/domain/NotificationId.java @@ -0,0 +1,32 @@ +package com.foodymoody.be.notification.domain; + +import com.foodymoody.be.common.util.IdGenerator; +import java.io.Serializable; +import javax.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +@Embeddable +public class NotificationId implements Serializable { + + private static final long serialVersionUID = -5351955236373496259L; + + private String value; + + private NotificationId(String value) { + this.value = value; + } + + public static NotificationId from(String id) { + return new NotificationId(id); + } + + public static NotificationId newId() { + return new NotificationId(IdGenerator.generate()); + } + + public String getValue() { + return value; + } +} diff --git a/be/src/main/java/com/foodymoody/be/notification/repository/NotificationRepository.java b/be/src/main/java/com/foodymoody/be/notification/repository/NotificationRepository.java new file mode 100644 index 000000000..3bf10ef71 --- /dev/null +++ b/be/src/main/java/com/foodymoody/be/notification/repository/NotificationRepository.java @@ -0,0 +1,29 @@ +package com.foodymoody.be.notification.repository; + +import com.foodymoody.be.notification.domain.Notification; +import com.foodymoody.be.notification.domain.NotificationId; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +public interface NotificationRepository extends JpaRepository { + + Slice findAllByMemberId(String memberId, Pageable pageable); + + @Modifying + @Query("UPDATE Notification _notification SET _notification.isRead = :status, _notification.updatedAt = :updatedAt WHERE _notification.id IN :notificationIds AND _notification.memberId = :memberId") + void updateAllStatus(boolean status, String memberId, LocalDateTime updatedAt, + List notificationIds); + + @Modifying + @Query("UPDATE Notification _notification SET _notification.isDeleted = true , _notification.updatedAt = :updatedAt WHERE _notification.memberId = :memberId") + void deleteAllByMemberId(String memberId, LocalDateTime updatedAt); + + @Modifying + @Query("UPDATE Notification _notification SET _notification.isDeleted = true , _notification.updatedAt = :updatedAt WHERE _notification.id IN :notificationIds AND _notification.memberId = :memberId") + void deleteAllByIdIn(List notificationIds, LocalDateTime updatedAt, String memberId); +} diff --git a/be/src/main/java/com/foodymoody/be/notification/service/NotificationMapper.java b/be/src/main/java/com/foodymoody/be/notification/service/NotificationMapper.java new file mode 100644 index 000000000..66bf29ce2 --- /dev/null +++ b/be/src/main/java/com/foodymoody/be/notification/service/NotificationMapper.java @@ -0,0 +1,34 @@ +package com.foodymoody.be.notification.service; + +import com.foodymoody.be.common.event.NotificationEvent; +import com.foodymoody.be.notification.controller.dto.NotificationResponse; +import com.foodymoody.be.notification.domain.Notification; +import com.foodymoody.be.notification.domain.NotificationId; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Component; + +@Component +public class NotificationMapper { + + public static List toNotificationID(List notificationIds) { + return notificationIds.stream() + .map(NotificationId::from) + .collect(Collectors.toList()); + } + + public Notification createNotificationEntityFromEvent(NotificationId notificationId, NotificationEvent event) { + return new Notification(notificationId, event.getMemberId(), event.getMessage(), + event.getNotificationType(), false, false, event.getCreatedAt(), event.getCreatedAt()); + } + + public Slice generateResponseDtoSliceFromNotifications(Slice notifications) { + return notifications.map(this::generateResponseDtoFromNotification); + } + + public NotificationResponse generateResponseDtoFromNotification(Notification notification) { + return new NotificationResponse(notification.getId().getValue(), notification.getMessage(), + notification.getType(), notification.isRead()); + } +} diff --git a/be/src/main/java/com/foodymoody/be/notification/service/NotificationService.java b/be/src/main/java/com/foodymoody/be/notification/service/NotificationService.java new file mode 100644 index 000000000..7f5504423 --- /dev/null +++ b/be/src/main/java/com/foodymoody/be/notification/service/NotificationService.java @@ -0,0 +1,89 @@ +package com.foodymoody.be.notification.service; + +import com.foodymoody.be.comment.domain.CommentAddNotificationEvent; +import com.foodymoody.be.member.service.MemberService; +import com.foodymoody.be.notification.controller.dto.NotificationResponse; +import com.foodymoody.be.notification.domain.Notification; +import com.foodymoody.be.notification.domain.NotificationId; +import com.foodymoody.be.notification.repository.NotificationRepository; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final MemberService memberService; + private final NotificationMapper notificationMapper; + + @EventListener(CommentAddNotificationEvent.class) + @Transactional + public void saveNotification(CommentAddNotificationEvent event) { + NotificationId notificationId = NotificationId.newId(); + Notification notification = notificationMapper.createNotificationEntityFromEvent(notificationId, event); + notificationRepository.save(notification); + } + + @Transactional + public void changeStatus(String memberId, String notificationId, boolean isRead) { + Notification notification = getNotification(notificationId); + var updatedAt = LocalDateTime.now(); + notification.changeStatus(isRead, memberId, updatedAt); + } + + @Transactional(readOnly = true) + public Slice requestAll(String memberId, Pageable pageable) { + memberService.findById(memberId); + Slice notifications = notificationRepository.findAllByMemberId(memberId, pageable); + return notificationMapper.generateResponseDtoSliceFromNotifications(notifications); + } + + @Transactional + public void delete(String memberId, String notificationId) { + Notification notification = getNotification(notificationId); + LocalDateTime updatedAt = LocalDateTime.now(); + notification.delete(memberId, updatedAt); + } + + @Transactional + public void deleteAll(String memberId) { + memberService.findById(memberId); + notificationRepository.deleteAllByMemberId(memberId, LocalDateTime.now()); + } + + @Transactional + public NotificationResponse requestOne(String memberId, String notificationId) { + memberService.findById(memberId); + Notification notification = getNotification(notificationId); + notification.changeStatus(true, memberId, LocalDateTime.now()); + return notificationMapper.generateResponseDtoFromNotification(notification); + } + + @Transactional + public void changeAllStatus(String memberId, List notificationIds, boolean read) { + memberService.findById(memberId); + notificationRepository.updateAllStatus(read, memberId, LocalDateTime.now(), + NotificationMapper.toNotificationID(notificationIds)); + } + + public Notification getNotification(String notificationId) { + return notificationRepository.findById(NotificationId.from(notificationId)).orElseThrow(); + } + + @Transactional + public void deleteAll(String memberId, List notificationIds) { + memberService.findById(memberId); + notificationRepository.deleteAllByIdIn( + NotificationMapper.toNotificationID(notificationIds), + LocalDateTime.now(), + memberId + ); + } +} diff --git a/be/src/main/java/com/foodymoody/be/sse/controller/SseController.java b/be/src/main/java/com/foodymoody/be/sse/controller/SseController.java new file mode 100644 index 000000000..c78e5e6f4 --- /dev/null +++ b/be/src/main/java/com/foodymoody/be/sse/controller/SseController.java @@ -0,0 +1,27 @@ +package com.foodymoody.be.sse.controller; + +import com.foodymoody.be.sse.service.SseService; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@RequiredArgsConstructor +@Controller +public class SseController { + + private final SseService sseService; + + @GetMapping(value = "/api/sse/{memberId}", produces = "text/event-stream;charset=UTF-8") + public SseEmitter streamSeeMvc(@PathVariable String memberId) throws IOException { + SseEmitter emitter = new SseEmitter(); + emitter.send(SseEmitter.event() + .name("connect") + .data("connected!")); + sseService.add(memberId, emitter); + sseService.sendSseEvents(memberId); + return emitter; + } +} diff --git a/be/src/main/java/com/foodymoody/be/sse/service/SseAsyncService.java b/be/src/main/java/com/foodymoody/be/sse/service/SseAsyncService.java new file mode 100644 index 000000000..f93cb8060 --- /dev/null +++ b/be/src/main/java/com/foodymoody/be/sse/service/SseAsyncService.java @@ -0,0 +1,37 @@ +package com.foodymoody.be.sse.service; + +import java.io.IOException; +import java.util.Map; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Component +public class SseAsyncService { + + @Async + public void sendSseEvents(String memberId, Map emitters) { + SseEmitter emitter = emitters.get(memberId); + while (true) { + emitter.onCompletion(() -> emitters.remove(memberId)); + emitter.onTimeout(() -> emitters.remove(memberId)); + emitter.onError(e -> emitters.remove(memberId)); + if (!emitters.containsValue(emitter)) { + break; + } + try { + emitter.send(SseEmitter.event().name("notification") + .id(memberId) + .data("1")); + } catch (IOException e) { + emitter.completeWithError(e); + } + try { + Thread.sleep(1000); // 1초 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + +} diff --git a/be/src/main/java/com/foodymoody/be/sse/service/SseService.java b/be/src/main/java/com/foodymoody/be/sse/service/SseService.java new file mode 100644 index 000000000..472f3cb51 --- /dev/null +++ b/be/src/main/java/com/foodymoody/be/sse/service/SseService.java @@ -0,0 +1,35 @@ +package com.foodymoody.be.sse.service; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import javax.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Service +@RequiredArgsConstructor +public class SseService { + + private final Map emitters = new ConcurrentHashMap<>(); + private final ExecutorService executorService = Executors.newCachedThreadPool(); + private final SseAsyncService sseAsyncService; + + public void sendSseEvents(String memberId) { + executorService.submit(() -> sseAsyncService.sendSseEvents(memberId, emitters)); + } + + public void add(String memberId, SseEmitter emitter) { + emitters.put(memberId, emitter); + } + + @PreDestroy + public void shutDown() { + for (SseEmitter emitter : emitters.values()) { + emitter.complete(); + } + executorService.shutdownNow(); + } +} diff --git a/be/src/main/resources/application-auth.yml b/be/src/main/resources/application-auth.yml index 2603e7649..8bea8e1b7 100644 --- a/be/src/main/resources/application-auth.yml +++ b/be/src/main/resources/application-auth.yml @@ -11,4 +11,5 @@ filter: POST /api/members, GET /api/members/.*, GET /api/feeds, GET /api/feeds/.*, GET /api/comments, - GET /api/moods/random, GET /api/moods, GET /api/moods/.*, POST /api/moods + GET /api/moods/random, GET /api/moods, GET /api/moods/.*, POST /api/moods, + GET /api/notifications, GET /api/notifications/.* diff --git a/be/src/test/java/com/foodymoody/be/acceptance/AcceptanceTest.java b/be/src/test/java/com/foodymoody/be/acceptance/AcceptanceTest.java index f633a6c5b..bf07b03f3 100644 --- a/be/src/test/java/com/foodymoody/be/acceptance/AcceptanceTest.java +++ b/be/src/test/java/com/foodymoody/be/acceptance/AcceptanceTest.java @@ -4,8 +4,8 @@ import static org.springframework.restdocs.restassured3.RestAssuredRestDocumentation.document; import com.foodymoody.be.acceptance.util.DatabaseCleanup; -import com.foodymoody.be.acceptance.util.TableCleanup; import com.foodymoody.be.acceptance.util.SqlFileExecutor; +import com.foodymoody.be.acceptance.util.TableCleanup; import io.restassured.builder.RequestSpecBuilder; import io.restassured.specification.RequestSpecification; import java.util.List; @@ -25,27 +25,25 @@ @ExtendWith(RestDocumentationExtension.class) public abstract class AcceptanceTest { - @Autowired - protected DatabaseCleanup databaseCleanup; - @Autowired - protected TableCleanup tableCleanup; - @Autowired - protected SqlFileExecutor sqlFileExecutor; - public static final DockerImageName MYSQL_IMAGE = DockerImageName.parse("mysql:8.0"); - public static String 회원아티_액세스토큰; - public static String 회원푸반_액세스토큰; - public static final MySQLContainer MYSQL = new MySQLContainer<>(MYSQL_IMAGE) .withDatabaseName("foodymoody") .withUsername("bono") .withPassword("1111") .withReuse(true); + public static String 회원아티_액세스토큰; + public static String 회원푸반_액세스토큰; static { MYSQL.setPortBindings(List.of("3306:3306")); } + @Autowired + protected DatabaseCleanup databaseCleanup; + @Autowired + protected TableCleanup tableCleanup; + @Autowired + protected SqlFileExecutor sqlFileExecutor; protected RequestSpecification spec; public static void api_문서_타이틀(String documentName, RequestSpecification specification) { @@ -56,8 +54,24 @@ public abstract class AcceptanceTest { ); } + protected void 데이터베이스를_초기화한다() { + databaseCleanup.execute(); + sqlFileExecutor.execute("data.sql"); + } + + protected void sql파일을_실행한다(String sqlFilePath) { + sqlFileExecutor.execute(sqlFilePath); + } + + protected void 테이블을_비운다(String tableName) { + tableCleanup.setTableName(tableName); + tableCleanup.execute(); + } + @BeforeEach void setSpec(RestDocumentationContextProvider provider) { + databaseCleanup.execute(); + 데이터베이스를_초기화한다(); this.spec = new RequestSpecBuilder() .addFilter(RestAssuredRestDocumentation.documentationConfiguration(provider)) .build(); @@ -71,24 +85,4 @@ void setSpec(RestDocumentationContextProvider provider) { static void startContainer() { MYSQL.start(); } - - protected void 데이터베이스를_비운다(List excludeTableNames) { - databaseCleanup.setExcludeTables(excludeTableNames); - databaseCleanup.execute(); - } - - protected void 데이터베이스를_초기화한다() { - databaseCleanup.setExcludeTables(List.of()); - databaseCleanup.execute(); - sqlFileExecutor.execute("data.sql"); - } - - protected void sql파일을_실행한다(String sqlFilePath) { - sqlFileExecutor.execute(sqlFilePath); - } - - protected void 테이블을_비운다(String tableName) { - tableCleanup.setTableName(tableName); - tableCleanup.execute(); - } } diff --git a/be/src/test/java/com/foodymoody/be/acceptance/member/MemberAcceptanceTest.java b/be/src/test/java/com/foodymoody/be/acceptance/member/MemberAcceptanceTest.java index 928e71755..568aaac7f 100644 --- a/be/src/test/java/com/foodymoody/be/acceptance/member/MemberAcceptanceTest.java +++ b/be/src/test/java/com/foodymoody/be/acceptance/member/MemberAcceptanceTest.java @@ -3,10 +3,10 @@ import static com.foodymoody.be.acceptance.member.MemberSteps.id가_test인_회원프로필을_조회한다; import static com.foodymoody.be.acceptance.member.MemberSteps.비회원보노가_유효하지_않은_이메일을_입력하고_닉네임을_입력하지_않고_패스워드를_입력하지_않고_회원가입한다; import static com.foodymoody.be.acceptance.member.MemberSteps.비회원보노가_틀린_재입력_패스워드로_회원가입한다; +import static com.foodymoody.be.acceptance.member.MemberSteps.비회원보노가_회원가입한다; import static com.foodymoody.be.acceptance.member.MemberSteps.비회원보노가_회원푸반의_닉네임으로_회원가입한다; import static com.foodymoody.be.acceptance.member.MemberSteps.비회원보노가_회원푸반의_이메일로_회원가입한다; import static com.foodymoody.be.acceptance.member.MemberSteps.상태코드가_200이고_응답에_id가_존재하며_회원가입한_보노의_회원프로필이_조회되는지_검증한다; -import static com.foodymoody.be.acceptance.member.MemberSteps.비회원보노가_회원가입한다; import static com.foodymoody.be.acceptance.member.MemberSteps.상태코드가_200이고_회원푸반의_회원프로필을_응답하는지_검증한다; import static com.foodymoody.be.acceptance.member.MemberSteps.상태코드가_400이고_오류코드가_g001이고_errors에_email과_nickname과_password가_존재하는지_검증한다; import static com.foodymoody.be.acceptance.member.MemberSteps.상태코드가_400이고_오류코드가_m002인지_검증한다; @@ -45,8 +45,6 @@ void when_signupMember_then_response200AndId_and_canFetchMemberProfile() { // then 상태코드가_200이고_응답에_id가_존재하며_회원가입한_보노의_회원프로필이_조회되는지_검증한다(response); - - 데이터베이스를_초기화한다(); } @DisplayName("회원 가입 시 잘못된 입력값을 입력하면, 상태코드 400과 오류코드 g001를 응답한다") diff --git a/be/src/test/java/com/foodymoody/be/acceptance/member/MemberSteps.java b/be/src/test/java/com/foodymoody/be/acceptance/member/MemberSteps.java index 30915ad5c..dd9a5cbeb 100644 --- a/be/src/test/java/com/foodymoody/be/acceptance/member/MemberSteps.java +++ b/be/src/test/java/com/foodymoody/be/acceptance/member/MemberSteps.java @@ -198,7 +198,7 @@ public class MemberSteps { ); } - private static ExtractableResponse 회원가입한다(Map memberRegisterRequest, + public static ExtractableResponse 회원가입한다(Map memberRegisterRequest, RequestSpecification spec) { return RestAssured .given() diff --git a/be/src/test/java/com/foodymoody/be/acceptance/notification/NotificationAcceptanceTest.java b/be/src/test/java/com/foodymoody/be/acceptance/notification/NotificationAcceptanceTest.java new file mode 100644 index 000000000..1be8b825c --- /dev/null +++ b/be/src/test/java/com/foodymoody/be/acceptance/notification/NotificationAcceptanceTest.java @@ -0,0 +1,146 @@ +package com.foodymoody.be.acceptance.notification; + +import static com.foodymoody.be.acceptance.notification.NotificationSteps.알람_발행; +import static com.foodymoody.be.acceptance.notification.NotificationSteps.알람_아이디로_알람을_조회한다; +import static com.foodymoody.be.acceptance.notification.NotificationSteps.알람을_삭제한다; +import static com.foodymoody.be.acceptance.notification.NotificationSteps.알람을_일괄적으로_변경; +import static com.foodymoody.be.acceptance.notification.NotificationSteps.알람을_일괄적으로_삭졔한다; +import static com.foodymoody.be.acceptance.notification.NotificationSteps.알람을_읽음으로_변경; +import static com.foodymoody.be.acceptance.notification.NotificationSteps.유저의_모든_알람을_삭제한다; +import static com.foodymoody.be.acceptance.notification.NotificationSteps.응답코드가_200; +import static com.foodymoody.be.acceptance.notification.NotificationSteps.응답코드가_204; +import static com.foodymoody.be.acceptance.notification.NotificationSteps.회원의_모든_알람을_조회하고_첫번째_알람을_가져온다; +import static com.foodymoody.be.acceptance.notification.NotificationSteps.회원의_모든_알람을_조회한다; + +import com.foodymoody.be.acceptance.AcceptanceTest; +import com.foodymoody.be.auth.util.JwtUtil; +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.springframework.beans.factory.annotation.Autowired; + +@DisplayName("알림 관련 기능") +class NotificationAcceptanceTest extends AcceptanceTest { + + String 아티_아이디; + @Autowired + JwtUtil jwtUtil; + + @BeforeEach + void setUp() { + 아티_아이디 = jwtUtil.parseAccessToken(회원아티_액세스토큰).get("id"); + 알람_발행(아티_아이디); + 알람_발행(아티_아이디); + 알람_발행(아티_아이디); + 알람_발행(아티_아이디); + 알람_발행(아티_아이디); + } + + @DisplayName("전체 알람 요청 성공하면 응답코드 200과 알람을 받는다.") + @Test + void when_request_all_notifications_if_success_return_200_and_receive_notifications() { + // docs + api_문서_타이틀("notification_request_all_success", spec); + + // when + var response = 회원의_모든_알람을_조회한다(회원아티_액세스토큰, spec); + + // then + 응답코드가_200(response); + } + + @DisplayName("알람을 모두 삭제하면 응답코드 204와 알람을 모두 삭제한다.") + @Test + void when_delete_all_notification_if_success_then_return_code_204() { + // docs + api_문서_타이틀("notification_delete_all_success", spec); + + // when + var response = 유저의_모든_알람을_삭제한다(회원아티_액세스토큰, spec); + + // then + 응답코드가_204(response); + } + + @DisplayName("알람이 존재하고") + @Nested + class ExistsNotifications { + + String 알람_아이디; + + @BeforeEach + void setUp() { + 알람_아이디 = 회원의_모든_알람을_조회하고_첫번째_알람을_가져온다(회원아티_액세스토큰); + } + + + @DisplayName("개별 알람 요청 성공하면 응답코드 200과 알람을 받는다.") + @Test + void when_request_single_notification_if_success_return_200_and_receive_notification() { + // docs + api_문서_타이틀("notification_request_single_success", spec); + + // when + var response = 알람_아이디로_알람을_조회한다(알람_아이디, 회원아티_액세스토큰, spec); + + // then + 응답코드가_200(response); + } + + @DisplayName("알람을 일괄적으로 읽음으로 요청하면 응답코드 204와 알람을 읽음으로 변경한다.") + @Test + void when_change_all_notification_status_if_success_then_return_204() { + // docs + api_문서_타이틀("notification_change_all_status_success", spec); + var 알람들 = 회원의_모든_알람을_조회한다(회원아티_액세스토큰, spec).jsonPath().getList("content.id", String.class); + + // when + var response = 알람을_일괄적으로_변경(알람들, 회원아티_액세스토큰, spec); + + // then + 응답코드가_204(response); + } + + @DisplayName("알람을 읽음으로 요청하면 응답코드 204과 알람을 읽음으로 변경한다.") + @Test + void when_change_notification_status_if_success_then_return_204() { + // docs + api_문서_타이틀("notification_change_status_success", spec); + + // when + var response = 알람을_읽음으로_변경(알람_아이디, 회원아티_액세스토큰, spec); + + // then + 응답코드가_204(response); + } + + @DisplayName("알람을 일괄적으로 삭제하면 응답코드 204가 반환된다.") + @Test + void when_delete_notifications_if_success_then_return_code_204() { + // docs + api_문서_타이틀("notification_delete_notification_list_success", spec); + var 알람들 = 회원의_모든_알람을_조회한다(회원아티_액세스토큰, spec).jsonPath().getList("content.id", String.class); + + // when + var response = 알람을_일괄적으로_삭졔한다(알람들, 회원아티_액세스토큰, spec); + + // then + 응답코드가_204(response); + } + + @DisplayName("알람을 삭제하면 응답코드 204가 반환된다.") + @Test + void when_delete_notification_if_success_then_return_code_204() { + // docs + api_문서_타이틀("notification_delete_success", spec); + + // when + var response = 알람을_삭제한다(알람_아이디, 회원아티_액세스토큰, spec); + + // then + 응답코드가_204(response); + } + + } +} diff --git a/be/src/test/java/com/foodymoody/be/acceptance/notification/NotificationSteps.java b/be/src/test/java/com/foodymoody/be/acceptance/notification/NotificationSteps.java new file mode 100644 index 000000000..463398e1e --- /dev/null +++ b/be/src/test/java/com/foodymoody/be/acceptance/notification/NotificationSteps.java @@ -0,0 +1,106 @@ +package com.foodymoody.be.acceptance.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.foodymoody.be.comment.domain.CommentAddNotificationEvent; +import com.foodymoody.be.common.event.NotificationEvents; +import com.foodymoody.be.common.event.NotificationType; +import io.restassured.RestAssured; +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import io.restassured.specification.RequestSpecification; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Assertions; + +public class NotificationSteps { + + public static ExtractableResponse 회원의_모든_알람을_조회한다(String accessToken) { + return 회원의_모든_알람을_조회한다(accessToken, new RequestSpecBuilder().build()); + } + + public static ExtractableResponse 회원의_모든_알람을_조회한다(String accessToken, RequestSpecification spec) { + return RestAssured + .given().log().all().spec(spec).auth().oauth2(accessToken) + .when().get("/api/notifications/") + .then().log().all() + .extract(); + } + + public static ExtractableResponse 알람을_읽음으로_변경(String 알람_아이디, String accessToken, + RequestSpecification spec) { + Map body = Map.of("isRead", true); + return RestAssured.given().log().all().spec(spec).auth().oauth2(accessToken).body(body) + .contentType("application/json;charset=UTF-8") + .accept("application/json;charset=UTF-8") + .when().put("/api/notifications/{notificationId}", 알람_아이디) + .then().log().all().extract(); + } + + public static ExtractableResponse 알람을_일괄적으로_변경(List 알람_아이디들, String accessToken, + RequestSpecification spec) { + Map body = new HashMap<>(); + body.put("isRead", true); + body.put("notificationIds", 알람_아이디들); + return RestAssured.given().log().all().spec(spec).auth().oauth2(accessToken).body(body) + .contentType("application/json;charset=UTF-8") + .accept("application/json;charset=UTF-8") + .when().put("/api/notifications") + .then().log().all().extract(); + } + + public static ExtractableResponse 유저의_모든_알람을_삭제한다(String accessToken, RequestSpecification spec) { + return RestAssured.given().log().all().spec(spec).auth().oauth2(accessToken).when().delete("/api/notifications") + .then() + .log().all().extract(); + } + + public static ExtractableResponse 알람을_삭제한다(String 알람_아이디, String accessToken, RequestSpecification spec) { + return RestAssured.given().log().all().spec(spec).auth().oauth2(accessToken).when() + .delete("/api/notifications/{eventId}", 알람_아이디).then().log().all().extract(); + } + + public static ExtractableResponse 알람을_일괄적으로_삭졔한다(List 알람들, String accessToken, + RequestSpecification spec) { + Map body = new HashMap<>(); + body.put("notificationIds", 알람들); + return RestAssured.given().log().all().spec(spec).auth().oauth2(accessToken) + .body(body).contentType("application/json;charset=UTF-8") + .when().delete("/api/notifications") + .then().log().all().extract(); + } + + public static String 회원의_모든_알람을_조회하고_첫번째_알람을_가져온다(String accessToken) { + return 회원의_모든_알람을_조회한다(accessToken).jsonPath().getList("content.id", String.class).get(0); + } + + @NotNull + public static CommentAddNotificationEvent createCommentAddNotification(String 회원_아이디, LocalDateTime createdAt) { + return CommentAddNotificationEvent.of(회원_아이디, "피드에 새로운 댓글이 추가했습니다", NotificationType.COMMENT_ADDED, createdAt); + } + + public static void 응답코드가_204(ExtractableResponse response) { + Assertions.assertAll(() -> assertThat(response.statusCode()).isEqualTo(204)); + } + + public static void 응답코드가_200(ExtractableResponse response) { + Assertions.assertAll(() -> assertThat(response.statusCode()).isEqualTo(200)); + } + + public static void 알람_발행(String 회원_아이디) { + LocalDateTime createdAt = LocalDateTime.of(2021, 1, 1, 1, 1, 1); + CommentAddNotificationEvent event = createCommentAddNotification(회원_아이디, createdAt); + NotificationEvents.publish(event); + } + + public static ExtractableResponse 알람_아이디로_알람을_조회한다(String 알람_아이디, String accessToken, RequestSpecification spec) { + return RestAssured.given().log().all().spec(spec).auth().oauth2(accessToken) + .when().get("/api/notifications/{notificationId}", 알람_아이디) + .then().log().all() + .extract(); + } +} diff --git a/be/src/test/java/com/foodymoody/be/acceptance/util/DatabaseCleanup.java b/be/src/test/java/com/foodymoody/be/acceptance/util/DatabaseCleanup.java index 5cdd96bd7..f0a8c1f00 100644 --- a/be/src/test/java/com/foodymoody/be/acceptance/util/DatabaseCleanup.java +++ b/be/src/test/java/com/foodymoody/be/acceptance/util/DatabaseCleanup.java @@ -1,6 +1,5 @@ package com.foodymoody.be.acceptance.util; -import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; import javax.annotation.PostConstruct; @@ -17,18 +16,10 @@ public class DatabaseCleanup { private List tableNames; - private List excludeTables = Arrays.asList("mood"); - - public void setExcludeTables(List excludeTables) { - this.excludeTables = excludeTables; - init(); - } - @PostConstruct public void init() { tableNames = (List) entityManager.createNativeQuery("SHOW TABLES").getResultList() .stream() - .filter(tableNames -> !excludeTables.contains(tableNames)) .collect(Collectors.toList()); } diff --git a/be/src/test/java/com/foodymoody/be/feed/mapper/FeedMapperTest.java b/be/src/test/java/com/foodymoody/be/feed/mapper/FeedMapperTest.java deleted file mode 100644 index d1b059a57..000000000 --- a/be/src/test/java/com/foodymoody/be/feed/mapper/FeedMapperTest.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.foodymoody.be.feed.mapper; - -class FeedMapperTest { - - -} diff --git a/be/src/test/java/com/foodymoody/be/notification/domain/NotificationTest.java b/be/src/test/java/com/foodymoody/be/notification/domain/NotificationTest.java new file mode 100644 index 000000000..0427f1488 --- /dev/null +++ b/be/src/test/java/com/foodymoody/be/notification/domain/NotificationTest.java @@ -0,0 +1,83 @@ +package com.foodymoody.be.notification.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.foodymoody.be.notification.util.NotificationFixture; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class NotificationTest { + + @DisplayName("알람의 소유자인지 확인한다.") + @Test + void isSameMember() { + // given + var notification = NotificationFixture.notification(); + + // when + var sameMember = notification.isSameMember(NotificationFixture.MEMBER_ID); + var notSameMember = notification.isSameMember(NotificationFixture.NOT_EXIST_MEMBER_ID); + + // then + assertThat(sameMember).isTrue(); + assertThat(notSameMember).isFalse(); + } + + @DisplayName("알람의 상태를 변경한다.") + @ParameterizedTest + @CsvSource({"true", "false"}) + void changeStatus(boolean isRead) { + // given + var notification = NotificationFixture.notification(); + + // when + notification.changeStatus(isRead, NotificationFixture.MEMBER_ID, NotificationFixture.UPDATE_AT); + + // then + assertThat(notification.isRead()).isEqualTo(isRead); + } + + @DisplayName("알람의 상태 변경시 해당 알람의 소유주가 아니면 예외가 발생한다.") + @Test + void changeStatusWithNotSameMember() { + // given + var notification = NotificationFixture.notification(); + + // when,then + Assertions.assertThatThrownBy( + () -> notification.changeStatus(true, NotificationFixture.NOT_EXIST_MEMBER_ID, + NotificationFixture.UPDATE_AT)) + .isInstanceOf(IllegalArgumentException.class) + .message().isEqualTo("해당 알림을 수정할 수 없습니다."); + } + + + @DisplayName("알람을 삭제한다.") + @Test + void delete() { + // given + var notification = NotificationFixture.notification(); + + // when + notification.delete(NotificationFixture.MEMBER_ID, NotificationFixture.UPDATE_AT); + + // then + assertThat(notification.isDeleted()).isTrue(); + } + + @DisplayName("알람을 삭제시 해당 알람의 소유자가 아니면 예외를 발생한다.") + @Test + void deleteWithNotSameMember() { + // given + var notification = NotificationFixture.notification(); + + // when,then + Assertions.assertThatThrownBy( + () -> notification.delete(NotificationFixture.NOT_EXIST_MEMBER_ID, NotificationFixture.UPDATE_AT)) + .isInstanceOf(IllegalArgumentException.class) + .message().isEqualTo("해당 알림을 수정할 수 없습니다."); + } +} diff --git a/be/src/test/java/com/foodymoody/be/notification/service/NotificationMapperTest.java b/be/src/test/java/com/foodymoody/be/notification/service/NotificationMapperTest.java new file mode 100644 index 000000000..0b9ce55e5 --- /dev/null +++ b/be/src/test/java/com/foodymoody/be/notification/service/NotificationMapperTest.java @@ -0,0 +1,65 @@ +package com.foodymoody.be.notification.service; + +import com.foodymoody.be.notification.util.NotificationFixture; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class NotificationMapperTest { + + NotificationMapper notificationMapper; + + @BeforeEach + void setUp() { + notificationMapper = new NotificationMapper(); + } + + @DisplayName("알람 이벤트를 받아서 알람 엔티티로 변환한다.") + @Test + void createNotificationEntityFromEvent() { + // given + var commentAddNotificationEvent = NotificationFixture.commentAddNotificationEvent(); + var notificationId = NotificationFixture.notificationId(); + + // when + var notification = notificationMapper.createNotificationEntityFromEvent(notificationId, + commentAddNotificationEvent); + + // then + Assertions.assertAll( + () -> Assertions.assertEquals(notificationId, notification.getId()), + () -> Assertions.assertEquals(commentAddNotificationEvent.getMemberId(), notification.getMemberId()), + () -> Assertions.assertEquals(commentAddNotificationEvent.getMessage(), notification.getMessage()), + () -> Assertions.assertEquals(commentAddNotificationEvent.getNotificationType(), + notification.getType()), + () -> Assertions.assertFalse(notification.isRead()), + () -> Assertions.assertFalse(notification.isDeleted()), + () -> Assertions.assertEquals(commentAddNotificationEvent.getCreatedAt(), notification.getCreatedAt()), + () -> Assertions.assertEquals(commentAddNotificationEvent.getCreatedAt(), notification.getUpdatedAt()) + ); + } + + @DisplayName("알람 엔티티 목록을 받아서 알람 응답 DTO 목록으로 변환한다.") + @Test + void generateResponseDtoSliceFromNotifications() { + // given + var notifications = NotificationFixture.notifications(); + + // when + var notificationDto = notificationMapper.generateResponseDtoSliceFromNotifications(notifications); + + // then + Assertions.assertAll( + () -> Assertions.assertEquals(notifications.getContent().size(), notificationDto.getContent().size()), + () -> Assertions.assertEquals(notifications.getContent().get(0).getId().getValue(), + notificationDto.getContent().get(0).getId()), + () -> Assertions.assertEquals(notifications.getContent().get(0).getMessage(), + notificationDto.getContent().get(0).getMessage()), + () -> Assertions.assertEquals(notifications.getContent().get(0).getType(), + notificationDto.getContent().get(0).getType()), + () -> Assertions.assertEquals(notifications.getContent().get(0).isRead(), + notificationDto.getContent().get(0).isRead()) + ); + } +} diff --git a/be/src/test/java/com/foodymoody/be/notification/util/NotificationFixture.java b/be/src/test/java/com/foodymoody/be/notification/util/NotificationFixture.java new file mode 100644 index 000000000..3ff54bd73 --- /dev/null +++ b/be/src/test/java/com/foodymoody/be/notification/util/NotificationFixture.java @@ -0,0 +1,48 @@ +package com.foodymoody.be.notification.util; + +import com.foodymoody.be.comment.domain.CommentAddNotificationEvent; +import com.foodymoody.be.common.event.NotificationType; +import com.foodymoody.be.notification.domain.Notification; +import com.foodymoody.be.notification.domain.NotificationId; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +public class NotificationFixture { + + public static final LocalDateTime CREATE_AT = LocalDateTime.of(2021, 1, 1, 0, 0, 0); + public static final String NOTIFICATION_ID = "11231312"; + public static final String NOT_EXIST_NOTIFICATION_ID = "not exist notification id"; + public static final String MEMBER_ID = "1231312312"; + public static final String NOTIFICATION_MESSAGE = "새로운 댓글이 달렸습니다."; + public static final String NOT_EXIST_MEMBER_ID = "not exist member id"; + public static final LocalDateTime UPDATE_AT = LocalDateTime.of(2021, 2, 3, 4, 5, 6); + + public static CommentAddNotificationEvent commentAddNotificationEvent() { + return CommentAddNotificationEvent.of(MEMBER_ID, NOTIFICATION_MESSAGE, + NotificationType.COMMENT_ADDED, NotificationFixture.CREATE_AT); + } + + public static NotificationId notificationId() { + return NotificationId.from(NOTIFICATION_ID); + } + + public static Notification notification() { + return notification(notificationId()); + } + + public static Notification notification(NotificationId id) { + return new Notification(id, MEMBER_ID, NOTIFICATION_MESSAGE, + NotificationType.COMMENT_ADDED, false, false, CREATE_AT, UPDATE_AT); + } + + public static Slice notifications() { + List notifications = IntStream.range(0, 10) + .mapToObj(i -> notification(NotificationId.from(i + ""))) + .collect(Collectors.toList()); + return new SliceImpl(notifications); + } +} diff --git a/be/src/test/resources/data.sql b/be/src/test/resources/data.sql index 0e51e3bf4..7a9b9964b 100644 --- a/be/src/test/resources/data.sql +++ b/be/src/test/resources/data.sql @@ -1,5 +1,7 @@ -INSERT INTO mood (id, name) VALUES ('1', '베지테리언'); -INSERT INTO mood (id, name) VALUES ('2', '베지테리언2'); +INSERT INTO mood (id, name) +VALUES ('1', '베지테리언'); +INSERT INTO mood (id, name) +VALUES ('2', '베지테리언2'); INSERT INTO mood (id, name) VALUES ('3', '무드1'); INSERT INTO mood (id, name) VALUES ('4', '무드2'); INSERT INTO mood (id, name) VALUES ('5', '무드3'); @@ -11,4 +13,4 @@ VALUES ('1', 'https://foodymoody-test.s3.ap-northeast-2.amazonaws.com/foodymoody INSERT INTO member (id, email, nickname, password, mood_id) VALUES ('1', 'ati@ati.com', '아티', 'ati123!', '1'); INSERT INTO member (id, email, nickname, password, mood_id) -VALUES ('2', 'puban@puban.com', '푸반', 'puban123!', '1'); \ No newline at end of file +VALUES ('2', 'puban@puban.com', '푸반', 'puban123!', '1');