From 4a7f13706803141b8f08f86215a0a99061e9a1ec Mon Sep 17 00:00:00 2001 From: dmori Date: Thu, 25 Dec 2025 16:27:10 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20Post=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=EC=97=90=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=88=98=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EC=A6=9D=EA=B0=80=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/techfork/domain/post/entity/Post.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/techfork/domain/post/entity/Post.java b/src/main/java/com/techfork/domain/post/entity/Post.java index a2c2e7b..a3a0350 100644 --- a/src/main/java/com/techfork/domain/post/entity/Post.java +++ b/src/main/java/com/techfork/domain/post/entity/Post.java @@ -46,6 +46,9 @@ public class Post extends BaseEntity { @Column private LocalDateTime embeddedAt; + @Column(nullable = false) + private Long viewCount = 0L; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "tech_blog_id", nullable = false) private TechBlog techBlog; @@ -94,4 +97,8 @@ public void addKeyword(PostKeyword keyword) { public void clearKeywords() { this.keywords.clear(); } + + public void incrementViewCount() { + this.viewCount++; + } } From 8e7aeefa997fe2601034dba3aff1c0d86dd5e008 Mon Sep 17 00:00:00 2001 From: dmori Date: Thu, 25 Dec 2025 16:31:45 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=A1=B0=ED=9A=8C=EC=88=98=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9D=B8?= =?UTF-8?q?=EA=B8=B0=EC=88=9C=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=88=98=20=EA=B8=B0=EC=A4=80=20=EB=82=B4=EB=A6=BC?= =?UTF-8?q?=EC=B0=A8=EC=88=9C=20=EC=A0=95=EB=A0=AC=EB=A1=9C=20=EB=B0=98?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/techfork/domain/post/dto/PostDetailDto.java | 1 + .../java/com/techfork/domain/post/dto/PostInfoDto.java | 1 + .../domain/post/repository/PostRepository.java | 10 +++++----- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/techfork/domain/post/dto/PostDetailDto.java b/src/main/java/com/techfork/domain/post/dto/PostDetailDto.java index d138e0a..735f311 100644 --- a/src/main/java/com/techfork/domain/post/dto/PostDetailDto.java +++ b/src/main/java/com/techfork/domain/post/dto/PostDetailDto.java @@ -14,6 +14,7 @@ public record PostDetailDto( String url, String logoUrl, LocalDateTime publishedAt, + Long viewCount, List keywords ) { } diff --git a/src/main/java/com/techfork/domain/post/dto/PostInfoDto.java b/src/main/java/com/techfork/domain/post/dto/PostInfoDto.java index be75186..934d84f 100644 --- a/src/main/java/com/techfork/domain/post/dto/PostInfoDto.java +++ b/src/main/java/com/techfork/domain/post/dto/PostInfoDto.java @@ -13,6 +13,7 @@ public record PostInfoDto( String url, String logoUrl, LocalDateTime publishedAt, + Long viewCount, List keywords ) { } diff --git a/src/main/java/com/techfork/domain/post/repository/PostRepository.java b/src/main/java/com/techfork/domain/post/repository/PostRepository.java index 7844199..b762d00 100644 --- a/src/main/java/com/techfork/domain/post/repository/PostRepository.java +++ b/src/main/java/com/techfork/domain/post/repository/PostRepository.java @@ -36,7 +36,7 @@ public interface PostRepository extends JpaRepository { @Query(""" SELECT new com.techfork.domain.post.dto.PostInfoDto( - p.id, p.title, t.companyName, p.url, t.logoUrl, p.publishedAt, null) + p.id, p.title, t.companyName, p.url, t.logoUrl, p.publishedAt, p.viewCount, null) FROM Post p JOIN TechBlog t on p.techBlog.id = t.id WHERE (:company IS NULL OR p.company = :company) @@ -51,7 +51,7 @@ List findByCompanyWithCursor( @Query(""" SELECT new com.techfork.domain.post.dto.PostInfoDto( - p.id, p.title, t.companyName, p.url, t.logoUrl, p.publishedAt, null) + p.id, p.title, t.companyName, p.url, t.logoUrl, p.publishedAt, p.viewCount, null) FROM Post p JOIN TechBlog t on p.techBlog.id = t.id WHERE :lastPostId IS NULL OR p.id < :lastPostId @@ -64,11 +64,11 @@ List findRecentPostsWithCursor( @Query(""" SELECT new com.techfork.domain.post.dto.PostInfoDto( - p.id, p.title, t.companyName, p.url, t.logoUrl, p.publishedAt, null) + p.id, p.title, t.companyName, p.url, t.logoUrl, p.publishedAt, p.viewCount, null) FROM Post p JOIN TechBlog t on p.techBlog.id = t.id WHERE :lastPostId IS NULL OR p.id < :lastPostId - ORDER BY p.crawledAt DESC, p.id DESC + ORDER BY p.viewCount DESC, p.id DESC """) List findPopularPostsWithCursor( @Param("lastPostId") Long lastPostId, @@ -77,7 +77,7 @@ List findPopularPostsWithCursor( @Query(""" SELECT new com.techfork.domain.post.dto.PostDetailDto( - p.id, p.title, p.summary, t.companyName, p.url, t.logoUrl, p.publishedAt, null) + p.id, p.title, p.summary, t.companyName, p.url, t.logoUrl, p.publishedAt, p.viewCount, null) FROM Post p JOIN TechBlog t on p.techBlog.id = t.id WHERE p.id = :id From c0d7e466a3f0dc2e0d54aa7c25f8130a73a0021e Mon Sep 17 00:00:00 2001 From: dmori Date: Thu, 25 Dec 2025 17:02:37 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=EC=9D=BD=EC=9D=80=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=A6=9D=EA=B0=80=20=EC=8B=9C=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=88=98=EB=8F=84=20=EC=A6=9D=EA=B0=80=EC=8B=9C=ED=82=A4?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EA=B5=AC=ED=98=84=20=EB=8B=A8,=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=88=98=EB=8A=94=20=ED=95=9C=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=EB=8B=B9=20=ED=95=9C=20=EB=B2=88=EB=A7=8C=20=EB=8A=98?= =?UTF-8?q?=EB=A6=B4=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/activity/service/ActivityCommandService.java | 9 +++++---- .../techfork/domain/post/converter/PostConverter.java | 1 + .../techfork/domain/post/service/PostQueryService.java | 6 +++++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/techfork/domain/activity/service/ActivityCommandService.java b/src/main/java/com/techfork/domain/activity/service/ActivityCommandService.java index 7515472..8e32c42 100644 --- a/src/main/java/com/techfork/domain/activity/service/ActivityCommandService.java +++ b/src/main/java/com/techfork/domain/activity/service/ActivityCommandService.java @@ -43,9 +43,9 @@ public void saveReadPost(Long userId, ReadPostRequest request) { Post post = postRepository.findById(request.postId()) .orElseThrow(() -> new GeneralException(PostErrorCode.POST_NOT_FOUND)); - if (readPostRepository.existsByUserAndPost(user, post)) { - log.info("User {} has already read post {}", userId, request.postId()); - return; + boolean isFirstRead = !readPostRepository.existsByUserAndPost(user, post); + if (isFirstRead) { + post.incrementViewCount(); } ReadPost readPost = ReadPost.create( @@ -56,7 +56,8 @@ public void saveReadPost(Long userId, ReadPostRequest request) { ); readPostRepository.save(readPost); - log.info("Saved read post for user {} and post {}", userId, request.postId()); + log.info("Saved read post for user {} and post {} (viewCount incremented: {})", + userId, request.postId(), isFirstRead); } @Transactional diff --git a/src/main/java/com/techfork/domain/post/converter/PostConverter.java b/src/main/java/com/techfork/domain/post/converter/PostConverter.java index 52dc1ce..be81b29 100644 --- a/src/main/java/com/techfork/domain/post/converter/PostConverter.java +++ b/src/main/java/com/techfork/domain/post/converter/PostConverter.java @@ -39,6 +39,7 @@ public PostDetailDto toPostDetailDto(PostDetailDto baseDto, List keyword .url(baseDto.url()) .logoUrl(baseDto.logoUrl()) .publishedAt(baseDto.publishedAt()) + .viewCount(baseDto.viewCount()) .keywords(keywords) .build(); } diff --git a/src/main/java/com/techfork/domain/post/service/PostQueryService.java b/src/main/java/com/techfork/domain/post/service/PostQueryService.java index e671f25..d9e4387 100644 --- a/src/main/java/com/techfork/domain/post/service/PostQueryService.java +++ b/src/main/java/com/techfork/domain/post/service/PostQueryService.java @@ -1,7 +1,10 @@ package com.techfork.domain.post.service; import com.techfork.domain.post.converter.PostConverter; -import com.techfork.domain.post.dto.*; +import com.techfork.domain.post.dto.CompanyListResponse; +import com.techfork.domain.post.dto.PostDetailDto; +import com.techfork.domain.post.dto.PostInfoDto; +import com.techfork.domain.post.dto.PostListResponse; import com.techfork.domain.post.entity.PostKeyword; import com.techfork.domain.post.enums.EPostSortType; import com.techfork.domain.post.repository.PostKeywordRepository; @@ -90,6 +93,7 @@ private List attachKeywordsToPostInfoList(List posts) .url(post.url()) .logoUrl(post.logoUrl()) .publishedAt(post.publishedAt()) + .viewCount(post.viewCount()) .keywords(keywordMap.getOrDefault(post.id(), List.of())) .build()) .toList(); From 3764d9ef264b4d539b242f568d5797209de42309 Mon Sep 17 00:00:00 2001 From: dmori Date: Thu, 25 Dec 2025 17:31:07 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20DTO=EB=A5=BC=20PostResponseDto?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/PostController.java | 20 +++---- .../domain/post/converter/PostConverter.java | 19 +++---- .../domain/post/dto/CompanyListResponse.java | 11 ---- .../domain/post/dto/PostDetailDto.java | 20 ------- .../techfork/domain/post/dto/PostInfoDto.java | 19 ------- .../domain/post/dto/PostListResponse.java | 13 ----- .../domain/post/dto/PostResponseDto.java | 55 +++++++++++++++++++ .../post/repository/PostRepository.java | 19 +++---- .../domain/post/service/PostQueryService.java | 29 +++++----- 9 files changed, 94 insertions(+), 111 deletions(-) delete mode 100644 src/main/java/com/techfork/domain/post/dto/CompanyListResponse.java delete mode 100644 src/main/java/com/techfork/domain/post/dto/PostDetailDto.java delete mode 100644 src/main/java/com/techfork/domain/post/dto/PostInfoDto.java delete mode 100644 src/main/java/com/techfork/domain/post/dto/PostListResponse.java create mode 100644 src/main/java/com/techfork/domain/post/dto/PostResponseDto.java diff --git a/src/main/java/com/techfork/domain/post/controller/PostController.java b/src/main/java/com/techfork/domain/post/controller/PostController.java index 8db7835..61b5073 100644 --- a/src/main/java/com/techfork/domain/post/controller/PostController.java +++ b/src/main/java/com/techfork/domain/post/controller/PostController.java @@ -1,8 +1,6 @@ package com.techfork.domain.post.controller; -import com.techfork.domain.post.dto.CompanyListResponse; -import com.techfork.domain.post.dto.PostDetailDto; -import com.techfork.domain.post.dto.PostListResponse; +import com.techfork.domain.post.dto.PostResponseDto; import com.techfork.domain.post.enums.EPostSortType; import com.techfork.domain.post.service.PostQueryService; import com.techfork.global.common.code.SuccessCode; @@ -29,8 +27,8 @@ public class PostController { description = "게시글이 존재하는 회사명 목록을 조회합니다. (필터링 칩용)" ) @GetMapping("/companies") - public ResponseEntity> getCompanies() { - CompanyListResponse response = postQueryService.getCompanies(); + public ResponseEntity> getCompanies() { + PostResponseDto.CompanyList response = postQueryService.getCompanies(); return BaseResponse.of(SuccessCode.OK, response); } @@ -39,7 +37,7 @@ public ResponseEntity> getCompanies() { description = "특정 기업의 게시글을 무한 스크롤 방식으로 조회합니다. company 파라미터가 없으면 전체 게시글을 조회합니다." ) @GetMapping("/by-company") - public ResponseEntity> getPostsByCompany( + public ResponseEntity> getPostsByCompany( @Parameter(description = "회사명 필터 (선택, 없으면 전체 조회)") @RequestParam(required = false) String company, @Parameter(description = "마지막 게시글 ID (커서, 선택)") @@ -47,7 +45,7 @@ public ResponseEntity> getPostsByCompany( @Parameter(description = "페이지 크기 (기본값: 20)") @RequestParam(defaultValue = "20") int size ) { - PostListResponse response = postQueryService.getPostsByCompany(company, lastPostId, size); + PostResponseDto.PostList response = postQueryService.getPostsByCompany(company, lastPostId, size); return BaseResponse.of(SuccessCode.OK, response); } @@ -56,7 +54,7 @@ public ResponseEntity> getPostsByCompany( description = "최근 생성된 게시글을 무한 스크롤 방식으로 조회합니다. sortBy로 정렬 기준을 선택할 수 있습니다." ) @GetMapping("/recent") - public ResponseEntity> getRecentPosts( + public ResponseEntity> getRecentPosts( @Parameter(description = "정렬 기준 (LATEST: 최신순, POPULAR: 인기순, 기본값: LATEST)") @RequestParam(defaultValue = "LATEST") EPostSortType sortBy, @Parameter(description = "마지막 게시글 ID (커서, 선택)") @@ -64,7 +62,7 @@ public ResponseEntity> getRecentPosts( @Parameter(description = "페이지 크기 (기본값: 20)") @RequestParam(defaultValue = "20") int size ) { - PostListResponse response = postQueryService.getRecentPosts(sortBy, lastPostId, size); + PostResponseDto.PostList response = postQueryService.getRecentPosts(sortBy, lastPostId, size); return BaseResponse.of(SuccessCode.OK, response); } @@ -73,11 +71,11 @@ public ResponseEntity> getRecentPosts( description = "특정 게시글의 상세 정보를 조회합니다." ) @GetMapping("/{postId}") - public ResponseEntity> getPostDetail( + public ResponseEntity> getPostDetail( @Parameter(description = "게시글 ID") @PathVariable Long postId ) { - PostDetailDto response = postQueryService.getPostDetail(postId); + PostResponseDto.Detail response = postQueryService.getPostDetail(postId); return BaseResponse.of(SuccessCode.OK, response); } } diff --git a/src/main/java/com/techfork/domain/post/converter/PostConverter.java b/src/main/java/com/techfork/domain/post/converter/PostConverter.java index be81b29..7fc1247 100644 --- a/src/main/java/com/techfork/domain/post/converter/PostConverter.java +++ b/src/main/java/com/techfork/domain/post/converter/PostConverter.java @@ -1,9 +1,6 @@ package com.techfork.domain.post.converter; -import com.techfork.domain.post.dto.CompanyListResponse; -import com.techfork.domain.post.dto.PostDetailDto; -import com.techfork.domain.post.dto.PostInfoDto; -import com.techfork.domain.post.dto.PostListResponse; +import com.techfork.domain.post.dto.PostResponseDto; import org.springframework.stereotype.Component; import java.util.List; @@ -11,27 +8,27 @@ @Component public class PostConverter { - public CompanyListResponse toCompanyListResponse(List companies) { - return CompanyListResponse.builder() + public PostResponseDto.CompanyList toCompanyListResponse(List companies) { + return PostResponseDto.CompanyList.builder() .companies(companies) .build(); } - public PostListResponse toPostListResponse(List posts, int requestedSize) { + public PostResponseDto.PostList toPostListResponse(List posts, int requestedSize) { boolean hasNext = posts.size() > requestedSize; - List content = hasNext ? posts.subList(0, requestedSize) : posts; + List content = hasNext ? posts.subList(0, requestedSize) : posts; Long lastPostId = content.isEmpty() ? null : content.get(content.size() - 1).id(); - return PostListResponse.builder() + return PostResponseDto.PostList.builder() .posts(content) .lastPostId(lastPostId) .hasNext(hasNext) .build(); } - public PostDetailDto toPostDetailDto(PostDetailDto baseDto, List keywords) { - return PostDetailDto.builder() + public PostResponseDto.Detail toPostDetailDto(PostResponseDto.Detail baseDto, List keywords) { + return PostResponseDto.Detail.builder() .id(baseDto.id()) .title(baseDto.title()) .summary(baseDto.summary()) diff --git a/src/main/java/com/techfork/domain/post/dto/CompanyListResponse.java b/src/main/java/com/techfork/domain/post/dto/CompanyListResponse.java deleted file mode 100644 index 7a4fbaf..0000000 --- a/src/main/java/com/techfork/domain/post/dto/CompanyListResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.techfork.domain.post.dto; - -import lombok.Builder; - -import java.util.List; - -@Builder -public record CompanyListResponse( - List companies -) { -} diff --git a/src/main/java/com/techfork/domain/post/dto/PostDetailDto.java b/src/main/java/com/techfork/domain/post/dto/PostDetailDto.java deleted file mode 100644 index 735f311..0000000 --- a/src/main/java/com/techfork/domain/post/dto/PostDetailDto.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.techfork.domain.post.dto; - -import lombok.Builder; - -import java.time.LocalDateTime; -import java.util.List; - -@Builder -public record PostDetailDto( - Long id, - String title, - String summary, - String company, - String url, - String logoUrl, - LocalDateTime publishedAt, - Long viewCount, - List keywords -) { -} diff --git a/src/main/java/com/techfork/domain/post/dto/PostInfoDto.java b/src/main/java/com/techfork/domain/post/dto/PostInfoDto.java deleted file mode 100644 index 934d84f..0000000 --- a/src/main/java/com/techfork/domain/post/dto/PostInfoDto.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.techfork.domain.post.dto; - -import lombok.Builder; - -import java.time.LocalDateTime; -import java.util.List; - -@Builder -public record PostInfoDto( - Long id, - String title, - String company, - String url, - String logoUrl, - LocalDateTime publishedAt, - Long viewCount, - List keywords -) { -} diff --git a/src/main/java/com/techfork/domain/post/dto/PostListResponse.java b/src/main/java/com/techfork/domain/post/dto/PostListResponse.java deleted file mode 100644 index 99a608a..0000000 --- a/src/main/java/com/techfork/domain/post/dto/PostListResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.techfork.domain.post.dto; - -import lombok.Builder; - -import java.util.List; - -@Builder -public record PostListResponse( - List posts, - Long lastPostId, - boolean hasNext -) { -} diff --git a/src/main/java/com/techfork/domain/post/dto/PostResponseDto.java b/src/main/java/com/techfork/domain/post/dto/PostResponseDto.java new file mode 100644 index 0000000..4ec8b24 --- /dev/null +++ b/src/main/java/com/techfork/domain/post/dto/PostResponseDto.java @@ -0,0 +1,55 @@ +package com.techfork.domain.post.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.List; + +public class PostResponseDto { + + @Schema(name = "PostDetailResponse") + @Builder + public record Detail( + Long id, + String title, + String summary, + String company, + String url, + String logoUrl, + LocalDateTime publishedAt, + Long viewCount, + List keywords + ) { + } + + @Schema(name = "PostInfoDto") + @Builder + public record Info( + Long id, + String title, + String company, + String url, + String logoUrl, + LocalDateTime publishedAt, + Long viewCount, + List keywords + ) { + } + + @Schema(name = "PostListResponse") + @Builder + public record PostList( + List posts, + Long lastPostId, + boolean hasNext + ) { + } + + @Schema(name = "CompanyListResponse") + @Builder + public record CompanyList( + List companies + ) { + } +} \ No newline at end of file diff --git a/src/main/java/com/techfork/domain/post/repository/PostRepository.java b/src/main/java/com/techfork/domain/post/repository/PostRepository.java index b762d00..0cbd175 100644 --- a/src/main/java/com/techfork/domain/post/repository/PostRepository.java +++ b/src/main/java/com/techfork/domain/post/repository/PostRepository.java @@ -1,7 +1,6 @@ package com.techfork.domain.post.repository; -import com.techfork.domain.post.dto.PostDetailDto; -import com.techfork.domain.post.dto.PostInfoDto; +import com.techfork.domain.post.dto.PostResponseDto; import com.techfork.domain.post.entity.Post; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -35,7 +34,7 @@ public interface PostRepository extends JpaRepository { List findDistinctCompanies(); @Query(""" - SELECT new com.techfork.domain.post.dto.PostInfoDto( + SELECT new com.techfork.domain.post.dto.PostResponseDto$Info( p.id, p.title, t.companyName, p.url, t.logoUrl, p.publishedAt, p.viewCount, null) FROM Post p JOIN TechBlog t on p.techBlog.id = t.id @@ -43,44 +42,44 @@ public interface PostRepository extends JpaRepository { AND (:lastPostId IS NULL OR p.id < :lastPostId) ORDER BY p.id DESC """) - List findByCompanyWithCursor( + List findByCompanyWithCursor( @Param("company") String company, @Param("lastPostId") Long lastPostId, Pageable pageable ); @Query(""" - SELECT new com.techfork.domain.post.dto.PostInfoDto( + SELECT new com.techfork.domain.post.dto.PostResponseDto$Info( p.id, p.title, t.companyName, p.url, t.logoUrl, p.publishedAt, p.viewCount, null) FROM Post p JOIN TechBlog t on p.techBlog.id = t.id WHERE :lastPostId IS NULL OR p.id < :lastPostId ORDER BY p.publishedAt DESC, p.id DESC """) - List findRecentPostsWithCursor( + List findRecentPostsWithCursor( @Param("lastPostId") Long lastPostId, Pageable pageable ); @Query(""" - SELECT new com.techfork.domain.post.dto.PostInfoDto( + SELECT new com.techfork.domain.post.dto.PostResponseDto$Info( p.id, p.title, t.companyName, p.url, t.logoUrl, p.publishedAt, p.viewCount, null) FROM Post p JOIN TechBlog t on p.techBlog.id = t.id WHERE :lastPostId IS NULL OR p.id < :lastPostId ORDER BY p.viewCount DESC, p.id DESC """) - List findPopularPostsWithCursor( + List findPopularPostsWithCursor( @Param("lastPostId") Long lastPostId, Pageable pageable ); @Query(""" - SELECT new com.techfork.domain.post.dto.PostDetailDto( + SELECT new com.techfork.domain.post.dto.PostResponseDto$Detail( p.id, p.title, p.summary, t.companyName, p.url, t.logoUrl, p.publishedAt, p.viewCount, null) FROM Post p JOIN TechBlog t on p.techBlog.id = t.id WHERE p.id = :id """) - Optional findByIdWithTechBlog(@Param("id") Long id); + Optional findByIdWithTechBlog(@Param("id") Long id); } diff --git a/src/main/java/com/techfork/domain/post/service/PostQueryService.java b/src/main/java/com/techfork/domain/post/service/PostQueryService.java index d9e4387..028fa91 100644 --- a/src/main/java/com/techfork/domain/post/service/PostQueryService.java +++ b/src/main/java/com/techfork/domain/post/service/PostQueryService.java @@ -1,10 +1,7 @@ package com.techfork.domain.post.service; import com.techfork.domain.post.converter.PostConverter; -import com.techfork.domain.post.dto.CompanyListResponse; -import com.techfork.domain.post.dto.PostDetailDto; -import com.techfork.domain.post.dto.PostInfoDto; -import com.techfork.domain.post.dto.PostListResponse; +import com.techfork.domain.post.dto.PostResponseDto; import com.techfork.domain.post.entity.PostKeyword; import com.techfork.domain.post.enums.EPostSortType; import com.techfork.domain.post.repository.PostKeywordRepository; @@ -31,21 +28,21 @@ public class PostQueryService { private final PostKeywordRepository postKeywordRepository; private final PostConverter postConverter; - public CompanyListResponse getCompanies() { + public PostResponseDto.CompanyList getCompanies() { List companies = postRepository.findDistinctCompanies(); return postConverter.toCompanyListResponse(companies); } - public PostListResponse getPostsByCompany(String company, Long lastPostId, int size) { + public PostResponseDto.PostList getPostsByCompany(String company, Long lastPostId, int size) { PageRequest pageRequest = PageRequest.of(0, size + 1); - List posts = postRepository.findByCompanyWithCursor(company, lastPostId, pageRequest); - List postsWithKeywords = attachKeywordsToPostInfoList(posts); + List posts = postRepository.findByCompanyWithCursor(company, lastPostId, pageRequest); + List postsWithKeywords = attachKeywordsToPostInfoList(posts); return postConverter.toPostListResponse(postsWithKeywords, size); } - public PostListResponse getRecentPosts(EPostSortType sortBy, Long lastPostId, int size) { + public PostResponseDto.PostList getRecentPosts(EPostSortType sortBy, Long lastPostId, int size) { PageRequest pageRequest = PageRequest.of(0, size + 1); - List posts; + List posts; if (sortBy == EPostSortType.POPULAR) { posts = postRepository.findPopularPostsWithCursor(lastPostId, pageRequest); @@ -53,12 +50,12 @@ public PostListResponse getRecentPosts(EPostSortType sortBy, Long lastPostId, in posts = postRepository.findRecentPostsWithCursor(lastPostId, pageRequest); } - List postsWithKeywords = attachKeywordsToPostInfoList(posts); + List postsWithKeywords = attachKeywordsToPostInfoList(posts); return postConverter.toPostListResponse(postsWithKeywords, size); } - public PostDetailDto getPostDetail(Long postId) { - PostDetailDto postDetail = postRepository.findByIdWithTechBlog(postId) + public PostResponseDto.Detail getPostDetail(Long postId) { + PostResponseDto.Detail postDetail = postRepository.findByIdWithTechBlog(postId) .orElseThrow(() -> new GeneralException(CommonErrorCode.NOT_FOUND)); List keywords = postKeywordRepository.findByPostIdIn(List.of(postId)) @@ -69,13 +66,13 @@ public PostDetailDto getPostDetail(Long postId) { return postConverter.toPostDetailDto(postDetail, keywords); } - private List attachKeywordsToPostInfoList(List posts) { + private List attachKeywordsToPostInfoList(List posts) { if (posts.isEmpty()) { return posts; } List postIds = posts.stream() - .map(PostInfoDto::id) + .map(PostResponseDto.Info::id) .toList(); Map> keywordMap = postKeywordRepository.findByPostIdIn(postIds) @@ -86,7 +83,7 @@ private List attachKeywordsToPostInfoList(List posts) )); return posts.stream() - .map(post -> PostInfoDto.builder() + .map(post -> PostResponseDto.Info.builder() .id(post.id()) .title(post.title()) .company(post.company()) From fcd50a35c2996a0ed81186d2d6b440e24d5ee5b3 Mon Sep 17 00:00:00 2001 From: dmori Date: Fri, 26 Dec 2025 22:20:55 +0900 Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20JPQL=20Projection=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Nested=20Class=20=EB=AC=B8=EC=A0=9C=EB=A1=9C=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EB=A1=A4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/PostController.java | 20 ++++--- .../domain/post/converter/PostConverter.java | 19 ++++--- .../domain/post/dto/CompanyListResponse.java | 13 +++++ .../domain/post/dto/PostDetailDto.java | 22 ++++++++ .../techfork/domain/post/dto/PostInfoDto.java | 21 +++++++ .../domain/post/dto/PostListResponse.java | 15 +++++ .../domain/post/dto/PostResponseDto.java | 55 ------------------- .../post/repository/PostRepository.java | 19 ++++--- .../domain/post/service/PostQueryService.java | 29 +++++----- 9 files changed, 119 insertions(+), 94 deletions(-) create mode 100644 src/main/java/com/techfork/domain/post/dto/CompanyListResponse.java create mode 100644 src/main/java/com/techfork/domain/post/dto/PostDetailDto.java create mode 100644 src/main/java/com/techfork/domain/post/dto/PostInfoDto.java create mode 100644 src/main/java/com/techfork/domain/post/dto/PostListResponse.java delete mode 100644 src/main/java/com/techfork/domain/post/dto/PostResponseDto.java diff --git a/src/main/java/com/techfork/domain/post/controller/PostController.java b/src/main/java/com/techfork/domain/post/controller/PostController.java index 61b5073..8db7835 100644 --- a/src/main/java/com/techfork/domain/post/controller/PostController.java +++ b/src/main/java/com/techfork/domain/post/controller/PostController.java @@ -1,6 +1,8 @@ package com.techfork.domain.post.controller; -import com.techfork.domain.post.dto.PostResponseDto; +import com.techfork.domain.post.dto.CompanyListResponse; +import com.techfork.domain.post.dto.PostDetailDto; +import com.techfork.domain.post.dto.PostListResponse; import com.techfork.domain.post.enums.EPostSortType; import com.techfork.domain.post.service.PostQueryService; import com.techfork.global.common.code.SuccessCode; @@ -27,8 +29,8 @@ public class PostController { description = "게시글이 존재하는 회사명 목록을 조회합니다. (필터링 칩용)" ) @GetMapping("/companies") - public ResponseEntity> getCompanies() { - PostResponseDto.CompanyList response = postQueryService.getCompanies(); + public ResponseEntity> getCompanies() { + CompanyListResponse response = postQueryService.getCompanies(); return BaseResponse.of(SuccessCode.OK, response); } @@ -37,7 +39,7 @@ public ResponseEntity> getCompanies() description = "특정 기업의 게시글을 무한 스크롤 방식으로 조회합니다. company 파라미터가 없으면 전체 게시글을 조회합니다." ) @GetMapping("/by-company") - public ResponseEntity> getPostsByCompany( + public ResponseEntity> getPostsByCompany( @Parameter(description = "회사명 필터 (선택, 없으면 전체 조회)") @RequestParam(required = false) String company, @Parameter(description = "마지막 게시글 ID (커서, 선택)") @@ -45,7 +47,7 @@ public ResponseEntity> getPostsByCompany( @Parameter(description = "페이지 크기 (기본값: 20)") @RequestParam(defaultValue = "20") int size ) { - PostResponseDto.PostList response = postQueryService.getPostsByCompany(company, lastPostId, size); + PostListResponse response = postQueryService.getPostsByCompany(company, lastPostId, size); return BaseResponse.of(SuccessCode.OK, response); } @@ -54,7 +56,7 @@ public ResponseEntity> getPostsByCompany( description = "최근 생성된 게시글을 무한 스크롤 방식으로 조회합니다. sortBy로 정렬 기준을 선택할 수 있습니다." ) @GetMapping("/recent") - public ResponseEntity> getRecentPosts( + public ResponseEntity> getRecentPosts( @Parameter(description = "정렬 기준 (LATEST: 최신순, POPULAR: 인기순, 기본값: LATEST)") @RequestParam(defaultValue = "LATEST") EPostSortType sortBy, @Parameter(description = "마지막 게시글 ID (커서, 선택)") @@ -62,7 +64,7 @@ public ResponseEntity> getRecentPosts( @Parameter(description = "페이지 크기 (기본값: 20)") @RequestParam(defaultValue = "20") int size ) { - PostResponseDto.PostList response = postQueryService.getRecentPosts(sortBy, lastPostId, size); + PostListResponse response = postQueryService.getRecentPosts(sortBy, lastPostId, size); return BaseResponse.of(SuccessCode.OK, response); } @@ -71,11 +73,11 @@ public ResponseEntity> getRecentPosts( description = "특정 게시글의 상세 정보를 조회합니다." ) @GetMapping("/{postId}") - public ResponseEntity> getPostDetail( + public ResponseEntity> getPostDetail( @Parameter(description = "게시글 ID") @PathVariable Long postId ) { - PostResponseDto.Detail response = postQueryService.getPostDetail(postId); + PostDetailDto response = postQueryService.getPostDetail(postId); return BaseResponse.of(SuccessCode.OK, response); } } diff --git a/src/main/java/com/techfork/domain/post/converter/PostConverter.java b/src/main/java/com/techfork/domain/post/converter/PostConverter.java index 7fc1247..be81b29 100644 --- a/src/main/java/com/techfork/domain/post/converter/PostConverter.java +++ b/src/main/java/com/techfork/domain/post/converter/PostConverter.java @@ -1,6 +1,9 @@ package com.techfork.domain.post.converter; -import com.techfork.domain.post.dto.PostResponseDto; +import com.techfork.domain.post.dto.CompanyListResponse; +import com.techfork.domain.post.dto.PostDetailDto; +import com.techfork.domain.post.dto.PostInfoDto; +import com.techfork.domain.post.dto.PostListResponse; import org.springframework.stereotype.Component; import java.util.List; @@ -8,27 +11,27 @@ @Component public class PostConverter { - public PostResponseDto.CompanyList toCompanyListResponse(List companies) { - return PostResponseDto.CompanyList.builder() + public CompanyListResponse toCompanyListResponse(List companies) { + return CompanyListResponse.builder() .companies(companies) .build(); } - public PostResponseDto.PostList toPostListResponse(List posts, int requestedSize) { + public PostListResponse toPostListResponse(List posts, int requestedSize) { boolean hasNext = posts.size() > requestedSize; - List content = hasNext ? posts.subList(0, requestedSize) : posts; + List content = hasNext ? posts.subList(0, requestedSize) : posts; Long lastPostId = content.isEmpty() ? null : content.get(content.size() - 1).id(); - return PostResponseDto.PostList.builder() + return PostListResponse.builder() .posts(content) .lastPostId(lastPostId) .hasNext(hasNext) .build(); } - public PostResponseDto.Detail toPostDetailDto(PostResponseDto.Detail baseDto, List keywords) { - return PostResponseDto.Detail.builder() + public PostDetailDto toPostDetailDto(PostDetailDto baseDto, List keywords) { + return PostDetailDto.builder() .id(baseDto.id()) .title(baseDto.title()) .summary(baseDto.summary()) diff --git a/src/main/java/com/techfork/domain/post/dto/CompanyListResponse.java b/src/main/java/com/techfork/domain/post/dto/CompanyListResponse.java new file mode 100644 index 0000000..5848b23 --- /dev/null +++ b/src/main/java/com/techfork/domain/post/dto/CompanyListResponse.java @@ -0,0 +1,13 @@ +package com.techfork.domain.post.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema(name = "CompanyListResponse") +public record CompanyListResponse( + List companies +) { +} diff --git a/src/main/java/com/techfork/domain/post/dto/PostDetailDto.java b/src/main/java/com/techfork/domain/post/dto/PostDetailDto.java new file mode 100644 index 0000000..4f54f9f --- /dev/null +++ b/src/main/java/com/techfork/domain/post/dto/PostDetailDto.java @@ -0,0 +1,22 @@ +package com.techfork.domain.post.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.List; + +@Builder +@Schema(name = "PostDetailResponse") +public record PostDetailDto( + Long id, + String title, + String summary, + String company, + String url, + String logoUrl, + LocalDateTime publishedAt, + Long viewCount, + List keywords +) { +} diff --git a/src/main/java/com/techfork/domain/post/dto/PostInfoDto.java b/src/main/java/com/techfork/domain/post/dto/PostInfoDto.java new file mode 100644 index 0000000..95f19b0 --- /dev/null +++ b/src/main/java/com/techfork/domain/post/dto/PostInfoDto.java @@ -0,0 +1,21 @@ +package com.techfork.domain.post.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.List; + +@Builder +@Schema(name = "PostInfoDto") +public record PostInfoDto( + Long id, + String title, + String company, + String url, + String logoUrl, + LocalDateTime publishedAt, + Long viewCount, + List keywords +) { +} diff --git a/src/main/java/com/techfork/domain/post/dto/PostListResponse.java b/src/main/java/com/techfork/domain/post/dto/PostListResponse.java new file mode 100644 index 0000000..ed4aef3 --- /dev/null +++ b/src/main/java/com/techfork/domain/post/dto/PostListResponse.java @@ -0,0 +1,15 @@ +package com.techfork.domain.post.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema(name = "PostListResponse") +public record PostListResponse( + List posts, + Long lastPostId, + boolean hasNext +) { +} diff --git a/src/main/java/com/techfork/domain/post/dto/PostResponseDto.java b/src/main/java/com/techfork/domain/post/dto/PostResponseDto.java deleted file mode 100644 index 4ec8b24..0000000 --- a/src/main/java/com/techfork/domain/post/dto/PostResponseDto.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.techfork.domain.post.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Builder; - -import java.time.LocalDateTime; -import java.util.List; - -public class PostResponseDto { - - @Schema(name = "PostDetailResponse") - @Builder - public record Detail( - Long id, - String title, - String summary, - String company, - String url, - String logoUrl, - LocalDateTime publishedAt, - Long viewCount, - List keywords - ) { - } - - @Schema(name = "PostInfoDto") - @Builder - public record Info( - Long id, - String title, - String company, - String url, - String logoUrl, - LocalDateTime publishedAt, - Long viewCount, - List keywords - ) { - } - - @Schema(name = "PostListResponse") - @Builder - public record PostList( - List posts, - Long lastPostId, - boolean hasNext - ) { - } - - @Schema(name = "CompanyListResponse") - @Builder - public record CompanyList( - List companies - ) { - } -} \ No newline at end of file diff --git a/src/main/java/com/techfork/domain/post/repository/PostRepository.java b/src/main/java/com/techfork/domain/post/repository/PostRepository.java index 0cbd175..b762d00 100644 --- a/src/main/java/com/techfork/domain/post/repository/PostRepository.java +++ b/src/main/java/com/techfork/domain/post/repository/PostRepository.java @@ -1,6 +1,7 @@ package com.techfork.domain.post.repository; -import com.techfork.domain.post.dto.PostResponseDto; +import com.techfork.domain.post.dto.PostDetailDto; +import com.techfork.domain.post.dto.PostInfoDto; import com.techfork.domain.post.entity.Post; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -34,7 +35,7 @@ public interface PostRepository extends JpaRepository { List findDistinctCompanies(); @Query(""" - SELECT new com.techfork.domain.post.dto.PostResponseDto$Info( + SELECT new com.techfork.domain.post.dto.PostInfoDto( p.id, p.title, t.companyName, p.url, t.logoUrl, p.publishedAt, p.viewCount, null) FROM Post p JOIN TechBlog t on p.techBlog.id = t.id @@ -42,44 +43,44 @@ public interface PostRepository extends JpaRepository { AND (:lastPostId IS NULL OR p.id < :lastPostId) ORDER BY p.id DESC """) - List findByCompanyWithCursor( + List findByCompanyWithCursor( @Param("company") String company, @Param("lastPostId") Long lastPostId, Pageable pageable ); @Query(""" - SELECT new com.techfork.domain.post.dto.PostResponseDto$Info( + SELECT new com.techfork.domain.post.dto.PostInfoDto( p.id, p.title, t.companyName, p.url, t.logoUrl, p.publishedAt, p.viewCount, null) FROM Post p JOIN TechBlog t on p.techBlog.id = t.id WHERE :lastPostId IS NULL OR p.id < :lastPostId ORDER BY p.publishedAt DESC, p.id DESC """) - List findRecentPostsWithCursor( + List findRecentPostsWithCursor( @Param("lastPostId") Long lastPostId, Pageable pageable ); @Query(""" - SELECT new com.techfork.domain.post.dto.PostResponseDto$Info( + SELECT new com.techfork.domain.post.dto.PostInfoDto( p.id, p.title, t.companyName, p.url, t.logoUrl, p.publishedAt, p.viewCount, null) FROM Post p JOIN TechBlog t on p.techBlog.id = t.id WHERE :lastPostId IS NULL OR p.id < :lastPostId ORDER BY p.viewCount DESC, p.id DESC """) - List findPopularPostsWithCursor( + List findPopularPostsWithCursor( @Param("lastPostId") Long lastPostId, Pageable pageable ); @Query(""" - SELECT new com.techfork.domain.post.dto.PostResponseDto$Detail( + SELECT new com.techfork.domain.post.dto.PostDetailDto( p.id, p.title, p.summary, t.companyName, p.url, t.logoUrl, p.publishedAt, p.viewCount, null) FROM Post p JOIN TechBlog t on p.techBlog.id = t.id WHERE p.id = :id """) - Optional findByIdWithTechBlog(@Param("id") Long id); + Optional findByIdWithTechBlog(@Param("id") Long id); } diff --git a/src/main/java/com/techfork/domain/post/service/PostQueryService.java b/src/main/java/com/techfork/domain/post/service/PostQueryService.java index 028fa91..d9e4387 100644 --- a/src/main/java/com/techfork/domain/post/service/PostQueryService.java +++ b/src/main/java/com/techfork/domain/post/service/PostQueryService.java @@ -1,7 +1,10 @@ package com.techfork.domain.post.service; import com.techfork.domain.post.converter.PostConverter; -import com.techfork.domain.post.dto.PostResponseDto; +import com.techfork.domain.post.dto.CompanyListResponse; +import com.techfork.domain.post.dto.PostDetailDto; +import com.techfork.domain.post.dto.PostInfoDto; +import com.techfork.domain.post.dto.PostListResponse; import com.techfork.domain.post.entity.PostKeyword; import com.techfork.domain.post.enums.EPostSortType; import com.techfork.domain.post.repository.PostKeywordRepository; @@ -28,21 +31,21 @@ public class PostQueryService { private final PostKeywordRepository postKeywordRepository; private final PostConverter postConverter; - public PostResponseDto.CompanyList getCompanies() { + public CompanyListResponse getCompanies() { List companies = postRepository.findDistinctCompanies(); return postConverter.toCompanyListResponse(companies); } - public PostResponseDto.PostList getPostsByCompany(String company, Long lastPostId, int size) { + public PostListResponse getPostsByCompany(String company, Long lastPostId, int size) { PageRequest pageRequest = PageRequest.of(0, size + 1); - List posts = postRepository.findByCompanyWithCursor(company, lastPostId, pageRequest); - List postsWithKeywords = attachKeywordsToPostInfoList(posts); + List posts = postRepository.findByCompanyWithCursor(company, lastPostId, pageRequest); + List postsWithKeywords = attachKeywordsToPostInfoList(posts); return postConverter.toPostListResponse(postsWithKeywords, size); } - public PostResponseDto.PostList getRecentPosts(EPostSortType sortBy, Long lastPostId, int size) { + public PostListResponse getRecentPosts(EPostSortType sortBy, Long lastPostId, int size) { PageRequest pageRequest = PageRequest.of(0, size + 1); - List posts; + List posts; if (sortBy == EPostSortType.POPULAR) { posts = postRepository.findPopularPostsWithCursor(lastPostId, pageRequest); @@ -50,12 +53,12 @@ public PostResponseDto.PostList getRecentPosts(EPostSortType sortBy, Long lastPo posts = postRepository.findRecentPostsWithCursor(lastPostId, pageRequest); } - List postsWithKeywords = attachKeywordsToPostInfoList(posts); + List postsWithKeywords = attachKeywordsToPostInfoList(posts); return postConverter.toPostListResponse(postsWithKeywords, size); } - public PostResponseDto.Detail getPostDetail(Long postId) { - PostResponseDto.Detail postDetail = postRepository.findByIdWithTechBlog(postId) + public PostDetailDto getPostDetail(Long postId) { + PostDetailDto postDetail = postRepository.findByIdWithTechBlog(postId) .orElseThrow(() -> new GeneralException(CommonErrorCode.NOT_FOUND)); List keywords = postKeywordRepository.findByPostIdIn(List.of(postId)) @@ -66,13 +69,13 @@ public PostResponseDto.Detail getPostDetail(Long postId) { return postConverter.toPostDetailDto(postDetail, keywords); } - private List attachKeywordsToPostInfoList(List posts) { + private List attachKeywordsToPostInfoList(List posts) { if (posts.isEmpty()) { return posts; } List postIds = posts.stream() - .map(PostResponseDto.Info::id) + .map(PostInfoDto::id) .toList(); Map> keywordMap = postKeywordRepository.findByPostIdIn(postIds) @@ -83,7 +86,7 @@ private List attachKeywordsToPostInfoList(List PostResponseDto.Info.builder() + .map(post -> PostInfoDto.builder() .id(post.id()) .title(post.title()) .company(post.company()) From 1352698f24bfd38fe1894e82c3121253688ca6ad Mon Sep 17 00:00:00 2001 From: dmori Date: Fri, 26 Dec 2025 23:24:40 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20saveReadPost=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=20=EC=8B=9C=20=EC=A1=B0=ED=9A=8C=EC=88=98=20=EC=A6=9D=EA=B0=80?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ActivityCommandServiceTest.java | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 src/test/java/com/techfork/domain/activity/service/ActivityCommandServiceTest.java diff --git a/src/test/java/com/techfork/domain/activity/service/ActivityCommandServiceTest.java b/src/test/java/com/techfork/domain/activity/service/ActivityCommandServiceTest.java new file mode 100644 index 0000000..566f5e5 --- /dev/null +++ b/src/test/java/com/techfork/domain/activity/service/ActivityCommandServiceTest.java @@ -0,0 +1,144 @@ +package com.techfork.domain.activity.service; + +import com.techfork.domain.activity.dto.ReadPostRequest; +import com.techfork.domain.activity.entity.ReadPost; +import com.techfork.domain.activity.repository.ReadPostRepository; +import com.techfork.domain.activity.repository.ScrabPostRepository; +import com.techfork.domain.activity.repository.SearchHistoryRepository; +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.post.repository.PostRepository; +import com.techfork.domain.source.entity.TechBlog; +import com.techfork.domain.user.entity.User; +import com.techfork.domain.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +/** + * ActivityCommandService 단위 테스트 + * - Mockito를 사용해서 의존성을 Mock으로 대체 + * - 실제 DB 없이 빠르게 테스트 + */ +@ExtendWith(MockitoExtension.class) +class ActivityCommandServiceTest { + + @Mock + private ReadPostRepository readPostRepository; + + @Mock + private PostRepository postRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private ActivityCommandService activityCommandService; + + @Test + @DisplayName("처음 읽는 게시글이면 조회수가 증가한다") + void saveReadPost_FirstRead_IncrementViewCount() { + // Given: 테스트 데이터 준비 + Long userId = 1L; + Long postId = 100L; + + User mockUser = mock(User.class); + TechBlog mockTechBlog = TechBlog.builder() + .companyName("테스트회사") + .blogUrl("https://test.com") + .rssUrl("https://test.com/rss") + .build(); + + Post mockPost = Post.builder() + .title("테스트 제목") + .fullContent("내용") + .plainContent("내용") + .company("테스트회사") + .url("https://test.com/post/1") + .publishedAt(LocalDateTime.now()) + .crawledAt(LocalDateTime.now()) + .techBlog(mockTechBlog) + .build(); + + ReadPostRequest request = new ReadPostRequest( + postId, + LocalDateTime.now(), + 300 + ); + + // Mock 동작 정의 + given(userRepository.findById(userId)).willReturn(Optional.of(mockUser)); + given(postRepository.findById(postId)).willReturn(Optional.of(mockPost)); + given(readPostRepository.existsByUserAndPost(mockUser, mockPost)).willReturn(false); // 처음 읽음 + given(readPostRepository.save(any(ReadPost.class))).willReturn(mock(ReadPost.class)); + + Long beforeViewCount = mockPost.getViewCount(); + + // When: saveReadPost 호출 + activityCommandService.saveReadPost(userId, request); + + // Then: 조회수가 1 증가했는지 검증 + assertThat(mockPost.getViewCount()).isEqualTo(beforeViewCount + 1); + + // 그리고 ReadPost가 저장되었는지 검증 + verify(readPostRepository, times(1)).save(any(ReadPost.class)); + } + + @Test + @DisplayName("이미 읽은 게시글이면 조회수가 증가하지 않는다") + void saveReadPost_AlreadyRead_NoIncrementViewCount() { + // Given + Long userId = 1L; + Long postId = 100L; + + User mockUser = mock(User.class); + TechBlog mockTechBlog = TechBlog.builder() + .companyName("테스트회사") + .blogUrl("https://test.com") + .rssUrl("https://test.com/rss") + .build(); + + Post mockPost = Post.builder() + .title("테스트 제목") + .fullContent("내용") + .plainContent("내용") + .company("테스트회사") + .url("https://test.com/post/1") + .publishedAt(LocalDateTime.now()) + .crawledAt(LocalDateTime.now()) + .techBlog(mockTechBlog) + .build(); + + ReadPostRequest request = new ReadPostRequest( + postId, + LocalDateTime.now(), + 300 + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(mockUser)); + given(postRepository.findById(postId)).willReturn(Optional.of(mockPost)); + given(readPostRepository.existsByUserAndPost(mockUser, mockPost)).willReturn(true); // 이미 읽음 + given(readPostRepository.save(any(ReadPost.class))).willReturn(mock(ReadPost.class)); + + Long beforeViewCount = mockPost.getViewCount(); + + // When + activityCommandService.saveReadPost(userId, request); + + // Then: 조회수가 증가하지 않음 + assertThat(mockPost.getViewCount()).isEqualTo(beforeViewCount); + + // 하지만 ReadPost는 저장됨 (읽은 기록은 매번 저장) + verify(readPostRepository, times(1)).save(any(ReadPost.class)); + } +} From ffd52d526abef75d694bee14336aff96a846894a Mon Sep 17 00:00:00 2001 From: dmori Date: Fri, 26 Dec 2025 23:26:15 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20PostRepository=EC=97=90=EC=84=9C=20?= =?UTF-8?q?Query=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EB=B6=99=EC=9D=80=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/repository/PostRepositoryTest.java | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 src/test/java/com/techfork/domain/post/repository/PostRepositoryTest.java diff --git a/src/test/java/com/techfork/domain/post/repository/PostRepositoryTest.java b/src/test/java/com/techfork/domain/post/repository/PostRepositoryTest.java new file mode 100644 index 0000000..37fb605 --- /dev/null +++ b/src/test/java/com/techfork/domain/post/repository/PostRepositoryTest.java @@ -0,0 +1,241 @@ +package com.techfork.domain.post.repository; + +import com.techfork.domain.post.dto.PostDetailDto; +import com.techfork.domain.post.dto.PostInfoDto; +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.source.entity.TechBlog; +import com.techfork.domain.source.repository.TechBlogRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * PostRepository 테스트 + * - @DataJpaTest: JPA 관련 컴포넌트만 로드 + * - @ActiveProfiles("test"): H2 in-memory database 사용 + * - 복잡한 JPQL 쿼리, 커서 페이징, 프로젝션 검증 + */ +@DataJpaTest +@ActiveProfiles("test") +class PostRepositoryTest { + + @Autowired + private PostRepository postRepository; + + @Autowired + private TechBlogRepository techBlogRepository; + + private TechBlog techBlog1; + private TechBlog techBlog2; + + @BeforeEach + void setUp() { + // Given: 테스트용 TechBlog 생성 + techBlog1 = TechBlog.builder() + .companyName("카카오") + .blogUrl("https://kakao.com/blog") + .rssUrl("https://kakao.com/rss") + .build(); + techBlogRepository.save(techBlog1); + + techBlog2 = TechBlog.builder() + .companyName("네이버") + .blogUrl("https://naver.com/blog") + .rssUrl("https://naver.com/rss") + .build(); + techBlogRepository.save(techBlog2); + } + + @Test + @DisplayName("findPopularPostsWithCursor - lastPostId가 null이면 첫 페이지 조회 (조회수 높은 순)") + void findPopularPostsWithCursor_FirstPage_OrderByViewCountDesc() { + // Given: 조회수가 다른 게시글 3개 + Post post1 = createPost("게시글1", techBlog1, LocalDateTime.now().minusDays(3), 100L); + Post post2 = createPost("게시글2", techBlog1, LocalDateTime.now().minusDays(2), 500L); + Post post3 = createPost("게시글3", techBlog1, LocalDateTime.now().minusDays(1), 300L); + postRepository.saveAll(List.of(post1, post2, post3)); + + // When: lastPostId = null, size = 10 + PageRequest pageRequest = PageRequest.of(0, 10); + List result = postRepository.findPopularPostsWithCursor(null, pageRequest); + + // Then: 조회수 높은 순으로 정렬 (500 > 300 > 100) + assertThat(result).hasSize(3); + assertThat(result.get(0).viewCount()).isEqualTo(500L); + assertThat(result.get(1).viewCount()).isEqualTo(300L); + assertThat(result.get(2).viewCount()).isEqualTo(100L); + } + + @Test + @DisplayName("findPopularPostsWithCursor - lastPostId 지정 시 해당 ID 이후 게시글만 조회") + void findPopularPostsWithCursor_NextPage_FilterByLastPostId() { + // Given: 게시글 5개 생성 + Post post1 = createPost("게시글1", techBlog1, LocalDateTime.now().minusDays(5), 500L); + Post post2 = createPost("게시글2", techBlog1, LocalDateTime.now().minusDays(4), 400L); + Post post3 = createPost("게시글3", techBlog1, LocalDateTime.now().minusDays(3), 300L); + Post post4 = createPost("게시글4", techBlog1, LocalDateTime.now().minusDays(2), 200L); + Post post5 = createPost("게시글5", techBlog1, LocalDateTime.now().minusDays(1), 100L); + postRepository.saveAll(List.of(post1, post2, post3, post4, post5)); + + // When: lastPostId = post3.id (커서 기준) + PageRequest pageRequest = PageRequest.of(0, 10); + List result = postRepository.findPopularPostsWithCursor(post3.getId(), pageRequest); + + // Then: post3.id보다 작은 ID만 반환 (post4, post5는 제외) + assertThat(result).hasSize(2); + assertThat(result).allMatch(dto -> dto.id() < post3.getId()); + } + + @Test + @DisplayName("findRecentPostsWithCursor - 최신 발행일 순으로 정렬") + void findRecentPostsWithCursor_OrderByPublishedAtDesc() { + // Given: 발행일이 다른 게시글 3개 + LocalDateTime now = LocalDateTime.now(); + Post post1 = createPost("게시글1", techBlog1, now.minusDays(3), 100L); + Post post2 = createPost("게시글2", techBlog1, now.minusDays(1), 200L); + Post post3 = createPost("게시글3", techBlog1, now.minusDays(2), 300L); + postRepository.saveAll(List.of(post1, post2, post3)); + + // When: 최신순 조회 + PageRequest pageRequest = PageRequest.of(0, 10); + List result = postRepository.findRecentPostsWithCursor(null, pageRequest); + + // Then: 발행일 최신순 (post2 > post3 > post1) + assertThat(result).hasSize(3); + assertThat(result.get(0).publishedAt()).isAfter(result.get(1).publishedAt()); + assertThat(result.get(1).publishedAt()).isAfter(result.get(2).publishedAt()); + } + + @Test + @DisplayName("findByCompanyWithCursor - company가 null이면 모든 게시글 조회") + void findByCompanyWithCursor_NullCompany_ReturnsAll() { + // Given: 다른 회사의 게시글 2개 + Post kakaoPost = createPost("카카오 게시글", techBlog1, LocalDateTime.now(), 100L); + Post naverPost = createPost("네이버 게시글", techBlog2, LocalDateTime.now(), 200L); + postRepository.saveAll(List.of(kakaoPost, naverPost)); + + // When: company = null + PageRequest pageRequest = PageRequest.of(0, 10); + List result = postRepository.findByCompanyWithCursor(null, null, pageRequest); + + // Then: 모든 게시글 반환 + assertThat(result).hasSize(2); + assertThat(result).extracting(PostInfoDto::company) + .containsExactlyInAnyOrder("카카오", "네이버"); + } + + @Test + @DisplayName("findByCompanyWithCursor - company 지정 시 해당 회사 게시글만 조회") + void findByCompanyWithCursor_SpecificCompany_ReturnsFiltered() { + // Given: 다른 회사의 게시글 3개 + Post kakaoPost1 = createPost("카카오 게시글1", techBlog1, LocalDateTime.now().minusDays(2), 100L); + Post kakaoPost2 = createPost("카카오 게시글2", techBlog1, LocalDateTime.now().minusDays(1), 200L); + Post naverPost = createPost("네이버 게시글", techBlog2, LocalDateTime.now(), 300L); + postRepository.saveAll(List.of(kakaoPost1, kakaoPost2, naverPost)); + + // When: company = "카카오" + PageRequest pageRequest = PageRequest.of(0, 10); + List result = postRepository.findByCompanyWithCursor("카카오", null, pageRequest); + + // Then: 카카오 게시글만 반환 + assertThat(result).hasSize(2); + assertThat(result).allMatch(dto -> dto.company().equals("카카오")); + } + + @Test + @DisplayName("findByIdWithTechBlog - JOIN하여 게시글 상세 정보 조회 성공") + void findByIdWithTechBlog_Success_ReturnsPostDetailDto() { + // Given: 게시글 생성 + Post post = createPost("테스트 게시글", techBlog1, LocalDateTime.now(), 100L); + post = postRepository.save(post); + + // When: ID로 조회 + Optional result = postRepository.findByIdWithTechBlog(post.getId()); + + // Then: PostDetailDto 프로젝션 성공 + assertThat(result).isPresent(); + PostDetailDto dto = result.get(); + assertThat(dto.id()).isEqualTo(post.getId()); + assertThat(dto.title()).isEqualTo("테스트 게시글"); + assertThat(dto.company()).isEqualTo("카카오"); + assertThat(dto.viewCount()).isEqualTo(100L); + assertThat(dto.keywords()).isNull(); // 키워드는 별도 조회 + } + + @Test + @DisplayName("findByIdWithTechBlog - 존재하지 않는 ID 조회 시 Empty 반환") + void findByIdWithTechBlog_NotFound_ReturnsEmpty() { + // When: 존재하지 않는 ID 조회 + Optional result = postRepository.findByIdWithTechBlog(99999L); + + // Then: Empty 반환 + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findDistinctCompanies - 중복 없이 회사 목록 조회") + void findDistinctCompanies_ReturnsUniqueCompanies() { + // Given: 같은 회사의 게시글 여러 개 + Post kakaoPost1 = createPost("카카오 게시글1", techBlog1, LocalDateTime.now(), 100L); + Post kakaoPost2 = createPost("카카오 게시글2", techBlog1, LocalDateTime.now(), 200L); + Post naverPost = createPost("네이버 게시글", techBlog2, LocalDateTime.now(), 300L); + postRepository.saveAll(List.of(kakaoPost1, kakaoPost2, naverPost)); + + // When: 회사 목록 조회 + List result = postRepository.findDistinctCompanies(); + + // Then: 중복 없이 2개 회사 반환 + assertThat(result).hasSize(2); + assertThat(result).containsExactlyInAnyOrder("카카오", "네이버"); + } + + @Test + @DisplayName("커서 페이징 - size+1 조회하여 hasNext 판단 가능") + void cursorPaging_SizePlusOne_CanDetermineHasNext() { + // Given: 게시글 5개 + for (int i = 1; i <= 5; i++) { + Post post = createPost("게시글" + i, techBlog1, LocalDateTime.now().minusDays(i), (long) (i * 100)); + postRepository.save(post); + } + + // When: size = 3으로 조회 (실제로는 4개 조회) + PageRequest pageRequest = PageRequest.of(0, 4); + List result = postRepository.findRecentPostsWithCursor(null, pageRequest); + + // Then: 4개 조회되면 hasNext = true + assertThat(result).hasSize(4); + boolean hasNext = result.size() > 3; + assertThat(hasNext).isTrue(); + } + + // 헬퍼 메서드 + private Post createPost(String title, TechBlog techBlog, LocalDateTime publishedAt, Long viewCount) { + Post post = Post.builder() + .title(title) + .fullContent("

" + title + " 내용

") + .plainContent(title + " 내용") + .company(techBlog.getCompanyName()) + .url("https://test.com/" + title) + .publishedAt(publishedAt) + .crawledAt(LocalDateTime.now()) + .techBlog(techBlog) + .build(); + + // viewCount 설정 (reflection 또는 여러 번 증가) + for (int i = 0; i < viewCount; i++) { + post.incrementViewCount(); + } + + return post; + } +} From 922f7fc4f03f9bc46db03d1c5b722628cb94028b Mon Sep 17 00:00:00 2001 From: dmori Date: Fri, 26 Dec 2025 23:26:43 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20PostQueryService=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/service/PostQueryServiceTest.java | 298 ++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 src/test/java/com/techfork/domain/post/service/PostQueryServiceTest.java diff --git a/src/test/java/com/techfork/domain/post/service/PostQueryServiceTest.java b/src/test/java/com/techfork/domain/post/service/PostQueryServiceTest.java new file mode 100644 index 0000000..4f2b7be --- /dev/null +++ b/src/test/java/com/techfork/domain/post/service/PostQueryServiceTest.java @@ -0,0 +1,298 @@ +package com.techfork.domain.post.service; + +import com.techfork.domain.post.converter.PostConverter; +import com.techfork.domain.post.dto.CompanyListResponse; +import com.techfork.domain.post.dto.PostDetailDto; +import com.techfork.domain.post.dto.PostInfoDto; +import com.techfork.domain.post.dto.PostListResponse; +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.post.entity.PostKeyword; +import com.techfork.domain.post.enums.EPostSortType; +import com.techfork.domain.post.repository.PostKeywordRepository; +import com.techfork.domain.post.repository.PostRepository; +import com.techfork.global.exception.GeneralException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +/** + * PostQueryService 단위 테스트 + * - Repository와 Converter를 Mock으로 대체 + * - 비즈니스 로직만 검증 + */ +@ExtendWith(MockitoExtension.class) +class PostQueryServiceTest { + + @Mock + private PostRepository postRepository; + + @Mock + private PostKeywordRepository postKeywordRepository; + + @Mock + private PostConverter postConverter; + + @InjectMocks + private PostQueryService postQueryService; + + @Test + @DisplayName("getCompanies() - 회사 목록 조회 성공") + void getCompanies_Success() { + // Given + List mockCompanies = List.of("카카오", "네이버", "라인"); + CompanyListResponse expectedResponse = new CompanyListResponse(mockCompanies); + + given(postRepository.findDistinctCompanies()).willReturn(mockCompanies); + given(postConverter.toCompanyListResponse(mockCompanies)).willReturn(expectedResponse); + + // When + CompanyListResponse result = postQueryService.getCompanies(); + + // Then + assertThat(result).isNotNull(); + assertThat(result.companies()).hasSize(3); + assertThat(result.companies()).contains("카카오", "네이버", "라인"); + + verify(postRepository, times(1)).findDistinctCompanies(); + verify(postConverter, times(1)).toCompanyListResponse(mockCompanies); + } + + @Test + @DisplayName("getPostDetail() - 게시글 상세 조회 성공") + void getPostDetail_Success() { + // Given + Long postId = 1L; + + PostDetailDto mockPostDetail = PostDetailDto.builder() + .id(postId) + .title("테스트 제목") + .summary("테스트 요약") + .company("카카오") + .url("https://test.com/1") + .logoUrl("https://test.com/logo.png") + .publishedAt(LocalDateTime.now()) + .viewCount(100L) + .keywords(null) // 키워드는 나중에 추가됨 + .build(); + + PostKeyword keyword1 = mock(PostKeyword.class); + when(keyword1.getKeyword()).thenReturn("Java"); + PostKeyword keyword2 = mock(PostKeyword.class); + when(keyword2.getKeyword()).thenReturn("Spring"); + + List mockKeywords = List.of(keyword1, keyword2); + List keywordStrings = List.of("Java", "Spring"); + + PostDetailDto expectedResponse = PostDetailDto.builder() + .id(postId) + .title("테스트 제목") + .summary("테스트 요약") + .company("카카오") + .url("https://test.com/1") + .logoUrl("https://test.com/logo.png") + .publishedAt(mockPostDetail.publishedAt()) + .viewCount(100L) + .keywords(keywordStrings) + .build(); + + given(postRepository.findByIdWithTechBlog(postId)).willReturn(Optional.of(mockPostDetail)); + given(postKeywordRepository.findByPostIdIn(List.of(postId))).willReturn(mockKeywords); + given(postConverter.toPostDetailDto(mockPostDetail, keywordStrings)).willReturn(expectedResponse); + + // When + PostDetailDto result = postQueryService.getPostDetail(postId); + + // Then + assertThat(result).isNotNull(); + assertThat(result.id()).isEqualTo(postId); + assertThat(result.title()).isEqualTo("테스트 제목"); + assertThat(result.viewCount()).isEqualTo(100L); + assertThat(result.keywords()).hasSize(2); + assertThat(result.keywords()).contains("Java", "Spring"); + + verify(postRepository, times(1)).findByIdWithTechBlog(postId); + verify(postKeywordRepository, times(1)).findByPostIdIn(List.of(postId)); + verify(postConverter, times(1)).toPostDetailDto(mockPostDetail, keywordStrings); + } + + @Test + @DisplayName("getPostDetail() - 존재하지 않는 게시글 조회 시 예외 발생") + void getPostDetail_NotFound_ThrowsException() { + // Given + Long postId = 999L; + given(postRepository.findByIdWithTechBlog(postId)).willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> postQueryService.getPostDetail(postId)) + .isInstanceOf(GeneralException.class); + + verify(postRepository, times(1)).findByIdWithTechBlog(postId); + verify(postKeywordRepository, never()).findByPostIdIn(any()); + } + + @Test + @DisplayName("getRecentPosts() - LATEST 정렬로 최근 게시글 조회") + void getRecentPosts_Latest_Success() { + // Given + EPostSortType sortBy = EPostSortType.LATEST; + Long lastPostId = null; + int size = 20; + + List mockPosts = List.of( + PostInfoDto.builder() + .id(2L) + .title("게시글 2") + .company("카카오") + .url("https://test.com/2") + .logoUrl("https://test.com/logo.png") + .publishedAt(LocalDateTime.now()) + .viewCount(50L) + .keywords(null) + .build(), + PostInfoDto.builder() + .id(1L) + .title("게시글 1") + .company("네이버") + .url("https://test.com/1") + .logoUrl("https://test.com/logo.png") + .publishedAt(LocalDateTime.now().minusDays(1)) + .viewCount(100L) + .keywords(null) + .build() + ); + + PostListResponse expectedResponse = PostListResponse.builder() + .posts(mockPosts) + .lastPostId(1L) + .hasNext(false) + .build(); + + given(postRepository.findRecentPostsWithCursor(eq(lastPostId), any(PageRequest.class))) + .willReturn(mockPosts); + given(postKeywordRepository.findByPostIdIn(any())).willReturn(List.of()); + given(postConverter.toPostListResponse(any(), eq(size))).willReturn(expectedResponse); + + // When + PostListResponse result = postQueryService.getRecentPosts(sortBy, lastPostId, size); + + // Then + assertThat(result).isNotNull(); + assertThat(result.posts()).hasSize(2); + assertThat(result.hasNext()).isFalse(); + + verify(postRepository, times(1)).findRecentPostsWithCursor(eq(lastPostId), any(PageRequest.class)); + verify(postRepository, never()).findPopularPostsWithCursor(any(), any()); + } + + @Test + @DisplayName("getRecentPosts() - POPULAR 정렬로 인기 게시글 조회") + void getRecentPosts_Popular_Success() { + // Given + EPostSortType sortBy = EPostSortType.POPULAR; + Long lastPostId = null; + int size = 20; + + List mockPosts = List.of( + PostInfoDto.builder() + .id(1L) + .title("인기 게시글 1") + .company("카카오") + .url("https://test.com/1") + .logoUrl("https://test.com/logo.png") + .publishedAt(LocalDateTime.now()) + .viewCount(1000L) + .keywords(null) + .build(), + PostInfoDto.builder() + .id(2L) + .title("인기 게시글 2") + .company("네이버") + .url("https://test.com/2") + .logoUrl("https://test.com/logo.png") + .publishedAt(LocalDateTime.now()) + .viewCount(500L) + .keywords(null) + .build() + ); + + PostListResponse expectedResponse = PostListResponse.builder() + .posts(mockPosts) + .lastPostId(2L) + .hasNext(false) + .build(); + + given(postRepository.findPopularPostsWithCursor(eq(lastPostId), any(PageRequest.class))) + .willReturn(mockPosts); + given(postKeywordRepository.findByPostIdIn(any())).willReturn(List.of()); + given(postConverter.toPostListResponse(any(), eq(size))).willReturn(expectedResponse); + + // When + PostListResponse result = postQueryService.getRecentPosts(sortBy, lastPostId, size); + + // Then + assertThat(result).isNotNull(); + assertThat(result.posts()).hasSize(2); + assertThat(result.posts().get(0).viewCount()).isGreaterThan(result.posts().get(1).viewCount()); + + verify(postRepository, times(1)).findPopularPostsWithCursor(eq(lastPostId), any(PageRequest.class)); + verify(postRepository, never()).findRecentPostsWithCursor(any(), any()); + } + + @Test + @DisplayName("getPostsByCompany() - 특정 회사의 게시글 조회") + void getPostsByCompany_Success() { + // Given + String company = "카카오"; + Long lastPostId = null; + int size = 20; + + List mockPosts = List.of( + PostInfoDto.builder() + .id(1L) + .title("카카오 게시글") + .company(company) + .url("https://test.com/1") + .logoUrl("https://test.com/logo.png") + .publishedAt(LocalDateTime.now()) + .viewCount(50L) + .keywords(null) + .build() + ); + + PostListResponse expectedResponse = PostListResponse.builder() + .posts(mockPosts) + .lastPostId(1L) + .hasNext(false) + .build(); + + given(postRepository.findByCompanyWithCursor(eq(company), eq(lastPostId), any(PageRequest.class))) + .willReturn(mockPosts); + given(postKeywordRepository.findByPostIdIn(any())).willReturn(List.of()); + given(postConverter.toPostListResponse(any(), eq(size))).willReturn(expectedResponse); + + // When + PostListResponse result = postQueryService.getPostsByCompany(company, lastPostId, size); + + // Then + assertThat(result).isNotNull(); + assertThat(result.posts()).hasSize(1); + assertThat(result.posts().get(0).company()).isEqualTo(company); + + verify(postRepository, times(1)).findByCompanyWithCursor(eq(company), eq(lastPostId), any(PageRequest.class)); + } +} From 34efa411ee7db1d25c4c61fee7fcf4a3d7e66e73 Mon Sep 17 00:00:00 2001 From: dmori Date: Fri, 26 Dec 2025 23:27:09 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=EB=A5=BC=20=ED=86=B5=ED=95=9C=20PostController=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 6 + .../PostControllerIntegrationTest.java | 164 ++++++++++++++++++ src/test/resources/application-test.yml | 19 ++ 3 files changed, 189 insertions(+) create mode 100644 src/test/java/com/techfork/domain/post/controller/PostControllerIntegrationTest.java create mode 100644 src/test/resources/application-test.yml diff --git a/build.gradle b/build.gradle index 84bf543..5375948 100644 --- a/build.gradle +++ b/build.gradle @@ -35,10 +35,16 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // Testcontainers for integration tests + testImplementation 'org.testcontainers:testcontainers:1.19.3' + testImplementation 'org.testcontainers:mysql:1.19.3' + testImplementation 'org.testcontainers:junit-jupiter:1.19.3' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-redis' runtimeOnly 'com.mysql:mysql-connector-j' + testRuntimeOnly 'com.h2database:h2' // 테스트용 in-memory database developmentOnly 'org.springframework.boot:spring-boot-devtools' compileOnly 'org.projectlombok:lombok' diff --git a/src/test/java/com/techfork/domain/post/controller/PostControllerIntegrationTest.java b/src/test/java/com/techfork/domain/post/controller/PostControllerIntegrationTest.java new file mode 100644 index 0000000..12d02dd --- /dev/null +++ b/src/test/java/com/techfork/domain/post/controller/PostControllerIntegrationTest.java @@ -0,0 +1,164 @@ +package com.techfork.domain.post.controller; + +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.post.entity.PostKeyword; +import com.techfork.domain.post.repository.PostKeywordRepository; +import com.techfork.domain.post.repository.PostRepository; +import com.techfork.domain.source.entity.TechBlog; +import com.techfork.domain.source.repository.TechBlogRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * PostController 통합 테스트 + * - @SpringBootTest: 전체 애플리케이션 컨텍스트 로드 + * - Testcontainers: 실제 MySQL 컨테이너로 통합 테스트 + * - 모든 레이어(Controller, Service, Repository) 통합 테스트 + * - MockMvc로 HTTP 요청/응답 테스트 + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@Testcontainers +class PostControllerIntegrationTest { + + @Container + static MySQLContainer mysql = new MySQLContainer<>("mysql:8.0") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", mysql::getJdbcUrl); + registry.add("spring.datasource.username", mysql::getUsername); + registry.add("spring.datasource.password", mysql::getPassword); + } + + @Autowired + private MockMvc mockMvc; + + @Autowired + private PostRepository postRepository; + + @Autowired + private PostKeywordRepository postKeywordRepository; + + @Autowired + private TechBlogRepository techBlogRepository; + + private TechBlog testTechBlog; + private Post testPost1; + private Post testPost2; + + @BeforeEach + void setUp() { + // Given: 실제 DB에 테스트 데이터 저장 + testTechBlog = TechBlog.builder() + .companyName("테스트 회사") + .blogUrl("https://test.com") + .rssUrl("https://test.com/rss") + .build(); + techBlogRepository.save(testTechBlog); + + testPost1 = Post.builder() + .title("테스트 게시글 1") + .fullContent("

전체 내용 1

") + .plainContent("전체 내용 1") + .company("테스트 회사") + .url("https://test.com/post/1") + .publishedAt(LocalDateTime.of(2025, 1, 1, 10, 0)) + .crawledAt(LocalDateTime.now()) + .techBlog(testTechBlog) + .build(); + testPost1 = postRepository.save(testPost1); + + // 키워드 추가 + PostKeyword keyword1 = PostKeyword.create("Java", testPost1); + PostKeyword keyword2 = PostKeyword.create("Spring", testPost1); + postKeywordRepository.saveAll(List.of(keyword1, keyword2)); + + testPost2 = Post.builder() + .title("테스트 게시글 2") + .fullContent("

전체 내용 2

") + .plainContent("전체 내용 2") + .company("테스트 회사") + .url("https://test.com/post/2") + .publishedAt(LocalDateTime.of(2025, 1, 2, 10, 0)) + .crawledAt(LocalDateTime.now()) + .techBlog(testTechBlog) + .build(); + testPost2 = postRepository.save(testPost2); + + PostKeyword keyword3 = PostKeyword.create("Kotlin", testPost2); + postKeywordRepository.save(keyword3); + } + + @Test + @DisplayName("GET /api/v1/posts/{postId} - 게시글 상세 조회 성공") + void getPostDetail_Success() throws Exception { + // When & Then: 실제 DB에 저장된 데이터로 HTTP 요청 테스트 + mockMvc.perform(get("/api/v1/posts/{postId}", testPost1.getId())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(testPost1.getId())) + .andExpect(jsonPath("$.data.title").value("테스트 게시글 1")) + .andExpect(jsonPath("$.data.company").value("테스트 회사")) + .andExpect(jsonPath("$.data.keywords").isArray()) + .andExpect(jsonPath("$.data.keywords.length()").value(2)); + } + + @Test + @DisplayName("GET /api/v1/posts/recent - 최근 게시글 조회 성공") + void getRecentPosts_Success() throws Exception { + // When & Then: 실제 DB에서 최근 게시글 조회 + mockMvc.perform(get("/api/v1/posts/recent") + .param("sortBy", "LATEST") + .param("size", "20")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.posts").isArray()) + .andExpect(jsonPath("$.data.posts.length()").value(2)) + .andExpect(jsonPath("$.data.hasNext").value(false)); + } + + @Test + @DisplayName("GET /api/v1/posts/companies - 회사 목록 조회 성공") + void getCompanies_Success() throws Exception { + // When & Then: 실제 DB에서 회사 목록 조회 + mockMvc.perform(get("/api/v1/posts/companies")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.companies").isArray()) + .andExpect(jsonPath("$.data.companies[0]").value("테스트 회사")) + .andExpect(jsonPath("$.data.companies.length()").value(1)); + } + + @Test + @DisplayName("GET /api/v1/posts/{postId} - 존재하지 않는 게시글 조회 시 404") + void getPostDetail_NotFound() throws Exception { + // When & Then: 존재하지 않는 게시글 조회 + mockMvc.perform(get("/api/v1/posts/{postId}", 99999L)) + .andDo(print()) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..60375c2 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,19 @@ +spring: + jpa: + hibernate: + ddl-auto: create-drop # 테스트 시작 시 테이블 생성, 종료 시 삭제 + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.H2Dialect + + datasource: + url: jdbc:h2:mem:testdb # in-memory database + driver-class-name: org.h2.Driver + username: sa + password: + + h2: + console: + enabled: false