diff --git a/src/main/java/com/back/domain/board/comment/dto/MyCommentResponse.java b/src/main/java/com/back/domain/board/comment/dto/MyCommentResponse.java new file mode 100644 index 00000000..1e697459 --- /dev/null +++ b/src/main/java/com/back/domain/board/comment/dto/MyCommentResponse.java @@ -0,0 +1,55 @@ +package com.back.domain.board.comment.dto; + +import com.back.domain.board.comment.entity.Comment; + +import java.time.LocalDateTime; + +/** + * 내 댓글 목록 응답 DTO + * + * @param commentId 댓글 ID + * @param postId 게시글 ID + * @param postTitle 게시글 제목 + * @param parentId 부모 댓글 ID + * @param parentContent 부모 댓글 내용 (50자) + * @param content 댓글 내용 + * @param likeCount 좋아요 수 + * @param createdAt 댓글 생성 일시 + * @param updatedAt 댓글 수정 일시 + */ +public record MyCommentResponse( + Long commentId, + Long postId, + String postTitle, + Long parentId, + String parentContent, + String content, + long likeCount, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static MyCommentResponse from(Comment comment) { + return new MyCommentResponse( + comment.getId(), + comment.getPost().getId(), + comment.getPost().getTitle(), + comment.getParent() != null + ? comment.getParent().getId() + : null, + comment.getParent() != null + ? truncate(comment.getParent().getContent()) + : null, + comment.getContent(), + comment.getLikeCount(), + comment.getCreatedAt(), + comment.getUpdatedAt() + ); + } + + private static String truncate(String content) { + int length = 50; + return (content == null || content.length() <= length) + ? content + : content.substring(0, length) + "..."; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/comment/repository/CommentRepository.java b/src/main/java/com/back/domain/board/comment/repository/CommentRepository.java index 72004816..2b008a16 100644 --- a/src/main/java/com/back/domain/board/comment/repository/CommentRepository.java +++ b/src/main/java/com/back/domain/board/comment/repository/CommentRepository.java @@ -2,9 +2,12 @@ import com.back.domain.board.comment.entity.Comment; import com.back.domain.board.comment.repository.custom.CommentRepositoryCustom; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { + Page findAllByUserId(Long userId, Pageable pageable); } diff --git a/src/main/java/com/back/domain/board/post/dto/PostListResponse.java b/src/main/java/com/back/domain/board/post/dto/PostListResponse.java index 20a5779a..338c6f4d 100644 --- a/src/main/java/com/back/domain/board/post/dto/PostListResponse.java +++ b/src/main/java/com/back/domain/board/post/dto/PostListResponse.java @@ -1,6 +1,7 @@ package com.back.domain.board.post.dto; import com.back.domain.board.common.dto.AuthorResponse; +import com.back.domain.board.post.entity.Post; import com.querydsl.core.annotations.QueryProjection; import lombok.Getter; import lombok.Setter; @@ -49,4 +50,21 @@ public PostListResponse(Long postId, this.createdAt = createdAt; this.updatedAt = updatedAt; } + + public static PostListResponse from(Post post) { + return new PostListResponse( + post.getId(), + AuthorResponse.from(post.getUser()), + post.getTitle(), + post.getThumbnailUrl(), + post.getCategories().stream() + .map(CategoryResponse::from) + .toList(), + post.getLikeCount(), + post.getBookmarkCount(), + post.getCommentCount(), + post.getCreatedAt(), + post.getUpdatedAt() + ); + } } \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/post/entity/Post.java b/src/main/java/com/back/domain/board/post/entity/Post.java index 30c34d02..1365c631 100644 --- a/src/main/java/com/back/domain/board/post/entity/Post.java +++ b/src/main/java/com/back/domain/board/post/entity/Post.java @@ -93,6 +93,7 @@ public void update(String title, String content) { this.content = content; } + // TODO: 진짜로 바뀐 카테고리만 추가/삭제하도록 개선 /** 카테고리 일괄 업데이트 */ public void updateCategories(List categories) { this.postCategoryMappings.clear(); diff --git a/src/main/java/com/back/domain/board/post/repository/PostBookmarkRepository.java b/src/main/java/com/back/domain/board/post/repository/PostBookmarkRepository.java index b26b2a77..ba8c2469 100644 --- a/src/main/java/com/back/domain/board/post/repository/PostBookmarkRepository.java +++ b/src/main/java/com/back/domain/board/post/repository/PostBookmarkRepository.java @@ -1,6 +1,8 @@ package com.back.domain.board.post.repository; import com.back.domain.board.post.entity.PostBookmark; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -10,4 +12,5 @@ public interface PostBookmarkRepository extends JpaRepository { boolean existsByUserIdAndPostId(Long userId, Long postId); Optional findByUserIdAndPostId(Long userId, Long postId); + Page findAllByUserId(Long userId, Pageable pageable); } diff --git a/src/main/java/com/back/domain/board/post/repository/PostRepository.java b/src/main/java/com/back/domain/board/post/repository/PostRepository.java index 7aafead2..9d338f4f 100644 --- a/src/main/java/com/back/domain/board/post/repository/PostRepository.java +++ b/src/main/java/com/back/domain/board/post/repository/PostRepository.java @@ -2,9 +2,12 @@ import com.back.domain.board.post.entity.Post; import com.back.domain.board.post.repository.custom.PostRepositoryCustom; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface PostRepository extends JpaRepository, PostRepositoryCustom { + Page findAllByUserId(Long userId, Pageable pageable); } diff --git a/src/main/java/com/back/domain/user/controller/UserController.java b/src/main/java/com/back/domain/user/controller/UserController.java index 5a1f6a86..79abf699 100644 --- a/src/main/java/com/back/domain/user/controller/UserController.java +++ b/src/main/java/com/back/domain/user/controller/UserController.java @@ -1,5 +1,8 @@ package com.back.domain.user.controller; +import com.back.domain.board.comment.dto.MyCommentResponse; +import com.back.domain.board.common.dto.PageResponse; +import com.back.domain.board.post.dto.PostListResponse; import com.back.domain.user.dto.ChangePasswordRequest; import com.back.domain.user.dto.UpdateUserProfileRequest; import com.back.domain.user.dto.UserDetailResponse; @@ -8,6 +11,9 @@ 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.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -20,7 +26,7 @@ public class UserController implements UserControllerDocs { // 내 정보 조회 @GetMapping("/me") - public ResponseEntity> getMyInfo ( + public ResponseEntity> getMyInfo( @AuthenticationPrincipal CustomUserDetails user ) { UserDetailResponse userDetail = userService.getUserInfo(user.getUserId()); @@ -69,4 +75,46 @@ public ResponseEntity> deleteMyAccount( "회원 탈퇴가 완료되었습니다." )); } + + // 내 게시글 목록 조회 + @GetMapping("/me/posts") + public ResponseEntity>> getMyPosts( + @AuthenticationPrincipal CustomUserDetails user, + @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + PageResponse response = userService.getMyPosts(user.getUserId(), pageable); + return ResponseEntity + .ok(RsData.success( + "내 게시글 목록이 조회되었습니다.", + response + )); + } + + // 내 댓글 목록 조회 + @GetMapping("/me/comments") + public ResponseEntity>> getMyComments( + @AuthenticationPrincipal CustomUserDetails user, + @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + PageResponse response = userService.getMyComments(user.getUserId(), pageable); + return ResponseEntity + .ok(RsData.success( + "내 댓글 목록이 조회되었습니다.", + response + )); + } + + // 내 북마크 게시글 목록 조회 + @GetMapping("/me/bookmarks") + public ResponseEntity>> getMyBookmarks( + @AuthenticationPrincipal CustomUserDetails user, + @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + PageResponse response = userService.getMyBookmarks(user.getUserId(), pageable); + return ResponseEntity + .ok(RsData.success( + "내 북마크 게시글 목록이 조회되었습니다.", + response + )); + } } diff --git a/src/main/java/com/back/domain/user/controller/UserControllerDocs.java b/src/main/java/com/back/domain/user/controller/UserControllerDocs.java index a282d385..8536d780 100644 --- a/src/main/java/com/back/domain/user/controller/UserControllerDocs.java +++ b/src/main/java/com/back/domain/user/controller/UserControllerDocs.java @@ -1,5 +1,8 @@ package com.back.domain.user.controller; +import com.back.domain.board.comment.dto.MyCommentResponse; +import com.back.domain.board.common.dto.PageResponse; +import com.back.domain.board.post.dto.PostListResponse; import com.back.domain.user.dto.ChangePasswordRequest; import com.back.domain.user.dto.UpdateUserProfileRequest; import com.back.domain.user.dto.UserDetailResponse; @@ -12,6 +15,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.RequestBody; @@ -317,13 +322,13 @@ ResponseEntity> updateMyProfile( content = @Content( mediaType = "application/json", examples = @ExampleObject(value = """ - { - "success": true, - "code": "SUCCESS_200", - "message": "비밀번호가 변경되었습니다.", - "data": null - } - """) + { + "success": true, + "code": "SUCCESS_200", + "message": "비밀번호가 변경되었습니다.", + "data": null + } + """) ) ), @ApiResponse( @@ -332,13 +337,13 @@ ResponseEntity> updateMyProfile( content = @Content( mediaType = "application/json", examples = @ExampleObject(value = """ - { - "success": false, - "code": "USER_005", - "message": "비밀번호는 최소 8자 이상, 숫자/특수문자를 포함해야 합니다.", - "data": null - } - """) + { + "success": false, + "code": "USER_005", + "message": "비밀번호는 최소 8자 이상, 숫자/특수문자를 포함해야 합니다.", + "data": null + } + """) ) ), @ApiResponse( @@ -347,13 +352,13 @@ ResponseEntity> updateMyProfile( content = @Content( mediaType = "application/json", examples = @ExampleObject(value = """ - { - "success": false, - "code": "USER_010", - "message": "소셜 로그인 회원은 비밀번호를 변경할 수 없습니다.", - "data": null - } - """) + { + "success": false, + "code": "USER_010", + "message": "소셜 로그인 회원은 비밀번호를 변경할 수 없습니다.", + "data": null + } + """) ) ), @ApiResponse( @@ -362,13 +367,13 @@ ResponseEntity> updateMyProfile( content = @Content( mediaType = "application/json", examples = @ExampleObject(value = """ - { - "success": false, - "code": "USER_008", - "message": "정지된 계정입니다. 관리자에게 문의하세요.", - "data": null - } - """) + { + "success": false, + "code": "USER_008", + "message": "정지된 계정입니다. 관리자에게 문의하세요.", + "data": null + } + """) ) ), @ApiResponse( @@ -377,13 +382,13 @@ ResponseEntity> updateMyProfile( content = @Content( mediaType = "application/json", examples = @ExampleObject(value = """ - { - "success": false, - "code": "USER_009", - "message": "탈퇴한 계정입니다.", - "data": null - } - """) + { + "success": false, + "code": "USER_009", + "message": "탈퇴한 계정입니다.", + "data": null + } + """) ) ), @ApiResponse( @@ -393,37 +398,37 @@ ResponseEntity> updateMyProfile( mediaType = "application/json", examples = { @ExampleObject(name = "토큰 없음", value = """ - { - "success": false, - "code": "AUTH_001", - "message": "인증이 필요합니다.", - "data": null - } - """), + { + "success": false, + "code": "AUTH_001", + "message": "인증이 필요합니다.", + "data": null + } + """), @ExampleObject(name = "잘못된 토큰", value = """ - { - "success": false, - "code": "AUTH_002", - "message": "유효하지 않은 액세스 토큰입니다.", - "data": null - } - """), + { + "success": false, + "code": "AUTH_002", + "message": "유효하지 않은 액세스 토큰입니다.", + "data": null + } + """), @ExampleObject(name = "만료된 토큰", value = """ - { - "success": false, - "code": "AUTH_004", - "message": "만료된 액세스 토큰입니다.", - "data": null - } - """), + { + "success": false, + "code": "AUTH_004", + "message": "만료된 액세스 토큰입니다.", + "data": null + } + """), @ExampleObject(name = "현재 비밀번호 불일치", value = """ - { - "success": false, - "code": "USER_006", - "message": "아이디 또는 비밀번호가 올바르지 않습니다.", - "data": null - } - """) + { + "success": false, + "code": "USER_006", + "message": "아이디 또는 비밀번호가 올바르지 않습니다.", + "data": null + } + """) } ) ), @@ -433,13 +438,13 @@ ResponseEntity> updateMyProfile( content = @Content( mediaType = "application/json", examples = @ExampleObject(value = """ - { - "success": false, - "code": "USER_001", - "message": "존재하지 않는 사용자입니다.", - "data": null - } - """) + { + "success": false, + "code": "USER_001", + "message": "존재하지 않는 사용자입니다.", + "data": null + } + """) ) ), @ApiResponse( @@ -448,13 +453,13 @@ ResponseEntity> updateMyProfile( content = @Content( mediaType = "application/json", examples = @ExampleObject(value = """ - { - "success": false, - "code": "COMMON_500", - "message": "서버 오류가 발생했습니다.", - "data": null - } - """) + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) ) ) }) @@ -581,4 +586,516 @@ ResponseEntity> changeMyPassword( ResponseEntity> deleteMyAccount( @AuthenticationPrincipal CustomUserDetails user ); + + @Operation( + summary = "내 게시글 목록 조회", + description = """ + 로그인한 사용자가 작성한 게시글 목록을 조회합니다. + - 기본 정렬: createdAt,desc + - 페이지 및 정렬 조건은 Query Parameter로 조정 가능합니다. + """ + ) + @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": "홍길동", "profileImageUrl": null }, + "title": "첫 글", + "thumbnailUrl": null, + "categories": [ + { "id": 1, "name": "프론트엔드", "type": "SUBJECT" } + ], + "likeCount": 5, + "bookmarkCount": 2, + "commentCount": 3, + "createdAt": "2025-09-30T10:15:30", + "updatedAt": "2025-09-30T10:20:00" + }, + { + "postId": 2, + "author": { "id": 10, "nickname": "홍길동", "profileImageUrl": null }, + "title": "두 번째 글", + "thumbnailUrl": null, + "categories": [], + "likeCount": 0, + "bookmarkCount": 0, + "commentCount": 1, + "createdAt": "2025-09-29T14:00:00", + "updatedAt": "2025-09-29T14:10:00" + } + ], + "page": 0, + "size": 10, + "totalElements": 25, + "totalPages": 3, + "last": false + } + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 사용자", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_001", + "message": "존재하지 않는 사용자입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "410", + description = "탈퇴한 계정", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_009", + "message": "탈퇴한 계정입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "403", + description = "정지된 계정", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_008", + "message": "정지된 계정입니다. 관리자에게 문의하세요.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (토큰 없음/잘못됨/만료)", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "토큰 없음", value = """ + { + "success": false, + "code": "AUTH_001", + "message": "인증이 필요합니다.", + "data": null + } + """), + @ExampleObject(name = "잘못된 토큰", value = """ + { + "success": false, + "code": "AUTH_002", + "message": "유효하지 않은 액세스 토큰입니다.", + "data": null + } + """), + @ExampleObject(name = "만료된 토큰", value = """ + { + "success": false, + "code": "AUTH_004", + "message": "만료된 액세스 토큰입니다.", + "data": null + } + """) + } + ) + ), + @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>> getMyPosts( + @AuthenticationPrincipal CustomUserDetails user, + @ParameterObject Pageable pageable + ); + + @Operation( + summary = "내 댓글 목록 조회", + description = """ + 로그인한 사용자가 작성한 댓글 목록을 조회합니다. + - 기본 정렬: createdAt,desc + - 페이지 및 정렬 조건은 Query Parameter로 조정 가능합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "내 댓글 목록 조회 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "내 댓글 목록이 조회되었습니다.", + "data": { + "items": [ + { + "commentId": 12, + "postId": 5, + "postTitle": "스프링 트랜잭션 정리", + "parentId": null, + "parentContent": null, + "content": "정말 도움이 많이 됐어요!", + "likeCount": 3, + "createdAt": "2025-09-29T12:15:00", + "updatedAt": "2025-09-29T12:30:00" + }, + { + "commentId": 14, + "postId": 5, + "postTitle": "스프링 트랜잭션 정리", + "parentId": 13, + "parentContent": "코딩 박사의 스프링 교재도 추천합니다.", + "content": "감사합니다! 더 공부해볼게요.", + "likeCount": 1, + "createdAt": "2025-09-29T12:45:00", + "updatedAt": "2025-09-29T12:45:00" + } + ], + "page": 0, + "size": 10, + "totalElements": 2, + "totalPages": 1, + "last": true + } + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 사용자", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_001", + "message": "존재하지 않는 사용자입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "410", + description = "탈퇴한 계정", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_009", + "message": "탈퇴한 계정입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "403", + description = "정지된 계정", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_008", + "message": "정지된 계정입니다. 관리자에게 문의하세요.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (토큰 없음/잘못됨/만료)", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "토큰 없음", value = """ + { + "success": false, + "code": "AUTH_001", + "message": "인증이 필요합니다.", + "data": null + } + """), + @ExampleObject(name = "잘못된 토큰", value = """ + { + "success": false, + "code": "AUTH_002", + "message": "유효하지 않은 액세스 토큰입니다.", + "data": null + } + """), + @ExampleObject(name = "만료된 토큰", value = """ + { + "success": false, + "code": "AUTH_004", + "message": "만료된 액세스 토큰입니다.", + "data": null + } + """) + } + ) + ), + @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>> getMyComments( + @AuthenticationPrincipal CustomUserDetails user, + @ParameterObject Pageable pageable + ); + + @Operation( + summary = "내 북마크 게시글 목록 조회", + description = """ + 로그인한 사용자가 북마크한 게시글 목록을 조회합니다. + - 기본 정렬: createdAt,desc + - 페이지 및 정렬 조건은 Query Parameter로 조정 가능합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "내 북마크 게시글 목록 조회 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "내 북마크 게시글 목록이 조회되었습니다.", + "data": { + "items": [ + { + "postId": 22, + "author": { "id": 3, "nickname": "홍길동", "profileImageUrl": null }, + "title": "JPA 영속성 전이 완벽 정리", + "thumbnailUrl": "https://cdn.example.com/thumbnails/jpa.png", + "categories": [ + { "id": 2, "name": "백엔드", "type": "SUBJECT" } + ], + "likeCount": 12, + "bookmarkCount": 7, + "commentCount": 3, + "createdAt": "2025-09-28T11:20:00", + "updatedAt": "2025-09-28T12:00:00" + }, + { + "postId": 10, + "author": { "id": 7, "nickname": "이자바", "profileImageUrl": null }, + "title": "테스트 코드 작성 가이드", + "thumbnailUrl": null, + "categories": [], + "likeCount": 2, + "bookmarkCount": 1, + "commentCount": 0, + "createdAt": "2025-09-25T09:10:00", + "updatedAt": "2025-09-25T09:10:00" + } + ], + "page": 0, + "size": 10, + "totalElements": 2, + "totalPages": 1, + "last": true + } + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 사용자", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_001", + "message": "존재하지 않는 사용자입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "410", + description = "탈퇴한 계정", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_009", + "message": "탈퇴한 계정입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "403", + description = "정지된 계정", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_008", + "message": "정지된 계정입니다. 관리자에게 문의하세요.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (토큰 없음/잘못됨/만료)", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "토큰 없음", value = """ + { + "success": false, + "code": "AUTH_001", + "message": "인증이 필요합니다.", + "data": null + } + """), + @ExampleObject(name = "잘못된 토큰", value = """ + { + "success": false, + "code": "AUTH_002", + "message": "유효하지 않은 액세스 토큰입니다.", + "data": null + } + """), + @ExampleObject(name = "만료된 토큰", value = """ + { + "success": false, + "code": "AUTH_004", + "message": "만료된 액세스 토큰입니다.", + "data": null + } + """) + } + ) + ), + @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>> getMyBookmarks( + @AuthenticationPrincipal CustomUserDetails user, + @ParameterObject Pageable pageable + ); } diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index 8568655c..33051f45 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -1,5 +1,11 @@ package com.back.domain.user.service; +import com.back.domain.board.comment.dto.MyCommentResponse; +import com.back.domain.board.comment.repository.CommentRepository; +import com.back.domain.board.common.dto.PageResponse; +import com.back.domain.board.post.dto.PostListResponse; +import com.back.domain.board.post.repository.PostBookmarkRepository; +import com.back.domain.board.post.repository.PostRepository; import com.back.domain.user.dto.ChangePasswordRequest; import com.back.domain.user.dto.UpdateUserProfileRequest; import com.back.domain.user.dto.UserDetailResponse; @@ -12,16 +18,23 @@ import com.back.global.exception.ErrorCode; import com.back.global.util.PasswordValidator; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @RequiredArgsConstructor @Transactional public class UserService { private final UserRepository userRepository; private final UserProfileRepository userProfileRepository; + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final PostBookmarkRepository postBookmarkRepository; private final PasswordEncoder passwordEncoder; /** @@ -124,6 +137,67 @@ public void deleteUser(Long userId) { } } + // TODO: 내 게시글/댓글/북마크 목록 조회 N+1 발생 가능, 추후 리팩토링 필요 + /** + * 내 게시글 목록 조회 서비스 + * 1. 사용자 조회 및 상태 검증 + * 2. 게시글 목록 조회 + * 3. PageResponse 반환 + */ + @Transactional(readOnly = true) + public PageResponse getMyPosts(Long userId, Pageable pageable) { + + // 사용자 조회 및 상태 검증 + User user = getValidUser(userId); + + // 게시글 목록 조회 + Page page = postRepository.findAllByUserId(userId, pageable) + .map(PostListResponse::from); + + // 페이지 응답 반환 + return PageResponse.from(page); + } + + /** + * 내 댓글 목록 조회 서비스 + * 1. 사용자 조회 및 상태 검증 + * 2. 댓글 목록 조회 + * 3. PageResponse 반환 + */ + @Transactional(readOnly = true) + public PageResponse getMyComments(Long userId, Pageable pageable) { + + // 사용자 조회 및 상태 검증 + User user = getValidUser(userId); + + // 댓글 목록 조회 + Page page = commentRepository.findAllByUserId(user.getId(), pageable) + .map(MyCommentResponse::from); + + // 페이지 응답 반환 + return PageResponse.from(page); + } + + /** + * 내 북마크 게시글 목록 조회 서비스 + * 1. 사용자 조회 및 상태 검증 + * 2. 북마크 목록 조회 + * 3. PageResponse 반환 + */ + @Transactional(readOnly = true) + public PageResponse getMyBookmarks(Long userId, Pageable pageable) { + + // 사용자 검증 + User user = getValidUser(userId); + + // 북마크된 게시글 조회 + Page page = postBookmarkRepository.findAllByUserId(user.getId(), pageable) + .map(bookmark -> PostListResponse.from(bookmark.getPost())); + + // 페이지 응답 반환 + return PageResponse.from(page); + } + /** * 유효한 사용자 조회 및 상태 검증 * diff --git a/src/test/java/com/back/domain/user/controller/UserControllerTest.java b/src/test/java/com/back/domain/user/controller/UserControllerTest.java index 0fc67bfb..af50fed8 100644 --- a/src/test/java/com/back/domain/user/controller/UserControllerTest.java +++ b/src/test/java/com/back/domain/user/controller/UserControllerTest.java @@ -1,5 +1,11 @@ package com.back.domain.user.controller; +import com.back.domain.board.comment.entity.Comment; +import com.back.domain.board.comment.repository.CommentRepository; +import com.back.domain.board.post.entity.Post; +import com.back.domain.board.post.entity.PostBookmark; +import com.back.domain.board.post.repository.PostBookmarkRepository; +import com.back.domain.board.post.repository.PostRepository; import com.back.domain.user.dto.ChangePasswordRequest; import com.back.domain.user.dto.UpdateUserProfileRequest; import com.back.domain.user.entity.User; @@ -21,6 +27,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; @@ -41,6 +48,15 @@ class UserControllerTest { @Autowired private UserRepository userRepository; + @Autowired + private PostRepository postRepository; + + @Autowired + private PostBookmarkRepository postBookmarkRepository; + + @Autowired + private CommentRepository commentRepository; + @Autowired private TestJwtTokenProvider testJwtTokenProvider; @@ -652,4 +668,417 @@ void deleteMyAccount_expiredAccessToken() throws Exception { .andExpect(jsonPath("$.code").value("AUTH_004")) .andExpect(jsonPath("$.message").value("만료된 액세스 토큰입니다.")); } + + // ====================== 내 게시글 목록 조회 테스트 ====================== + + @Test + @DisplayName("내 게시글 목록 조회 성공 → 200 OK") + void getMyPosts_success() throws Exception { + // given: 정상 유저 + 게시글 2개 생성 + 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); + + Post post1 = new Post(user, "첫 번째 글", "내용1", null); + Post post2 = new Post(user, "두 번째 글", "내용2", null); + postRepository.saveAll(List.of(post1, post2)); + + String accessToken = generateAccessToken(user); + + // when + ResultActions resultActions = mvc.perform( + get("/api/users/me/posts") + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10") + ).andDo(print()); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("내 게시글 목록이 조회되었습니다.")) + .andExpect(jsonPath("$.data.items").isArray()) + .andExpect(jsonPath("$.data.items.length()").value(2)) + .andExpect(jsonPath("$.data.items[0].title").value("두 번째 글")) // 최신순(createdAt desc) + .andExpect(jsonPath("$.data.items[1].title").value("첫 번째 글")); + } + + @Test + @DisplayName("존재하지 않는 사용자 → 404 Not Found") + void getMyPosts_userNotFound() throws Exception { + // given: 존재하지 않는 ID로 JWT 발급 + String fakeToken = testJwtTokenProvider.createAccessToken(999L, "ghost", "USER"); + + // when & then + mvc.perform( + get("/api/users/me/posts") + .header("Authorization", "Bearer " + fakeToken) + ) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("USER_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 사용자입니다.")); + } + + @Test + @DisplayName("탈퇴한 계정 → 410 Gone") + void getMyPosts_deletedUser() throws Exception { + // given + User user = User.createUser("deleted", "deleted@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.DELETED); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(get("/api/users/me/posts") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isGone()) + .andExpect(jsonPath("$.code").value("USER_009")) + .andExpect(jsonPath("$.message").value("탈퇴한 계정입니다.")); + } + + @Test + @DisplayName("정지된 계정 → 403 Forbidden") + void getMyPosts_suspendedUser() throws Exception { + // given + User user = User.createUser("suspended", "suspended@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.SUSPENDED); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(get("/api/users/me/posts") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("USER_008")) + .andExpect(jsonPath("$.message").value("정지된 계정입니다. 관리자에게 문의하세요.")); + } + + @Test + @DisplayName("AccessToken 없음 → 401 Unauthorized") + void getMyPosts_noAccessToken() throws Exception { + // when & then + mvc.perform(get("/api/users/me/posts")) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } + + @Test + @DisplayName("잘못된 AccessToken → 401 Unauthorized") + void getMyPosts_invalidAccessToken() throws Exception { + mvc.perform(get("/api/users/me/posts") + .header("Authorization", "Bearer invalidToken")) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_002")) + .andExpect(jsonPath("$.message").value("유효하지 않은 액세스 토큰입니다.")); + } + + @Test + @DisplayName("만료된 AccessToken → 401 Unauthorized") + void getMyPosts_expiredAccessToken() throws Exception { + // given + User user = User.createUser("expired", "expired@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String expiredToken = testJwtTokenProvider.createExpiredAccessToken(user.getId(), user.getUsername(), user.getRole().name()); + + // when & then + mvc.perform(get("/api/users/me/posts") + .header("Authorization", "Bearer " + expiredToken)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_004")) + .andExpect(jsonPath("$.message").value("만료된 액세스 토큰입니다.")); + } + + // ====================== 내 댓글 목록 조회 테스트 ====================== + + @Test + @DisplayName("내 댓글 목록 조회 성공 → 200 OK") + void getMyComments_success() throws Exception { + // given: 정상 유저 + 게시글 + 댓글 2개 생성 + User user = User.createUser("commenter", "commenter@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "스프링 트랜잭션 정리", "내용입니다.", null); + postRepository.save(post); + + Comment parent = new Comment(post, user, "코딩 박사의 스프링 교재도 추천합니다.", null); + Comment comment1 = new Comment(post, user, "정말 도움이 많이 됐어요!", null); + Comment comment2 = new Comment(post, user, "감사합니다! 더 공부해볼게요.", parent); + commentRepository.saveAll(List.of(parent, comment1, comment2)); + + String accessToken = generateAccessToken(user); + + // when + ResultActions resultActions = mvc.perform( + get("/api/users/me/comments") + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10") + ).andDo(print()); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("내 댓글 목록이 조회되었습니다.")) + .andExpect(jsonPath("$.data.items").isArray()) + .andExpect(jsonPath("$.data.items.length()").value(3)) + .andExpect(jsonPath("$.data.items[0].content").value("감사합니다! 더 공부해볼게요.")) + .andExpect(jsonPath("$.data.items[1].content").value("정말 도움이 많이 됐어요!")); + } + + @Test + @DisplayName("존재하지 않는 사용자 → 404 Not Found") + void getMyComments_userNotFound() throws Exception { + // given + String fakeToken = testJwtTokenProvider.createAccessToken(999L, "ghost", "USER"); + + // when & then + mvc.perform(get("/api/users/me/comments") + .header("Authorization", "Bearer " + fakeToken)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("USER_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 사용자입니다.")); + } + + @Test + @DisplayName("탈퇴한 계정 → 410 Gone") + void getMyComments_deletedUser() throws Exception { + // given + User user = User.createUser("deleted", "deleted@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.DELETED); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(get("/api/users/me/comments") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isGone()) + .andExpect(jsonPath("$.code").value("USER_009")) + .andExpect(jsonPath("$.message").value("탈퇴한 계정입니다.")); + } + + @Test + @DisplayName("정지된 계정 → 403 Forbidden") + void getMyComments_suspendedUser() throws Exception { + // given + User user = User.createUser("suspended", "suspended@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.SUSPENDED); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(get("/api/users/me/comments") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("USER_008")) + .andExpect(jsonPath("$.message").value("정지된 계정입니다. 관리자에게 문의하세요.")); + } + + @Test + @DisplayName("AccessToken 없음 → 401 Unauthorized") + void getMyComments_noAccessToken() throws Exception { + // when & then + mvc.perform(get("/api/users/me/comments")) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } + + @Test + @DisplayName("잘못된 AccessToken → 401 Unauthorized") + void getMyComments_invalidAccessToken() throws Exception { + // when & then + mvc.perform(get("/api/users/me/comments") + .header("Authorization", "Bearer invalidToken")) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_002")) + .andExpect(jsonPath("$.message").value("유효하지 않은 액세스 토큰입니다.")); + } + + @Test + @DisplayName("만료된 AccessToken → 401 Unauthorized") + void getMyComments_expiredAccessToken() throws Exception { + // given + User user = User.createUser("expired", "expired@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String expiredToken = testJwtTokenProvider.createExpiredAccessToken(user.getId(), user.getUsername(), user.getRole().name()); + + // when & then + mvc.perform(get("/api/users/me/comments") + .header("Authorization", "Bearer " + expiredToken)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_004")) + .andExpect(jsonPath("$.message").value("만료된 액세스 토큰입니다.")); + } + + // ====================== 내 북마크 게시글 목록 조회 테스트 ====================== + + @Test + @DisplayName("내 북마크 게시글 목록 조회 성공 → 200 OK") + void getMyBookmarks_success() throws Exception { + // given + User user = User.createUser("bookmarkUser", "bookmark@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post1 = new Post(user, "JPA 영속성 전이 완벽 정리", "내용1", null); + Post post2 = new Post(user, "테스트 코드 작성 가이드", "내용2", null); + postRepository.saveAll(List.of(post1, post2)); + + PostBookmark bookmark1 = new PostBookmark(post1, user); + PostBookmark bookmark2 = new PostBookmark(post2, user); + postBookmarkRepository.saveAll(List.of(bookmark1, bookmark2)); + + String accessToken = generateAccessToken(user); + + // when + ResultActions resultActions = mvc.perform( + get("/api/users/me/bookmarks") + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10") + ) + .andDo(print()); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("내 북마크 게시글 목록이 조회되었습니다.")) + .andExpect(jsonPath("$.data.items.length()").value(2)) + .andExpect(jsonPath("$.data.items[0].title").value("테스트 코드 작성 가이드")) + .andExpect(jsonPath("$.data.items[1].title").value("JPA 영속성 전이 완벽 정리")); + } + + @Test + @DisplayName("존재하지 않는 사용자 → 404 Not Found") + void getMyBookmarks_userNotFound() throws Exception { + // given + String fakeToken = testJwtTokenProvider.createAccessToken(999L, "ghost", "USER"); + + // when & then + mvc.perform(get("/api/users/me/bookmarks") + .header("Authorization", "Bearer " + fakeToken)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("USER_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 사용자입니다.")); + } + + @Test + @DisplayName("탈퇴한 계정 → 410 Gone") + void getMyBookmarks_deletedUser() throws Exception { + // given + User user = User.createUser("deleted", "deleted@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.DELETED); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(get("/api/users/me/bookmarks") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isGone()) + .andExpect(jsonPath("$.code").value("USER_009")) + .andExpect(jsonPath("$.message").value("탈퇴한 계정입니다.")); + } + + @Test + @DisplayName("정지된 계정 → 403 Forbidden") + void getMyBookmarks_suspendedUser() throws Exception { + // given + User user = User.createUser("suspended", "suspended@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.SUSPENDED); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(get("/api/users/me/bookmarks") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("USER_008")) + .andExpect(jsonPath("$.message").value("정지된 계정입니다. 관리자에게 문의하세요.")); + } + + @Test + @DisplayName("AccessToken 없음 → 401 Unauthorized") + void getMyBookmarks_noAccessToken() throws Exception { + // when & then + mvc.perform(get("/api/users/me/bookmarks")) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } + + @Test + @DisplayName("잘못된 AccessToken → 401 Unauthorized") + void getMyBookmarks_invalidAccessToken() throws Exception { + // when & then + mvc.perform(get("/api/users/me/bookmarks") + .header("Authorization", "Bearer invalidToken")) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_002")) + .andExpect(jsonPath("$.message").value("유효하지 않은 액세스 토큰입니다.")); + } + + @Test + @DisplayName("만료된 AccessToken → 401 Unauthorized") + void getMyBookmarks_expiredAccessToken() throws Exception { + // given + User user = User.createUser("expired", "expired@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String expiredToken = testJwtTokenProvider.createExpiredAccessToken(user.getId(), user.getUsername(), user.getRole().name()); + + // when & then + mvc.perform(get("/api/users/me/bookmarks") + .header("Authorization", "Bearer " + expiredToken)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_004")) + .andExpect(jsonPath("$.message").value("만료된 액세스 토큰입니다.")); + } } diff --git a/src/test/java/com/back/domain/user/service/UserServiceTest.java b/src/test/java/com/back/domain/user/service/UserServiceTest.java index a4ead658..ab64a894 100644 --- a/src/test/java/com/back/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/back/domain/user/service/UserServiceTest.java @@ -1,5 +1,14 @@ package com.back.domain.user.service; +import com.back.domain.board.comment.dto.MyCommentResponse; +import com.back.domain.board.comment.entity.Comment; +import com.back.domain.board.comment.repository.CommentRepository; +import com.back.domain.board.common.dto.PageResponse; +import com.back.domain.board.post.dto.PostListResponse; +import com.back.domain.board.post.entity.Post; +import com.back.domain.board.post.entity.PostBookmark; +import com.back.domain.board.post.repository.PostBookmarkRepository; +import com.back.domain.board.post.repository.PostRepository; import com.back.domain.user.dto.ChangePasswordRequest; import com.back.domain.user.dto.UpdateUserProfileRequest; import com.back.domain.user.dto.UserDetailResponse; @@ -14,11 +23,15 @@ 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.security.crypto.password.PasswordEncoder; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -35,7 +48,13 @@ class UserServiceTest { private UserRepository userRepository; @Autowired - private UserProfileRepository userProfileRepository; + private PostRepository postRepository; + + @Autowired + private PostBookmarkRepository postBookmarkRepository; + + @Autowired + private CommentRepository commentRepository; @Autowired private PasswordEncoder passwordEncoder; @@ -365,4 +384,218 @@ void deleteUser_notFound() { .hasMessage(ErrorCode.USER_NOT_FOUND.getMessage()); } + // ====================== 내 게시글 목록 조회 테스트 ====================== + + @Test + @DisplayName("내 게시글 목록 조회 성공") + void getMyPosts_success() { + // 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); + + // 게시글 2개 작성 + Post post1 = new Post(user, "제목1", "내용1", null); + Post post2 = new Post(user, "제목2", "내용2", null); + postRepository.saveAll(List.of(post1, post2)); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // when + PageResponse response = userService.getMyPosts(user.getId(), pageable); + + // then + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(0).getTitle()).isEqualTo("제목2"); // 최신순 정렬 + assertThat(response.items().get(1).getTitle()).isEqualTo("제목1"); + } + + @Test + @DisplayName("존재하지 않는 사용자 ID → USER_NOT_FOUND 예외 발생") + void getMyPosts_userNotFound() { + // given + Pageable pageable = PageRequest.of(0, 10); + + // when & then + assertThatThrownBy(() -> userService.getMyPosts(999L, pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("탈퇴된 사용자 → USER_DELETED 예외 발생") + void getMyPosts_deletedUser() { + // given + User user = User.createUser("deleted", "deleted@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.DELETED); + userRepository.save(user); + + Pageable pageable = PageRequest.of(0, 10); + + // when & then + assertThatThrownBy(() -> userService.getMyPosts(user.getId(), pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_DELETED.getMessage()); + } + + @Test + @DisplayName("정지된 사용자 → USER_SUSPENDED 예외 발생") + void getMyPosts_suspendedUser() { + // given + User user = User.createUser("suspended", "suspended@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.SUSPENDED); + userRepository.save(user); + + Pageable pageable = PageRequest.of(0, 10); + + // when & then + assertThatThrownBy(() -> userService.getMyPosts(user.getId(), pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_SUSPENDED.getMessage()); + } + + // ====================== 내 댓글 목록 조회 테스트 ====================== + + @Test + @DisplayName("내 댓글 목록 조회 성공") + void getMyComments_success() { + // given + User user = User.createUser("commenter", "commenter@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + // 게시글 하나 생성 + Post post = new Post(user, "테스트 게시글", "게시글 내용", null); + postRepository.save(post); + + // 댓글 2개 작성 + Comment comment1 = new Comment(post, user, "첫 번째 댓글", null); + Comment comment2 = new Comment(post, user, "두 번째 댓글", null); + commentRepository.saveAll(List.of(comment1, comment2)); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // when + PageResponse response = userService.getMyComments(user.getId(), pageable); + + // then + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(0).content()).isEqualTo("두 번째 댓글"); // 최신순 정렬 + assertThat(response.items().get(1).content()).isEqualTo("첫 번째 댓글"); + } + + @Test + @DisplayName("존재하지 않는 사용자 ID → USER_NOT_FOUND 예외 발생") + void getMyComments_userNotFound() { + // given + Pageable pageable = PageRequest.of(0, 10); + + // when & then + assertThatThrownBy(() -> userService.getMyComments(999L, pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("탈퇴된 사용자 → USER_DELETED 예외 발생") + void getMyComments_deletedUser() { + // given + User user = User.createUser("deleted", "deleted@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.DELETED); + userRepository.save(user); + + Pageable pageable = PageRequest.of(0, 10); + + // when & then + assertThatThrownBy(() -> userService.getMyComments(user.getId(), pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_DELETED.getMessage()); + } + + @Test + @DisplayName("정지된 사용자 → USER_SUSPENDED 예외 발생") + void getMyComments_suspendedUser() { + // given + User user = User.createUser("suspended", "suspended@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.SUSPENDED); + userRepository.save(user); + + Pageable pageable = PageRequest.of(0, 10); + + // when & then + assertThatThrownBy(() -> userService.getMyComments(user.getId(), pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_SUSPENDED.getMessage()); + } + + // ====================== 내 북마크 게시글 목록 조회 테스트 ====================== + + @Test + @DisplayName("내 북마크 게시글 목록 조회 성공") + void getMyBookmarks_success() { + // given + User user = User.createUser("bookmarkUser", "bookmark@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post1 = new Post(user, "JPA 영속성 전이 완벽 정리", "내용1", null); + Post post2 = new Post(user, "테스트 코드 작성 가이드", "내용2", null); + postRepository.saveAll(List.of(post1, post2)); + + PostBookmark bookmark1 = new PostBookmark(post1, user); + PostBookmark bookmark2 = new PostBookmark(post2, user); + postBookmarkRepository.saveAll(List.of(bookmark1, bookmark2)); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // when + PageResponse response = userService.getMyBookmarks(user.getId(), pageable); + + // then + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(0).getTitle()).isEqualTo("테스트 코드 작성 가이드"); // 최신순 + assertThat(response.items().get(1).getTitle()).isEqualTo("JPA 영속성 전이 완벽 정리"); + } + + @Test + @DisplayName("존재하지 않는 사용자 → USER_NOT_FOUND 예외 발생") + void getMyBookmarks_userNotFound() { + Pageable pageable = PageRequest.of(0, 10); + assertThatThrownBy(() -> userService.getMyBookmarks(999L, pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("탈퇴된 사용자 → USER_DELETED 예외 발생") + void getMyBookmarks_deletedUser() { + User user = User.createUser("deleted", "deleted@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserStatus(UserStatus.DELETED); + userRepository.save(user); + + Pageable pageable = PageRequest.of(0, 10); + assertThatThrownBy(() -> userService.getMyBookmarks(user.getId(), pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_DELETED.getMessage()); + } + + @Test + @DisplayName("정지된 사용자 → USER_SUSPENDED 예외 발생") + void getMyBookmarks_suspendedUser() { + User user = User.createUser("suspended", "suspended@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserStatus(UserStatus.SUSPENDED); + userRepository.save(user); + + Pageable pageable = PageRequest.of(0, 10); + assertThatThrownBy(() -> userService.getMyBookmarks(user.getId(), pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_SUSPENDED.getMessage()); + } }