Skip to content

Commit

Permalink
feat: ✨ 사용자가 수신한 푸시 알림 리스트 최신순 조회 (#134)
Browse files Browse the repository at this point in the history
* test: notifications controller unit test 작성

* feat: notification controller 클래스 생성

* feat: notification use case 클래스 작성

* test: pageable param 전달 테스트 케이스

* test: given 절 반환 dto 수정

* feat: notification info & slice dto 정의

* fix: notification use case 껍데기 구현

* fix: controller use case 호출

* test: notification fixture 상수 및 dto 생성 메서드 추가

* test: 응답 json 경로 수정

* fix: get notifications controller success response로 응답 포맷 수정

* test: controller 응답 포맷 테스트

* fix: get notifications pageable size default 20 -> 30

* feat: notification slide select 메서드 추가

* fix: notification repository jpa_repository -> extended_repository 인터페이스 변경

* fix: controller pagable sort dirction 내림차순 옵션 추가

* feat: notificaiton search service impl

* feat: notification use case 내, service 및 mapper 호출 로직 처리

* feat: notification dto info builder 추가

* fix: notification use case import notification mapper

* feat: notification mapper 메서드 정의

* feat: notification table 수정 squash merge

* fix: formatting 메서드 수정

* refactor: 포매팅된 title, content 로직을 notification 엔티티 메서드로 제공

* test: notification fixture to_entity 주입 방식 수정 및 dummy dto 생성 로직 제거

* refactor: notification info dto 생성 로직 mapper -> dto로 이전

* docs: swagger config에 notification와 storage 태깅 추가

* docs: swagger 문서 추가

* test: domain 모듈 notification service unit test

* fix: notification 정기 지출 알림 쿼리 조건문 수정

* rename: 공지 타입 포맷팅 메서드 주석 추가

* rename: notification entity 내 포맷팅 메서드 주석 추가
  • Loading branch information
psychology50 committed Jul 17, 2024
1 parent 127cf5c commit 869be8d
Show file tree
Hide file tree
Showing 18 changed files with 664 additions and 55 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package kr.co.pennyway.api.apis.notification.api;


import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.SchemaProperty;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import kr.co.pennyway.api.apis.notification.dto.NotificationDto;
import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.data.web.SortDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;

@Tag(name = "[알림 API]")
public interface NotificationApi {
@Operation(summary = "수신한 알림 목록 무한 스크롤 조회")
@Parameters({
@Parameter(
in = ParameterIn.QUERY,
description = "조회하려는 페이지 (0..N) (기본 값 : 0)",
name = "page",
example = "0",
schema = @Schema(
type = "integer",
defaultValue = "0"
)
),
@Parameter(
in = ParameterIn.QUERY,
description = "페이지 내 데이터 수 (기본 값 : 30)",
name = "size",
example = "30",
schema = @Schema(
type = "integer",
defaultValue = "30"
)
),
@Parameter(
in = ParameterIn.QUERY,
description = "정렬 기준 (기본 값 : notification.createdAt,DESC)",
name = "sort",
example = "notification.createdAt,DESC",
array = @ArraySchema(
schema = @Schema(
type = "string"
)
)
), @Parameter(name = "pageable", hidden = true)})
@ApiResponse(responseCode = "200", description = "알림 목록 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "notifications", schema = @Schema(implementation = NotificationDto.SliceRes.class))))
ResponseEntity<?> getNotifications(
@PageableDefault(page = 0, size = 30) @SortDefault(sort = "notification.createdAt", direction = Sort.Direction.DESC) Pageable pageable,
@AuthenticationPrincipal SecurityUserDetails user
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package kr.co.pennyway.api.apis.notification.controller;

import kr.co.pennyway.api.apis.notification.api.NotificationApi;
import kr.co.pennyway.api.apis.notification.usecase.NotificationUseCase;
import kr.co.pennyway.api.common.response.SuccessResponse;
import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.data.web.SortDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/v2/notifications")
public class NotificationController implements NotificationApi {
private static final String NOTIFICATIONS = "notifications";

private final NotificationUseCase notificationUseCase;

@Override
@GetMapping("")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<?> getNotifications(
@PageableDefault(page = 0, size = 30) @SortDefault(sort = "notification.createdAt", direction = Sort.Direction.DESC) Pageable pageable,
@AuthenticationPrincipal SecurityUserDetails user
) {
return ResponseEntity.ok(SuccessResponse.from(NOTIFICATIONS, notificationUseCase.getNotifications(user.getUserId(), pageable)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package kr.co.pennyway.api.apis.notification.dto;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import io.swagger.v3.oas.annotations.media.Schema;
import kr.co.pennyway.domain.domains.notification.domain.Notification;
import kr.co.pennyway.domain.domains.notification.type.NoticeType;
import lombok.Builder;
import org.springframework.data.domain.Pageable;

import java.time.LocalDateTime;
import java.util.List;

public class NotificationDto {
@Schema(title = "푸시 알림 슬라이스 응답")
public record SliceRes(
@Schema(description = "푸시 알림 리스트")
List<Info> content,
@Schema(description = "현재 페이지 번호")
int currentPageNumber,
@Schema(description = "페이지 크기")
int pageSize,
@Schema(description = "전체 요소 개수")
int numberOfElements,
@Schema(description = "다음 페이지 존재 여부")
boolean hasNext
) {
public static SliceRes from(List<Info> notifications, Pageable pageable, int numberOfElements, boolean hasNext) {
return new SliceRes(notifications, pageable.getPageNumber(), pageable.getPageSize(), numberOfElements, hasNext);
}
}

@Builder
@Schema(title = "푸시 알림 상세 정보", description = "푸시 알림 pk, 읽음 여부, 제목, 내용, 타입 그리고 딥 링크 정보를 담고 있다.")
public record Info(
@Schema(description = "푸시 알림 pk", example = "1")
Long id,
@Schema(description = "푸시 알림 읽음 여부", example = "true")
boolean isRead,
@Schema(description = "푸시 알림 제목", example = "페니웨이 공지")
String title,
@Schema(description = "푸시 알림 내용", example = "안녕하세요. 페니웨이입니다.")
String content,
@Schema(description = "푸시 알림 타입. ex) ANNOUNCEMENT", example = "FEED_LIKE_FROM_TO")
String type,
@Schema(description = "푸시 알림 행위자. ex) 다른 사용자 <type이 ANNOUNCEMENT면 존재하지 않음>", example = "pennyway")
@JsonInclude(JsonInclude.Include.NON_NULL)
String from,
@Schema(description = "푸시 알림 행위자 pk <type이 ANNOUNCEMENT면 존재하지 않음>", example = "1")
@JsonInclude(JsonInclude.Include.NON_NULL)
Long fromId,
@Schema(description = "푸시 알림 행위자가 액션을 취한 대상 pk. ex) 피드 pk, 댓글 pk <type이 ANNOUNCEMENT면 존재하지 않음>", example = "3")
@JsonInclude(JsonInclude.Include.NON_NULL)
Long toId,
@Schema(description = "푸시 알림 생성 시간", example = "2024-07-17 12:00:00")
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
LocalDateTime createdAt
) {
public static NotificationDto.Info from(Notification notification) {
NotificationDto.Info.InfoBuilder builder = NotificationDto.Info.builder()
.id(notification.getId())
.isRead(notification.getReadAt() != null)
.title(notification.createFormattedTitle())
.content(notification.createFormattedContent())
.type(notification.getType().name())
.createdAt(notification.getCreatedAt());

if (!notification.getType().equals(NoticeType.ANNOUNCEMENT)) {
builder.from(notification.getSenderName())
.fromId(notification.getSender().getId())
.toId(notification.getToId());
}

return builder.build();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package kr.co.pennyway.api.apis.notification.mapper;

import kr.co.pennyway.api.apis.notification.dto.NotificationDto;
import kr.co.pennyway.common.annotation.Mapper;
import kr.co.pennyway.domain.domains.notification.domain.Notification;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;

@Slf4j
@Mapper
public class NotificationMapper {
/**
* Slice<Notification> 타입을 무한 스크롤 응답 형태로 변환한다.
*/
public static NotificationDto.SliceRes toSliceRes(Slice<Notification> notifications, Pageable pageable) {
return NotificationDto.SliceRes.from(
notifications.getContent().stream().map(NotificationDto.Info::from).toList(),
pageable,
notifications.getNumberOfElements(),
notifications.hasNext()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package kr.co.pennyway.api.apis.notification.service;

import kr.co.pennyway.domain.domains.notification.domain.Notification;
import kr.co.pennyway.domain.domains.notification.service.NotificationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class NotificationSearchService {
private final NotificationService notificationService;

@Transactional(readOnly = true)
public Slice<Notification> getNotifications(Long userId, Pageable pageable) {
return notificationService.readNotificationsSlice(userId, pageable);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package kr.co.pennyway.api.apis.notification.usecase;

import kr.co.pennyway.api.apis.notification.dto.NotificationDto;
import kr.co.pennyway.api.apis.notification.mapper.NotificationMapper;
import kr.co.pennyway.api.apis.notification.service.NotificationSearchService;
import kr.co.pennyway.common.annotation.UseCase;
import kr.co.pennyway.domain.domains.notification.domain.Notification;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;

@Slf4j
@UseCase
@RequiredArgsConstructor
public class NotificationUseCase {
private final NotificationSearchService notificationSearchService;

public NotificationDto.SliceRes getNotifications(Long userId, Pageable pageable) {
Slice<Notification> notifications = notificationSearchService.getNotifications(userId, pageable);

return NotificationMapper.toSliceRes(notifications, pageable);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,24 @@ public GroupedOpenApi authApi() {

@Bean
public GroupedOpenApi userApi() {
String[] targets = {"kr.co.pennyway.api.apis.users"};
String[] targets = {"kr.co.pennyway.api.apis.users", "kr.co.pennyway.api.apis.notification"};

return GroupedOpenApi.builder()
.packagesToScan(targets)
.group("사용자 기본 기능")
.build();
}

@Bean
public GroupedOpenApi storageApi() {
String[] targets = {"kr.co.pennyway.api.apis.storage"};

return GroupedOpenApi.builder()
.packagesToScan(targets)
.group("정적 파일 저장")
.build();
}

@Bean
public GroupedOpenApi ledgerApi() {
String[] targets = {"kr.co.pennyway.api.apis.ledger"};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package kr.co.pennyway.api.apis.notification.controller;

import kr.co.pennyway.api.apis.notification.dto.NotificationDto;
import kr.co.pennyway.api.apis.notification.usecase.NotificationUseCase;
import kr.co.pennyway.api.config.WebConfig;
import kr.co.pennyway.api.config.fixture.NotificationFixture;
import kr.co.pennyway.api.config.fixture.UserFixture;
import kr.co.pennyway.api.config.supporter.WithSecurityMockUser;
import kr.co.pennyway.domain.domains.notification.domain.Notification;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.data.domain.Pageable;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import java.util.List;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(controllers = {NotificationController.class}, excludeFilters = {
@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebConfig.class)})
@ActiveProfiles("test")
public class GetNotificationsControllerUnitTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private NotificationUseCase notificationUseCase;

@BeforeEach
void setUp(WebApplicationContext context) {
this.mockMvc = MockMvcBuilders
.webAppContextSetup(context)
.defaultRequest(post("/**").with(csrf()))
.defaultRequest(put("/**").with(csrf()))
.defaultRequest(delete("/**").with(csrf()))
.build();
}

@Test
@WithSecurityMockUser
@DisplayName("쿼리 파라미터로 page 외의 파라미터는 기본값을 갖는다.")
void getNotificationsWithDefaultParameters() throws Exception {
// when
int page = 0, currentPageNumber = 0, pageSize = 20, numberOfElements = 1;
Pageable pa = Pageable.ofSize(pageSize).withPage(currentPageNumber);

Notification notification = NotificationFixture.ANNOUNCEMENT_DAILY_SPENDING.toEntity(UserFixture.GENERAL_USER.toUser());
NotificationDto.Info info = NotificationDto.Info.from(notification);

given(notificationUseCase.getNotifications(eq(1L), any())).willReturn(NotificationDto.SliceRes.from(List.of(info), pa, numberOfElements, false));

// when
ResultActions result = performGetNotifications(page);

// then
result.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.notifications.currentPageNumber").value(page));
}

@Test
@WithSecurityMockUser
@DisplayName("응답은 무한 스크롤 방식으로 제공되며, content, currentPageNumber, pageSize, numberOfElements, hasNext 필드를 포함한다.")
void getNotificationsWithInfiniteScroll() throws Exception {
// when
int page = 0, currentPageNumber = 0, pageSize = 20, numberOfElements = 1;
Pageable pa = Pageable.ofSize(pageSize).withPage(currentPageNumber);

Notification notification = NotificationFixture.ANNOUNCEMENT_DAILY_SPENDING.toEntity(UserFixture.GENERAL_USER.toUser());
NotificationDto.Info info = NotificationDto.Info.from(notification);
NotificationDto.SliceRes sliceRes = NotificationDto.SliceRes.from(List.of(info), pa, numberOfElements, false);

given(notificationUseCase.getNotifications(eq(1L), any())).willReturn(sliceRes);

// when
ResultActions result = performGetNotifications(page);

// then
result.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.notifications.content").exists())
.andExpect(jsonPath("$.data.notifications.currentPageNumber").value(sliceRes.currentPageNumber()))
.andExpect(jsonPath("$.data.notifications.pageSize").value(sliceRes.pageSize()))
.andExpect(jsonPath("$.data.notifications.numberOfElements").value(sliceRes.numberOfElements()))
.andExpect(jsonPath("$.data.notifications.hasNext").value(sliceRes.hasNext()));
}

private ResultActions performGetNotifications(int page) throws Exception {
return mockMvc.perform(get("/v2/notifications")
.param("page", String.valueOf(page)));
}
}
Loading

0 comments on commit 869be8d

Please sign in to comment.