diff --git a/src/main/java/com/back/domain/board/controller/PostController.java b/src/main/java/com/back/domain/board/controller/PostController.java index 0e7400d4..c4afc0bc 100644 --- a/src/main/java/com/back/domain/board/controller/PostController.java +++ b/src/main/java/com/back/domain/board/controller/PostController.java @@ -1,12 +1,14 @@ package com.back.domain.board.controller; -import com.back.domain.board.dto.PostRequest; -import com.back.domain.board.dto.PostResponse; +import com.back.domain.board.dto.*; import com.back.domain.board.service.PostService; import com.back.global.common.dto.RsData; import com.back.global.security.user.CustomUserDetails; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -32,4 +34,35 @@ public ResponseEntity> createPost( response )); } + + // 게시글 다건 조회 + @GetMapping + public ResponseEntity>> getPosts( + @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String searchType, + @RequestParam(required = false) Long categoryId + ) { + PageResponse response = postService.getPosts(keyword, searchType, categoryId, pageable); + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success( + "게시글 목록이 조회되었습니다.", + response + )); + } + + // 게시글 단건 조회 + @GetMapping("/{postId}") + public ResponseEntity> getPost( + @PathVariable Long postId + ) { + PostDetailResponse response = postService.getPost(postId); + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success( + "게시글이 조회되었습니다.", + response + )); + } } \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/controller/PostControllerDocs.java b/src/main/java/com/back/domain/board/controller/PostControllerDocs.java index 4ce1eb44..0f09c418 100644 --- a/src/main/java/com/back/domain/board/controller/PostControllerDocs.java +++ b/src/main/java/com/back/domain/board/controller/PostControllerDocs.java @@ -1,7 +1,6 @@ package com.back.domain.board.controller; -import com.back.domain.board.dto.PostRequest; -import com.back.domain.board.dto.PostResponse; +import com.back.domain.board.dto.*; import com.back.global.common.dto.RsData; import com.back.global.security.user.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; @@ -10,9 +9,13 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "Post API", description = "게시글 관련 API") public interface PostControllerDocs { @@ -144,4 +147,151 @@ ResponseEntity> createPost( @RequestBody PostRequest request, @AuthenticationPrincipal CustomUserDetails user ); + + @Operation( + summary = "게시글 목록 조회", + description = "모든 사용자가 게시글 목록을 조회할 수 있습니다. (로그인 불필요)" + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "게시글 목록 조회 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "게시글 목록이 조회되었습니다.", + "data": { + "items": [ + { + "postId": 1, + "author": { "id": 10, "nickname": "홍길동" }, + "title": "첫 글", + "categories": [{ "id": 1, "name": "공지사항" }], + "likeCount": 5, + "bookmarkCount": 2, + "commentCount": 3, + "createdAt": "2025-09-30T10:15:30", + "updatedAt": "2025-09-30T10:20:00" + } + ], + "page": 0, + "size": 10, + "totalElements": 25, + "totalPages": 3, + "last": false + } + } + """) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (페이징 파라미터 오류 등)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_400", + "message": "잘못된 요청입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity>> getPosts( + @PageableDefault(sort = "createdAt") Pageable pageable, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String searchType, + @RequestParam(required = false) Long categoryId + ); + + + @Operation( + summary = "게시글 단건 조회", + description = "모든 사용자가 특정 게시글의 상세 정보를 조회할 수 있습니다. (로그인 불필요)" + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "게시글 단건 조회 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "게시글이 조회되었습니다.", + "data": { + "postId": 101, + "author": { "id": 5, "nickname": "홍길동" }, + "title": "첫 번째 게시글", + "content": "안녕하세요, 첫 글입니다!", + "categories": [ + { "id": 1, "name": "공지사항" }, + { "id": 2, "name": "자유게시판" } + ], + "likeCount": 10, + "bookmarkCount": 2, + "commentCount": 3, + "createdAt": "2025-09-22T10:30:00", + "updatedAt": "2025-09-22T10:30:00" + } + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 게시글", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "POST_001", + "message": "존재하지 않는 게시글입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity> getPost( + @PathVariable Long postId + ); } diff --git a/src/main/java/com/back/domain/board/dto/PageResponse.java b/src/main/java/com/back/domain/board/dto/PageResponse.java new file mode 100644 index 00000000..ef317fb0 --- /dev/null +++ b/src/main/java/com/back/domain/board/dto/PageResponse.java @@ -0,0 +1,36 @@ +package com.back.domain.board.dto; + +import org.springframework.data.domain.Page; + +import java.util.List; + +/** + * 페이지 응답 DTO + * + * @param items 목록 데이터 + * @param page 현재 페이지 번호 + * @param size 페이지 크기 + * @param totalElements 전체 요소 수 + * @param totalPages 전체 페이지 수 + * @param last 마지막 페이지 여부 + * @param 제네릭 타입 + */ +public record PageResponse( + List items, + int page, + int size, + long totalElements, + int totalPages, + boolean last +) { + public static PageResponse from(Page page) { + return new PageResponse<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages(), + page.isLast() + ); + } +} diff --git a/src/main/java/com/back/domain/board/dto/PostDetailResponse.java b/src/main/java/com/back/domain/board/dto/PostDetailResponse.java new file mode 100644 index 00000000..b9dc5198 --- /dev/null +++ b/src/main/java/com/back/domain/board/dto/PostDetailResponse.java @@ -0,0 +1,50 @@ +package com.back.domain.board.dto; + +import com.back.domain.board.entity.Post; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 게시글 상세 응답 DTO + * + * @param postId 게시글 ID + * @param author 작성자 정보 + * @param title 게시글 제목 + * @param content 게시글 내용 + * @param categories 게시글 카테고리 목록 + * @param likeCount 좋아요 수 + * @param bookmarkCount 북마크 수 + * @param commentCount 댓글 수 + * @param createdAt 게시글 생성 일시 + * @param updatedAt 게시글 수정 일시 + */ +public record PostDetailResponse( + Long postId, + AuthorResponse author, + String title, + String content, + List categories, + long likeCount, + long bookmarkCount, + long commentCount, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static PostDetailResponse from(Post post) { + return new PostDetailResponse( + post.getId(), + AuthorResponse.from(post.getUser()), + post.getTitle(), + post.getContent(), + post.getCategories().stream() + .map(CategoryResponse::from) + .toList(), + post.getPostLikes().size(), + post.getPostBookmarks().size(), + post.getComments().size(), + post.getCreatedAt(), + post.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/back/domain/board/dto/PostListResponse.java b/src/main/java/com/back/domain/board/dto/PostListResponse.java new file mode 100644 index 00000000..2976b9c6 --- /dev/null +++ b/src/main/java/com/back/domain/board/dto/PostListResponse.java @@ -0,0 +1,77 @@ +package com.back.domain.board.dto; + +import com.querydsl.core.annotations.QueryProjection; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 게시글 목록 응답 DTO + */ +@Getter +public class PostListResponse { + private final Long postId; + private final AuthorResponse author; + private final String title; + private final long likeCount; + private final long bookmarkCount; + private final long commentCount; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + + @Setter + private List categories; + + @QueryProjection + public PostListResponse(Long postId, + AuthorResponse author, + String title, + List categories, + long likeCount, + long bookmarkCount, + long commentCount, + LocalDateTime createdAt, + LocalDateTime updatedAt) { + this.postId = postId; + this.author = author; + this.title = title; + this.categories = categories; + this.likeCount = likeCount; + this.bookmarkCount = bookmarkCount; + this.commentCount = commentCount; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + /** + * 작성자 응답 DTO + */ + @Getter + public static class AuthorResponse { + private final Long id; + private final String nickname; + + @QueryProjection + public AuthorResponse(Long userId, String nickname) { + this.id = userId; + this.nickname = nickname; + } + } + + /** + * 카테고리 응답 DTO + */ + @Getter + public static class CategoryResponse { + private final Long id; + private final String name; + + @QueryProjection + public CategoryResponse(Long id, String name) { + this.id = id; + this.name = name; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/repository/PostRepository.java b/src/main/java/com/back/domain/board/repository/PostRepository.java index 50294f94..32912081 100644 --- a/src/main/java/com/back/domain/board/repository/PostRepository.java +++ b/src/main/java/com/back/domain/board/repository/PostRepository.java @@ -5,5 +5,5 @@ import org.springframework.stereotype.Repository; @Repository -public interface PostRepository extends JpaRepository { +public interface PostRepository extends JpaRepository, PostRepositoryCustom { } diff --git a/src/main/java/com/back/domain/board/repository/PostRepositoryCustom.java b/src/main/java/com/back/domain/board/repository/PostRepositoryCustom.java new file mode 100644 index 00000000..c00c5ba4 --- /dev/null +++ b/src/main/java/com/back/domain/board/repository/PostRepositoryCustom.java @@ -0,0 +1,9 @@ +package com.back.domain.board.repository; + +import com.back.domain.board.dto.PostListResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface PostRepositoryCustom { + Page searchPosts(String keyword, String searchType, Long categoryId, Pageable pageable); +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/repository/PostRepositoryImpl.java b/src/main/java/com/back/domain/board/repository/PostRepositoryImpl.java new file mode 100644 index 00000000..7e6d09f3 --- /dev/null +++ b/src/main/java/com/back/domain/board/repository/PostRepositoryImpl.java @@ -0,0 +1,222 @@ +package com.back.domain.board.repository; + +import com.back.domain.board.dto.PostListResponse; +import com.back.domain.board.dto.QPostListResponse; +import com.back.domain.board.dto.QPostListResponse_AuthorResponse; +import com.back.domain.board.dto.QPostListResponse_CategoryResponse; +import com.back.domain.board.entity.*; +import com.back.domain.user.entity.QUser; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.PathBuilder; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.util.*; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +public class PostRepositoryImpl implements PostRepositoryCustom { + private final JPAQueryFactory queryFactory; + + /** + * 게시글 다건 검색 + * + * @param keyword 검색 키워드 + * @param searchType 검색 타입(title/content/author/전체) + * @param categoryId 카테고리 ID 필터 (nullable) + * @param pageable 페이징 + 정렬 조건 + */ + @Override + public Page searchPosts(String keyword, String searchType, Long categoryId, Pageable pageable) { + // 검색 조건 생성 + BooleanBuilder where = buildWhere(keyword, searchType, categoryId); + + // 정렬 조건 생성 + List> orders = buildOrderSpecifiers(pageable); + + // 메인 게시글 쿼리 실행 + List results = fetchPosts(where, orders, pageable); + + // 카테고리 조회 후 DTO에 주입 + injectCategories(results); + + // 전체 카운트 조회 + long total = countPosts(where, categoryId); + + // 결과를 Page로 감싸서 반환 + return new PageImpl<>(results, pageable, total); + } + + /** + * 검색 조건 생성 + * - keyword + searchType(title/content/author)에 따라 동적 조건 추가 + * - categoryId가 주어지면 카테고리 필터 조건 추가 + */ + private BooleanBuilder buildWhere(String keyword, String searchType, Long categoryId) { + QPost post = QPost.post; + QPostCategoryMapping categoryMapping = QPostCategoryMapping.postCategoryMapping; + + BooleanBuilder where = new BooleanBuilder(); + + // 검색 조건 추가 + if (keyword != null && !keyword.isBlank()) { + switch (searchType) { + case "title" -> where.and(post.title.containsIgnoreCase(keyword)); + case "content" -> where.and(post.content.containsIgnoreCase(keyword)); + case "author" -> where.and(post.user.username.containsIgnoreCase(keyword)); + default -> where.and( + post.title.containsIgnoreCase(keyword) + .or(post.content.containsIgnoreCase(keyword)) + .or(post.user.username.containsIgnoreCase(keyword)) + ); + } + } + + // 카테고리 필터링 + if (categoryId != null) { + where.and(categoryMapping.category.id.eq(categoryId)); + } + + return where; + } + + /** + * 정렬 처리 빌더 + * - Pageable의 Sort 정보 기반으로 OrderSpecifier 생성 + * - likeCount/bookmarkCount/commentCount -> countDistinct() 기준 정렬 + * - 그 외 속성 -> Post 엔티티 필드 기준 정렬 + */ + private List> buildOrderSpecifiers(Pageable pageable) { + QPost post = QPost.post; + QPostLike postLike = QPostLike.postLike; + QPostBookmark postBookmark = QPostBookmark.postBookmark; + QComment comment = QComment.comment; + + List> orders = new ArrayList<>(); + PathBuilder entityPath = new PathBuilder<>(Post.class, post.getMetadata()); + + // 정렬 조건 추가 + for (Sort.Order order : pageable.getSort()) { + Order direction = order.isAscending() ? Order.ASC : Order.DESC; + String prop = order.getProperty(); + switch (prop) { + case "likeCount" -> orders.add(new OrderSpecifier<>(direction, postLike.id.countDistinct())); + case "bookmarkCount" -> orders.add(new OrderSpecifier<>(direction, postBookmark.id.countDistinct())); + case "commentCount" -> orders.add(new OrderSpecifier<>(direction, comment.id.countDistinct())); + default -> + orders.add(new OrderSpecifier<>(direction, entityPath.getComparable(prop, Comparable.class))); + } + } + + return orders; + } + + /** + * 게시글 조회 (메인 쿼리) + * - Post + User join + * - 좋아요, 북마크, 댓글 countDistinct() 집계 + * - groupBy(post.id, user.id, userProfie.nickname) + * - Pageable offset/limit 적용 + */ + private List fetchPosts(BooleanBuilder where, List> orders, Pageable pageable) { + QPost post = QPost.post; + QUser user = QUser.user; + QPostLike postLike = QPostLike.postLike; + QPostBookmark postBookmark = QPostBookmark.postBookmark; + QComment comment = QComment.comment; + + return queryFactory + .select(new QPostListResponse( + post.id, + new QPostListResponse_AuthorResponse(user.id, user.userProfile.nickname), + post.title, + Expressions.constant(Collections.emptyList()), // 카테고리는 나중에 주입 + postLike.id.countDistinct(), + postBookmark.id.countDistinct(), + comment.id.countDistinct(), + post.createdAt, + post.updatedAt + )) + .from(post) + .leftJoin(post.user, user) + .leftJoin(post.postLikes, postLike) + .leftJoin(post.postBookmarks, postBookmark) + .leftJoin(post.comments, comment) + .where(where) + .groupBy(post.id, user.id, user.userProfile.nickname) + .orderBy(orders.toArray(new OrderSpecifier[0])) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + } + + /** + * 카테고리 일괄 조회 & 매핑 + * - postId 목록을 모아 IN 쿼리 실행 + * - 결과를 Map>로 변환 + * - 각 PostListResponse DTO에 categories 주입 + */ + private void injectCategories(List results) { + if (results.isEmpty()) return; + + QPostCategoryMapping categoryMapping = QPostCategoryMapping.postCategoryMapping; + + // postId 목록 생성 + List postIds = results.stream() + .map(PostListResponse::getPostId) + .toList(); + + // 해당하는 카테고리 정보 조회 + List categoryTuples = queryFactory + .select( + categoryMapping.post.id, + new QPostListResponse_CategoryResponse(categoryMapping.category.id, categoryMapping.category.name) + ) + .from(categoryMapping) + .where(categoryMapping.post.id.in(postIds)) + .fetch(); + + // Map>로 변환 + Map> categoryMap = categoryTuples.stream() + .collect(Collectors.groupingBy( + tuple -> Objects.requireNonNull(tuple.get(categoryMapping.post.id)), + Collectors.mapping(t -> t.get(1, PostListResponse.CategoryResponse.class), Collectors.toList()) + )); + + // categories 주입 + results.forEach(r -> r.setCategories(categoryMap.getOrDefault(r.getPostId(), List.of()))); + } + + /** + * 전체 게시글 개수 조회 + * - 조건에 맞는 게시글 총 개수를 가져옴 + * - categoryId 필터가 있으면 postCategoryMapping join 포함 + */ + private long countPosts(BooleanBuilder where, Long categoryId) { + QPost post = QPost.post; + QPostCategoryMapping categoryMapping = QPostCategoryMapping.postCategoryMapping; + + // 카운트 쿼리 + JPAQuery countQuery = queryFactory + .select(post.countDistinct()) + .from(post); + + // 카테고리 필터링 + if (categoryId != null) { + countQuery.leftJoin(post.postCategoryMappings, categoryMapping); + } + + Long total = countQuery.where(where).fetchOne(); + + return total != null ? total : 0L; + } +} diff --git a/src/main/java/com/back/domain/board/service/PostService.java b/src/main/java/com/back/domain/board/service/PostService.java index 906d48ec..6f464448 100644 --- a/src/main/java/com/back/domain/board/service/PostService.java +++ b/src/main/java/com/back/domain/board/service/PostService.java @@ -1,7 +1,6 @@ package com.back.domain.board.service; -import com.back.domain.board.dto.PostRequest; -import com.back.domain.board.dto.PostResponse; +import com.back.domain.board.dto.*; import com.back.domain.board.entity.Post; import com.back.domain.board.entity.PostCategory; import com.back.domain.board.repository.PostCategoryRepository; @@ -11,6 +10,8 @@ import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -53,4 +54,30 @@ public PostResponse createPost(PostRequest request, Long userId) { Post saved = postRepository.save(post); return PostResponse.from(saved); } + + /** + * 게시글 다건 조회 서비스 + * 1. Post 검색 (키워드, 검색타입, 카테고리, 페이징) + * 2. PageResponse 반환 + */ + @Transactional(readOnly = true) + public PageResponse getPosts(String keyword, String searchType, Long categoryId, Pageable pageable) { + Page posts = postRepository.searchPosts(keyword, searchType, categoryId, pageable); + return PageResponse.from(posts); + } + + /** + * 게시글 단건 조회 서비스 + * 1. Post 조회 + * 2. PostResponse 반환 + */ + @Transactional(readOnly = true) + public PostDetailResponse getPost(Long postId) { + // Post 조회 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // 응답 반환 + return PostDetailResponse.from(post); + } } \ No newline at end of file diff --git a/src/main/java/com/back/global/security/SecurityConfig.java b/src/main/java/com/back/global/security/SecurityConfig.java index 64478985..b8fa34e9 100644 --- a/src/main/java/com/back/global/security/SecurityConfig.java +++ b/src/main/java/com/back/global/security/SecurityConfig.java @@ -40,6 +40,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // CORS Preflight 요청 허용 .requestMatchers("/api/auth/**", "/oauth2/**", "/login/oauth2/**").permitAll() .requestMatchers("api/ws/**", "/ws/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll() .requestMatchers("/api/rooms/*/messages/**").permitAll() //스터디 룸 내에 잡혀있어 있는 채팅 관련 전체 허용 //.requestMatchers("/api/rooms/RoomChatApiControllerTest").permitAll() // 테스트용 임시 허용 .requestMatchers("/","/swagger-ui/**", "/v3/api-docs/**").permitAll() // Swagger 허용 diff --git a/src/test/java/com/back/domain/board/controller/PostControllerTest.java b/src/test/java/com/back/domain/board/controller/PostControllerTest.java index 736903ab..2b492959 100644 --- a/src/test/java/com/back/domain/board/controller/PostControllerTest.java +++ b/src/test/java/com/back/domain/board/controller/PostControllerTest.java @@ -1,8 +1,10 @@ package com.back.domain.board.controller; import com.back.domain.board.dto.PostRequest; +import com.back.domain.board.entity.Post; import com.back.domain.board.entity.PostCategory; import com.back.domain.board.repository.PostCategoryRepository; +import com.back.domain.board.repository.PostRepository; import com.back.domain.user.entity.User; import com.back.domain.user.entity.UserProfile; import com.back.domain.user.entity.UserStatus; @@ -24,7 +26,7 @@ import java.time.LocalDate; import java.util.List; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +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.*; @@ -40,6 +42,9 @@ class PostControllerTest { @Autowired private UserRepository userRepository; + @Autowired + private PostRepository postRepository; + @Autowired private PostCategoryRepository postCategoryRepository; @@ -168,4 +173,82 @@ void createPost_badRequest() throws Exception { .andExpect(jsonPath("$.code").value("COMMON_400")) .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")); } + + // ====================== 게시글 조회 테스트 ====================== + + @Test + @DisplayName("게시글 다건 조회 성공 → 200 OK") + void getPosts_success() throws Exception { + // given: 유저 + 카테고리 + 게시글 2개 + User user = User.createUser("reader", "reader@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + PostCategory c1 = new PostCategory("공지사항"); + PostCategory c2 = new PostCategory("자유게시판"); + postCategoryRepository.saveAll(List.of(c1, c2)); + + Post post1 = new Post(user, "첫 글", "내용1"); + post1.updateCategories(List.of(c1)); + postRepository.save(post1); + + Post post2 = new Post(user, "두 번째 글", "내용2"); + post2.updateCategories(List.of(c2)); + postRepository.save(post2); + + // when + mvc.perform(get("/api/posts") + .param("page", "0") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + // then + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.data.items.length()").value(2)) + .andExpect(jsonPath("$.data.items[0].author.nickname").value("홍길동")); + } + + @Test + @DisplayName("게시글 단건 조회 성공 → 200 OK") + void getPost_success() throws Exception { + // given + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "이몽룡", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + PostCategory c1 = new PostCategory("공지사항"); + postCategoryRepository.save(c1); + + Post post = new Post(user, "조회 테스트 글", "조회 테스트 내용"); + post.updateCategories(List.of(c1)); + postRepository.save(post); + + // when + mvc.perform(get("/api/posts/{postId}", post.getId()) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + // then + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.data.postId").value(post.getId())) + .andExpect(jsonPath("$.data.title").value("조회 테스트 글")) + .andExpect(jsonPath("$.data.author.nickname").value("이몽룡")) + .andExpect(jsonPath("$.data.categories[0].name").value("공지사항")); + } + + @Test + @DisplayName("게시글 단건 조회 실패 - 존재하지 않는 게시글 → 404 Not Found") + void getPost_fail_notFound() throws Exception { + mvc.perform(get("/api/posts/{postId}", 999L) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("POST_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 게시글입니다.")); + } } diff --git a/src/test/java/com/back/domain/board/service/PostServiceTest.java b/src/test/java/com/back/domain/board/service/PostServiceTest.java index deb96ae4..41c49fd9 100644 --- a/src/test/java/com/back/domain/board/service/PostServiceTest.java +++ b/src/test/java/com/back/domain/board/service/PostServiceTest.java @@ -1,7 +1,7 @@ package com.back.domain.board.service; -import com.back.domain.board.dto.PostRequest; -import com.back.domain.board.dto.PostResponse; +import com.back.domain.board.dto.*; +import com.back.domain.board.entity.Post; import com.back.domain.board.entity.PostCategory; import com.back.domain.board.repository.PostCategoryRepository; import com.back.domain.board.repository.PostRepository; @@ -15,6 +15,9 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; @@ -95,4 +98,78 @@ void createPost_fail_categoryNotFound() { .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.CATEGORY_NOT_FOUND.getMessage()); } + + // ====================== 게시글 조회 테스트 ====================== + + + @Test + @DisplayName("게시글 다건 조회 성공 - 페이징 + 카테고리") + void getPosts_success() { + // given + User user = User.createUser("writer3", "writer3@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "작성자3", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + PostCategory c1 = new PostCategory("공지사항"); + PostCategory c2 = new PostCategory("자유게시판"); + postCategoryRepository.saveAll(List.of(c1, c2)); + + Post post1 = new Post(user, "첫 번째 글", "내용1"); + post1.updateCategories(List.of(c1)); + postRepository.save(post1); + + Post post2 = new Post(user, "두 번째 글", "내용2"); + post2.updateCategories(List.of(c2)); + postRepository.save(post2); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // when + PageResponse response = postService.getPosts(null, null, null, pageable); + + // then + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(0).getTitle()).isEqualTo("두 번째 글"); + assertThat(response.items().get(1).getTitle()).isEqualTo("첫 번째 글"); + } + + @Test + @DisplayName("게시글 단건 조회 성공") + void getPost_success() { + // given + User user = User.createUser("reader", "reader@example.com", "encodedPwd"); + user.setUserProfile(new UserProfile(user, "독자", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + PostCategory category = new PostCategory("공지"); + postCategoryRepository.save(category); + + Post post = new Post(user, "조회용 제목", "조회용 내용"); + post.updateCategories(List.of(category)); + postRepository.save(post); + + // when + PostDetailResponse response = postService.getPost(post.getId()); + + // then + assertThat(response.postId()).isEqualTo(post.getId()); + assertThat(response.title()).isEqualTo("조회용 제목"); + assertThat(response.content()).isEqualTo("조회용 내용"); + assertThat(response.author().nickname()).isEqualTo("독자"); + assertThat(response.categories()).extracting("name").containsExactly("공지"); + assertThat(response.likeCount()).isZero(); + assertThat(response.bookmarkCount()).isZero(); + assertThat(response.commentCount()).isZero(); + } + + @Test + @DisplayName("게시글 단건 조회 실패 - 존재하지 않는 게시글") + void getPost_fail_postNotFound() { + // when & then + assertThatThrownBy(() -> postService.getPost(999L)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.POST_NOT_FOUND.getMessage()); + } }