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 12 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
13 changes: 8 additions & 5 deletions src/main/java/wanted/media/exception/ErrorCode.java
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
package wanted.media.exception;

import org.springframework.http.HttpStatus;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;

@Getter
@RequiredArgsConstructor
public enum ErrorCode {
ENTITY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 엔티티입니다.");
ENTITY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 엔티티입니다."),
INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "클라이언트의 입력 값을 확인해주세요."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."),
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다."),
FORBIDDEN(HttpStatus.FORBIDDEN, "권한이 없습니다.");

private final HttpStatus status;
private final String message;
private final HttpStatus status;
private final String message;
}
10 changes: 8 additions & 2 deletions src/main/java/wanted/media/exception/ErrorResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public class ErrorResponse {

private final int statusCode;
private final String message;
private final int statusCode;
private final String message;

public ErrorResponse(ErrorCode errorCode) {
this.statusCode = errorCode.getStatus().value();
this.message = errorCode.getMessage();
}
}
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 @@ -4,15 +4,29 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import wanted.media.exception.ErrorCode;
import wanted.media.exception.ErrorResponse;
import wanted.media.exception.PostListCustomException;

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(BadRequestException.class)
public ResponseEntity<ErrorResponse> handleBadRequestException(BadRequestException e) {
return ResponseEntity.badRequest()
.body(new ErrorResponse(400, e.getMessage()));
}
@ExceptionHandler(BadRequestException.class)
public ResponseEntity<ErrorResponse> handleBadRequestException(BadRequestException e) {
return ResponseEntity.badRequest()
.body(new ErrorResponse(400, e.getMessage()));
}

@ExceptionHandler(PostListCustomException.class)
public ResponseEntity<ErrorResponse> handleCustomException(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());
}
}
39 changes: 38 additions & 1 deletion src/main/java/wanted/media/post/controller/PostController.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,46 @@
package wanted.media.post.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
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 java.util.List;
import java.util.stream.Collectors;

@RestController
@RequestMapping("/posts")
@RequestMapping("/api/posts")
public class PostController {
@Autowired
private PostService postService;

@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);
}

}
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()
);
}
}
22 changes: 22 additions & 0 deletions src/main/java/wanted/media/post/repository/PostRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +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, Long> {
@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);
}
21 changes: 21 additions & 0 deletions src/main/java/wanted/media/post/service/PostService.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,28 @@
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.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;
}
}
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()); // 응답이 비어있지 않음을 확인
}
}