Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feat] 게시물 목록 기능 구현 #30

Merged
merged 13 commits into from
Aug 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/main/java/wanted/media/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
ENTITY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 엔티티입니다."),
ENTITY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 엔티티입니다."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."),
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다."),
FORBIDDEN(HttpStatus.FORBIDDEN, "권한이 없습니다.");
// 클라이언트의 입력 값에 대한 일반적인 오류 (@PathVariable, @RequestParam가 잘못되었을 때)
INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "클라이언트의 입력 값을 확인해주세요."),
INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."),

USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."),
INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."),
VERIFICATION_CODE_MISMATCH(HttpStatus.BAD_REQUEST, "인증코드가 일치하지 않습니다."),
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/wanted/media/exception/PostListCustomException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package wanted.media.exception;

import lombok.Getter;

@Getter
public class PostListCustomException extends RuntimeException {
private final ErrorCode errorCode;

public PostListCustomException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import wanted.media.exception.PostListCustomException;
import wanted.media.exception.BaseException;
import wanted.media.exception.ErrorCode;
import wanted.media.exception.CustomException;
Expand All @@ -19,6 +20,20 @@ public ResponseEntity<ErrorResponse> handleBadRequestException(BadRequestExcepti
return ResponseEntity.badRequest()
.body(new ErrorResponse(400, e.getMessage()));
}

@ExceptionHandler(PostListCustomException.class)
public ResponseEntity<ErrorResponse> handlePostCustomException(PostListCustomException ex) {
ErrorCode errorCode = ex.getErrorCode();
ErrorResponse response = new ErrorResponse(errorCode);
return new ResponseEntity<>(response, errorCode.getStatus());
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
ErrorResponse response = new ErrorResponse(ErrorCode.INTERNAL_SERVER_ERROR);
return new ResponseEntity<>(response, ErrorCode.INTERNAL_SERVER_ERROR.getStatus());
}

@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorResponse> handlePostNotFound(NotFoundException e) {
ErrorCode errorCode = e.getErrorCode();
Expand All @@ -28,16 +43,19 @@ public ResponseEntity<ErrorResponse> handlePostNotFound(NotFoundException e) {
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}

@ExceptionHandler(CustomException.class)
protected ResponseEntity<ErrorResponse> handleCustomException(final CustomException e) {
return ResponseEntity
.status(e.getErrorCode().getStatus().value())
.body(new ErrorResponse(e.getErrorCode().getStatus().value(), e.getCustomMessage()));
}

@ExceptionHandler(BaseException.class)
public ResponseEntity<ErrorResponse> handleBaseException(BaseException e) {
ErrorCode errorCode = e.getErrorCode();
return ResponseEntity.status(errorCode.getStatus())
.body(new ErrorResponse(errorCode.getStatus().value(), errorCode.getMessage()));

}
}
30 changes: 29 additions & 1 deletion src/main/java/wanted/media/post/controller/PostController.java
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
package wanted.media.post.controller;

import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import wanted.media.post.domain.Post;
import wanted.media.post.domain.Type;
import wanted.media.post.dto.PostDto;
import wanted.media.post.service.PostService;
import wanted.media.post.dto.PostDetailResponse;
import wanted.media.post.dto.PostIdResponse;
import wanted.media.post.service.PostService;

import java.util.List;
import java.util.stream.Collectors;
import java.util.HashMap;
import java.util.Map;

Expand Down Expand Up @@ -45,6 +52,27 @@ public ResponseEntity<PostDetailResponse> getPost(@PathVariable String postId) {
.build();
return ResponseEntity.ok(result);
}

@GetMapping
public ResponseEntity<List<PostDto>> list(@RequestParam(value = "hashtag", required = true) String account,
@RequestParam(value = "type", required = false) Type type,
@RequestParam(value = "orderBy", defaultValue = "createdAt") String orderBy,
@RequestParam(value = "sortDirection", defaultValue = "ASC") String sortDirection,
@RequestParam(value = "search_by", defaultValue = "title, content") String searchBy,
@RequestParam(value = "search", required = false) String search,
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "page_count", defaultValue = "10") int pageCount) {
Page<Post> postPage = postService.findPosts(account, type, orderBy, sortDirection, searchBy, search, page, pageCount);
List<PostDto> postDtos = postPage.getContent().stream()
.map(PostDto::allPosts)
.collect(Collectors.toList());

if (postDtos.isEmpty()) {
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}

return new ResponseEntity<>(postDtos, HttpStatus.OK);
}

@PostMapping("/likes/{postId}")
public ResponseEntity<?> getLikes(@PathVariable(name = "postId") String postId) {
Expand Down
36 changes: 36 additions & 0 deletions src/main/java/wanted/media/post/dto/PostDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package wanted.media.post.dto;

import wanted.media.post.domain.Post;
import wanted.media.post.domain.Type;

import java.time.LocalDateTime;

public record PostDto(
String id,
Long likeCount,
Type type,
String title,
String content,
String hashtags,
Long viewCount,
Long shareCount,
LocalDateTime updatedAt,
LocalDateTime createdAt,
String account
) {
public static PostDto allPosts(Post post) {
return new PostDto(
post.getId(),
post.getLikeCount(),
post.getType(),
post.getTitle(),
post.getContent(),
post.getHashtags(),
post.getViewCount(),
post.getShareCount(),
post.getUpdatedAt(),
post.getCreatedAt(),
post.getUser().getAccount()
);
}
}
15 changes: 15 additions & 0 deletions src/main/java/wanted/media/post/repository/PostRepository.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
package wanted.media.post.repository;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import wanted.media.post.domain.Post;
import wanted.media.post.domain.Type;

public interface PostRepository extends JpaRepository<Post, String> {
@Query("SELECT p FROM Post p " +
"WHERE p.user.account = :account " +
"AND (:type IS NULL OR p.type = :type) " +
"AND ((:searchBy = 'title' AND LOWER(p.title) LIKE LOWER(CONCAT('%', :search, '%'))) " +
"OR (:searchBy = 'content' AND LOWER(p.content) LIKE LOWER(CONCAT('%', :search, '%'))) " +
"OR (:searchBy = 'title,content' AND (LOWER(p.title) LIKE LOWER(CONCAT('%', :search, '%')) " +
"OR LOWER(p.content) LIKE LOWER(CONCAT('%', :search, '%')))))")
Page<Post> findBySearchContaining(@Param("account") String account, @Param("type") Type type,
@Param("searchBy") String searchBy, @Param("search") String search,
Pageable pageable);
}
17 changes: 17 additions & 0 deletions src/main/java/wanted/media/post/service/PostService.java
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
package wanted.media.post.service;

import lombok.RequiredArgsConstructor;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import wanted.media.exception.CustomException;
import wanted.media.exception.ErrorCode;
import wanted.media.exception.NotFoundException;
import wanted.media.post.domain.Post;
import wanted.media.post.domain.Type;
import wanted.media.post.repository.PostRepository;

@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;

@Transactional(readOnly = true)
public Page<Post> findPosts(String account, Type type, String orderBy, String sortDirection, String searchBy, String search, int page, int pageCount) {
Sort sort = Sort.by(Sort.Direction.fromString(sortDirection), orderBy);
Pageable pageable = PageRequest.of(page, pageCount, sort);

Page<Post> posts = postRepository.findBySearchContaining(account, type, searchBy, search, pageable);

return posts;
}

@Transactional
public Post getPost(String postId) {
Expand Down
94 changes: 94 additions & 0 deletions src/test/java/wanted/media/post/controller/PostControllerTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package wanted.media.post.controller;

import lombok.RequiredArgsConstructor;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@RequiredArgsConstructor
@TestPropertySource(locations = "classpath:application-test.yml")
class PostControllerTest {
private MockMvc mockMvc;

@Test
void posts_list_성공() throws Exception {
mockMvc.perform(get("/api/posts")
.param("hashtag", "wanted")
.param("type", "FACEBOOK")
.param("orderBy", "createdAt")
.param("sortDirection", "ASC")
.param("search_by", "title")
.param("search", "판교")
.param("page", "0")
.param("page_count", "10")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isNotEmpty()); // 응답이 비어있지 않음을 확인
}

@Test
void search_by_성공() throws Exception {
mockMvc.perform(get("/api/posts")
.param("hashtag", "wanted")
.param("type", "FACEBOOK")
.param("orderBy", "createdAt")
.param("sortDirection", "ASC")
.param("search_by", "invalidSearchBy")
.param("page", "0")
.param("page_count", "10")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest()); // 잘못된 검색 조건에 대한 에러 확인
}

@Test
void 유효하지_않은_page값() throws Exception {
mockMvc.perform(get("/api/posts")
.param("hashtag", "wanted")
.param("type", "FACEBOOK")
.param("orderBy", "createdAt")
.param("sortDirection", "ASC")
.param("search_by", "title")
.param("search", "판교")
.param("page", "-1") // 유효하지 않은 페이지 번호
.param("page_count", "10")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest()); // 페이지 값이 유효하지 않음을 확인
}

@Test
void 유효하지_않은_page_count_값() throws Exception {
mockMvc.perform(get("/api/posts")
.param("hashtag", "wanted")
.param("type", "FACEBOOK")
.param("orderBy", "createdAt")
.param("sortDirection", "ASC")
.param("search_by", "title")
.param("search", "판교")
.param("page", "0")
.param("page_count", "-10") // 유효하지 않은 페이지 수
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest()); // 페이지 수가 유효하지 않음을 확인
}

@Test
void search_키워드가_없을_때() throws Exception {
mockMvc.perform(get("/api/posts")
.param("hashtag", "wanted")
.param("type", "FACEBOOK")
.param("orderBy", "createdAt")
.param("sortDirection", "ASC")
.param("search_by", "title")
.param("page", "0")
.param("page_count", "10")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isNotEmpty()); // 응답이 비어있지 않음을 확인
}
}