Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.techfork.domain.post.controller;

import com.techfork.domain.post.dto.CompanyListResponse;
import com.techfork.domain.post.service.PostQueryService;
import com.techfork.global.common.code.SuccessCode;
import com.techfork.global.response.BaseResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Post V2", description = "게시글 API V2")
@Slf4j
@RestController
@RequestMapping("/api/v2/posts")
@RequiredArgsConstructor
public class PostControllerV2 {

private final PostQueryService postQueryService;

@Operation(
summary = "게시글이 있는 회사 목록 조회 (V2)",
description = """
게시글이 존재하는 회사 목록을 조회합니다.
- 최신 게시글 발행일 기준으로 정렬
- 오늘 발행된 게시글이 있는지 여부 포함
- 회사 로고 URL 포함
"""
)
@GetMapping("/companies")
public ResponseEntity<BaseResponse<CompanyListResponse>> getCompanies() {
CompanyListResponse response = postQueryService.getCompaniesV2();
return BaseResponse.of(SuccessCode.OK, response);
}
}
Original file line number Diff line number Diff line change
@@ -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.*;
import org.springframework.stereotype.Component;

import java.util.List;
Expand All @@ -17,6 +14,13 @@ public CompanyListResponse toCompanyListResponse(List<String> companies) {
.build();
}

public CompanyListResponse toCompanyListResponseV2(List<CompanyDto> companies) {
return CompanyListResponse.builder()
.totalNumber(companies.size())
.companies(companies)
.build();
}

public PostListResponse toPostListResponse(List<PostInfoDto> posts, int requestedSize) {
boolean hasNext = posts.size() > requestedSize;
List<PostInfoDto> content = hasNext ? posts.subList(0, requestedSize) : posts;
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/com/techfork/domain/post/dto/CompanyDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.techfork.domain.post.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;

@Builder
@Schema(name = "CompanyDto", description = "회사 정보")
public record CompanyDto(
String company,
boolean hasNewPost,
String logoUrl
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
@Builder
@Schema(name = "CompanyListResponse")
public record CompanyListResponse(
List<String> companies
Integer totalNumber,
@Schema(description = "회사 목록 (V1: String, V2: CompanyDto)")
List<?> companies
) {
}
6 changes: 5 additions & 1 deletion src/main/java/com/techfork/domain/post/entity/Post.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
import java.util.List;

@Entity
@Table(name = "posts")
@Table(name = "posts", indexes = {
@Index(name = "idx_post_published_at", columnList = "publishedAt"),
@Index(name = "idx_post_view_count_id", columnList = "viewCount, publishedAt"),
@Index(name = "idx_post_company_published_at", columnList = "company, publishedAt")
})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post extends BaseEntity {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.techfork.domain.post.repository;

import com.techfork.domain.post.dto.CompanyDto;
import com.techfork.domain.post.dto.PostDetailDto;
import com.techfork.domain.post.dto.PostInfoDto;
import com.techfork.domain.post.entity.Post;
Expand Down Expand Up @@ -35,13 +36,25 @@ public interface PostRepository extends JpaRepository<Post, Long> {
@Query("SELECT DISTINCT p.company FROM Post p ORDER BY p.company")
List<String> findDistinctCompanies();

@Query("""
SELECT new com.techfork.domain.post.dto.CompanyDto(
p.company,
(COUNT(CASE WHEN p.publishedAt >= CURRENT_DATE THEN 1 END) > 0),
MAX(p.logoUrl)
)
FROM Post p
GROUP BY p.company
ORDER BY MAX(p.publishedAt) DESC
""")
List<CompanyDto> findCompaniesWithDetails();

@Query("""
SELECT new com.techfork.domain.post.dto.PostInfoDto(
p.id, p.title, p.company, p.url, p.logoUrl, p.publishedAt, p.viewCount, null)
FROM Post p
WHERE (:company IS NULL OR p.company = :company)
AND (:lastPostId IS NULL OR p.id < :lastPostId)
ORDER BY p.id DESC
ORDER BY p.publishedAt DESC
""")
List<PostInfoDto> findByCompanyWithCursor(
@Param("company") String company,
Expand All @@ -54,7 +67,7 @@ List<PostInfoDto> findByCompanyWithCursor(
p.id, p.title, p.company, p.url, p.logoUrl, p.publishedAt, p.viewCount, null)
FROM Post p
WHERE :lastPostId IS NULL OR p.id < :lastPostId
ORDER BY p.publishedAt DESC, p.id DESC
ORDER BY p.publishedAt DESC
""")
List<PostInfoDto> findRecentPostsWithCursor(
@Param("lastPostId") Long lastPostId,
Expand All @@ -66,7 +79,7 @@ List<PostInfoDto> findRecentPostsWithCursor(
p.id, p.title, p.company, p.url, p.logoUrl, p.publishedAt, p.viewCount, null)
FROM Post p
WHERE :lastPostId IS NULL OR p.id < :lastPostId
ORDER BY p.viewCount DESC, p.id DESC
ORDER BY p.viewCount DESC, p.publishedAt DESC
""")
List<PostInfoDto> findPopularPostsWithCursor(
@Param("lastPostId") Long lastPostId,
Expand Down
Original file line number Diff line number Diff line change
@@ -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.*;
import com.techfork.domain.post.entity.PostKeyword;
import com.techfork.domain.post.enums.EPostSortType;
import com.techfork.domain.post.repository.PostKeywordRepository;
Expand Down Expand Up @@ -36,6 +33,11 @@ public CompanyListResponse getCompanies() {
return postConverter.toCompanyListResponse(companies);
}

public CompanyListResponse getCompaniesV2() {
List<CompanyDto> companies = postRepository.findCompaniesWithDetails();
return postConverter.toCompanyListResponseV2(companies);
}

public PostListResponse getPostsByCompany(String company, Long lastPostId, int size) {
PageRequest pageRequest = PageRequest.of(0, size + 1);
List<PostInfoDto> posts = postRepository.findByCompanyWithCursor(company, lastPostId, pageRequest);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package com.techfork.domain.post.controller;

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.source.repository.TechBlogRepository;
import com.techfork.global.configuration.MySQLTestConfig;
import org.junit.jupiter.api.AfterEach;
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.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

import java.time.LocalDate;
import java.time.LocalDateTime;

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;

/**
* PostControllerV2 통합 테스트
* - @SpringBootTest: 전체 애플리케이션 컨텍스트 로드
* - MySQLTestConfig.class: 실제 MySQL 컨테이너로 통합 테스트
* - 모든 레이어(Controller, Service, Repository) 통합 테스트
* - MockMvc로 HTTP 요청/응답 테스트
*/
@SpringBootTest
@AutoConfigureMockMvc
@Import(MySQLTestConfig.class)
@ActiveProfiles("integrationtest")
class PostControllerV2IntegrationTest {

@Autowired
private MockMvc mockMvc;

@Autowired
private PostRepository postRepository;

@Autowired
private TechBlogRepository techBlogRepository;

private TechBlog testTechBlog1;
private TechBlog testTechBlog2;
private Post todayPost;
private Post oldPost;

@BeforeEach
void setUp() {
// Given: 실제 DB에 테스트 데이터 저장
testTechBlog1 = TechBlog.builder()
.companyName("카카오")
.blogUrl("https://kakao.com")
.rssUrl("https://kakao.com/rss")
.logoUrl("https://kakao.com/logo.png")
.build();
techBlogRepository.save(testTechBlog1);

testTechBlog2 = TechBlog.builder()
.companyName("네이버")
.blogUrl("https://naver.com")
.rssUrl("https://naver.com/rss")
.logoUrl("https://naver.com/logo.png")
.build();
techBlogRepository.save(testTechBlog2);

// 오늘 발행된 게시글 (카카오)
todayPost = Post.builder()
.title("오늘의 게시글")
.fullContent("<p>오늘 내용</p>")
.plainContent("오늘 내용")
.company("카카오")
.url("https://kakao.com/post/today")
.logoUrl("https://kakao.com/logo.png")
.publishedAt(LocalDate.now().atStartOfDay())
.crawledAt(LocalDateTime.now())
.techBlog(testTechBlog1)
.build();
postRepository.save(todayPost);

// 어제 발행된 게시글 (네이버)
oldPost = Post.builder()
.title("어제의 게시글")
.fullContent("<p>어제 내용</p>")
.plainContent("어제 내용")
.company("네이버")
.url("https://naver.com/post/old")
.logoUrl("https://naver.com/logo.png")
.publishedAt(LocalDate.now().minusDays(1).atStartOfDay())
.crawledAt(LocalDateTime.now())
.techBlog(testTechBlog2)
.build();
postRepository.save(oldPost);
}

@AfterEach
void tearDown() {
// 테스트 데이터 정리 (외래키 제약조건 순서 고려)
postRepository.deleteAll();
techBlogRepository.deleteAll();
}

@Test
@DisplayName("GET /api/v2/posts/companies - 회사 목록 상세 조회 성공")
void getCompanies_Success() throws Exception {
// When & Then: 실제 DB에서 회사 상세 정보 조회
mockMvc.perform(get("/api/v2/posts/companies"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.totalNumber").value(2))
.andExpect(jsonPath("$.data.companies").isArray())
.andExpect(jsonPath("$.data.companies.length()").value(2))
// 첫 번째 회사 (카카오 - 오늘 발행)
.andExpect(jsonPath("$.data.companies[0].company").value("카카오"))
.andExpect(jsonPath("$.data.companies[0].hasNewPost").value(true))
.andExpect(jsonPath("$.data.companies[0].logoUrl").value("https://kakao.com/logo.png"))
// 두 번째 회사 (네이버 - 어제 발행)
.andExpect(jsonPath("$.data.companies[1].company").value("네이버"))
.andExpect(jsonPath("$.data.companies[1].hasNewPost").value(false))
.andExpect(jsonPath("$.data.companies[1].logoUrl").value("https://naver.com/logo.png"));
}

@Test
@DisplayName("GET /api/v2/posts/companies - 게시글이 없는 경우 빈 배열 반환")
void getCompanies_EmptyWhenNoPosts() throws Exception {
// Given: 모든 게시글 삭제
postRepository.deleteAll();

// When & Then: 빈 배열 반환 확인
mockMvc.perform(get("/api/v2/posts/companies"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.totalNumber").value(0))
.andExpect(jsonPath("$.data.companies").isArray())
.andExpect(jsonPath("$.data.companies.length()").value(0));
}

@Test
@DisplayName("GET /api/v2/posts/companies - 발행일 기준 정렬 확인")
void getCompanies_SortedByLatestPublishedAt() throws Exception {
// Given: 새로운 게시글 추가 (네이버가 최근)
Post newerPost = Post.builder()
.title("최신 네이버 게시글")
.fullContent("<p>최신 내용</p>")
.plainContent("최신 내용")
.company("네이버")
.url("https://naver.com/post/newest")
.logoUrl("https://naver.com/logo.png")
.publishedAt(LocalDateTime.now())
.crawledAt(LocalDateTime.now())
.techBlog(testTechBlog2)
.build();
postRepository.save(newerPost);

// When & Then: 네이버가 첫 번째로 정렬되는지 확인
mockMvc.perform(get("/api/v2/posts/companies"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.companies[0].company").value("네이버"))
.andExpect(jsonPath("$.data.companies[1].company").value("카카오"));
}
}
Loading
Loading