Skip to content
Open
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
32 changes: 21 additions & 11 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -52,23 +52,27 @@ dependencies {
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
implementation 'io.jsonwebtoken:jjwt-api:0.11.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.3'

testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
testImplementation 'io.rest-assured:spring-mock-mvc:5.4.0'
testImplementation 'org.mockito:mockito-core:5.12.0'
testImplementation 'org.assertj:assertj-core:3.26.0'

// WebClient
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'com.fasterxml.jackson.core:jackson-annotations'
implementation 'com.fasterxml.jackson.core:jackson-core'
// WebClient
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'com.fasterxml.jackson.core:jackson-annotations'
implementation 'com.fasterxml.jackson.core:jackson-core'

// 헬스 체크 api 를 사용하기 위한 Actuator 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-actuator'
// 헬스 체크 api 를 사용하기 위한 Actuator 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}

test {
useJUnitPlatform()
}

jacoco {
Expand All @@ -93,6 +97,9 @@ tasks.named('jacocoTestReport', JacocoReport) {
"**/*request*",
"**/*response*",
"**/generated/querydsl/**",
"**/exception/**",
"**/test/**",
"**/resolver/**",
"**/Q*.*"
)
}
Expand Down Expand Up @@ -130,6 +137,9 @@ tasks.named('jacocoTestCoverageVerification', JacocoCoverageVerification) {
"**/*request*",
"**/*response*",
"**/generated/querydsl/**",
"**/exception/**",
"**/test/**",
"**/resolver/**",
"**/Q*.*"
)
}
Expand Down Expand Up @@ -163,4 +173,4 @@ tasks.named('check') {

tasks.named('sonarqube') {
dependsOn tasks.jacocoTestReport
}
}
12 changes: 6 additions & 6 deletions src/main/java/Konkuk/U2E/domain/pin/service/PinListService.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public GetPinListResponse getPinList(String region, String climate, Long newsId)
ClimateProblem climateProblem = ClimateProblem.fromString(climate);
return GetPinListResponse.of(getPinListByClimate(climateProblem));
}
if (region == null && climate == null && newsId != null) {
if (region == null && climate == null) {
return GetPinListResponse.of(getPinListByNewsId(newsId));
}

Expand Down Expand Up @@ -109,15 +109,15 @@ private PinInfo createPinInfo(Pin pin) {
}

private PinInfo createPinInfo(Pin pin, ClimateProblem climateProblem) {
List<News> newsList = newsPinRepository.findNewsByPinId(pin.getPinId());
List<News> newsList = newsPinRepository.findNewsByPinId(pin.getPinId()).stream()
.filter(news -> climateRepository.existsByNewsAndClimateProblem(news, climateProblem)) //사용자가 필터링을 요청한 ClimateProblem을 포함하지 않는 경우 제거
.toList();

if (newsList.isEmpty()) { // 뉴스가 없는 핀은 존재할 수 없으므로 예외처리
throw new PinNewsNotFoundException(PINNEWS_NOT_FOUND);
}

News latestNews = dateUtil.getLatestNews(
newsList.stream()
.filter(news -> climateRepository.existsByNewsAndClimateProblem(news, climateProblem)).toList() //사용자가 필터링을 요청한 ClimateProblem을 포함하지 않는 경우 제거
);
News latestNews = dateUtil.getLatestNews(newsList);
boolean isLately = dateUtil.checkLately(latestNews.getNewsDate());

List<ClimateProblem> climateProblems = climateRepository.findClimatesByNews(latestNews).stream()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package Konkuk.U2E.domain.news.controller;

import Konkuk.U2E.domain.news.dto.response.GetLatelyNewsResponse;
import Konkuk.U2E.domain.news.dto.response.GetNewsInfoResponse;
import Konkuk.U2E.domain.news.dto.response.LatelyNews;
import Konkuk.U2E.domain.news.service.NewsInfoService;
import Konkuk.U2E.domain.news.service.NewsLatelyService;
import Konkuk.U2E.global.openApi.gemini.dto.response.RelatedArticle;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
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.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import java.util.List;

import static org.hamcrest.Matchers.*;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@ActiveProfiles("test")
@SpringBootTest
@AutoConfigureMockMvc
class NewsControllerTest {

@Autowired
MockMvc mockMvc;

@MockitoBean
NewsLatelyService newsLatelyService;

@MockitoBean
NewsInfoService newsInfoService;

@Nested
@DisplayName("GET /news/lately")
class LatelyApi {

@Test
@DisplayName("최신 뉴스 목록을 BaseResponse(result)로 감싸서 반환한다")
void viewLatelyNewsList() throws Exception {
// given
LatelyNews n1 = new LatelyNews(
1L,
List.of("Seoul", "Busan"), // regionList
List.of(), // climateList (enum 리스트, 여기선 빈 리스트)
"제목1"
);
LatelyNews n2 = new LatelyNews(
2L,
List.of("Incheon"),
List.of(),
"제목2"
);
GetLatelyNewsResponse response = GetLatelyNewsResponse.of(List.of(n1, n2));

Mockito.when(newsLatelyService.getLatelyNews()).thenReturn(response);

// when & then
mockMvc.perform(get("/news/lately").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
// BaseResponse 가 {"result": {...}} 라는 전제
.andExpect(jsonPath("$.data.latelyNewsList", hasSize(2)))
.andExpect(jsonPath("$.data.latelyNewsList[0].newsId").value(1L))
.andExpect(jsonPath("$.data.latelyNewsList[0].newsTitle").value("제목1"))
.andExpect(jsonPath("$.data.latelyNewsList[0].regionList", hasSize(2)))
.andExpect(jsonPath("$.data.latelyNewsList[0].regionList[0]").value("Seoul"))
.andExpect(jsonPath("$.data.latelyNewsList[1].newsId").value(2L))
.andExpect(jsonPath("$.data.latelyNewsList[1].regionList[0]").value("Incheon"));
}
}

@Nested
@DisplayName("GET /news/{newsId}")
class InfoApi {

@Test
@DisplayName("뉴스 상세를 BaseResponse(result)로 감싸서 반환한다")
void viewNewsInfo() throws Exception {
// given
GetNewsInfoResponse dto = new GetNewsInfoResponse(
List.of(), // climateList
List.of("Seoul", "Busan"), // regionList
"테스트 제목", // newsTitle
"https://example.com/news", // newsUrl
"https://example.com/img.jpg", // newsImageUrl
"요약 본문", // newsBody (aiSummary 매핑)
"2025-10-29", // newsDate
"AI 솔루션 본문", // aiSolution
List.of(new RelatedArticle("관련1", "https://a.com"))
);

Mockito.when(newsInfoService.getNewsInfo(anyLong())).thenReturn(dto);

// when & then
mockMvc.perform(get("/news/{newsId}", 10L).accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.data.newsTitle").value("테스트 제목"))
.andExpect(jsonPath("$.data.newsUrl").value("https://example.com/news"))
.andExpect(jsonPath("$.data.newsImageUrl").value("https://example.com/img.jpg"))
.andExpect(jsonPath("$.data.newsBody").value("요약 본문"))
.andExpect(jsonPath("$.data.newsDate").value("2025-10-29"))
.andExpect(jsonPath("$.data.regionList", hasSize(2)))
.andExpect(jsonPath("$.data.aiSolution").value("AI 솔루션 본문"))
.andExpect(jsonPath("$.data.aiRelated", hasSize(1)))
.andExpect(jsonPath("$.data.aiRelated[0].title").value("관련1"))
.andExpect(jsonPath("$.data.aiRelated[0].url").value("https://a.com"));
}
}
}
133 changes: 133 additions & 0 deletions src/test/java/Konkuk/U2E/domain/news/service/NewsInfoServiceTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package Konkuk.U2E.domain.news.service;

import Konkuk.U2E.domain.news.domain.News;
import Konkuk.U2E.domain.news.dto.response.GetNewsInfoResponse;
import Konkuk.U2E.domain.news.exception.NewsNotFoundException;
import Konkuk.U2E.domain.news.repository.NewsRepository;
import Konkuk.U2E.domain.news.service.mapper.NewsMapperFactory;
import Konkuk.U2E.domain.news.service.mapper.NewsMappingResult;
import Konkuk.U2E.global.openApi.gemini.service.NewsAiService;
import Konkuk.U2E.global.openApi.gemini.service.NewsRegionUpsertService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.util.StringUtils;

import java.time.LocalDate;
import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.when;

class NewsInfoServiceTest {

NewsRepository newsRepository = Mockito.mock(NewsRepository.class);
NewsMapperFactory newsMapperFactory = Mockito.mock(NewsMapperFactory.class);
NewsAiService newsAiService = Mockito.mock(NewsAiService.class);
NewsRegionUpsertService newsRegionUpsertService = Mockito.mock(NewsRegionUpsertService.class);

NewsInfoService service = new NewsInfoService(
newsRepository,
newsMapperFactory,
newsAiService,
newsRegionUpsertService
);

@Test
@DisplayName("newsId로 조회 성공 시, 엔티티와 매핑 결과가 GetNewsInfoResponse 로 반환된다")
void getNewsInfo_success() {
// given: 엔티티 더미
News news = News.builder()
.newsUrl("https://example.com/news")
.imageUrl("https://example.com/img.jpg")
.newsTitle("제목")
.newsBody("본문 원문(길게)")
.newsDate(LocalDate.of(2025,10,29))
.climateList(List.of())
.build();

// AI 필드 세팅 (3개의 관련 기사 모두 존재하도록)
news.applyAiResult(
"AI 솔루션",
"관련1", "https://a.com",
"관련2", "https://b.com",
"관련3", "https://c.com"
);
// 요약(= dto.newsBody에 매핑됨)
news.applyAiSummary("요약 본문");

when(newsRepository.findById(anyLong())).thenReturn(Optional.of(news));

// 매퍼 스텁: climate/region 을 세팅한 NewsMappingResult 반환
when(newsMapperFactory.newsMappingFunction())
.thenReturn((News n) -> new NewsMappingResult(
List.of(), // climateProblems
List.of("Seoul","Busan"), // regionNames
n
));

// when
GetNewsInfoResponse resp = service.getNewsInfo(1L);

// then
assertThat(resp).isNotNull();
assertThat(resp.newsTitle()).isEqualTo("제목");
assertThat(resp.newsUrl()).isEqualTo("https://example.com/news");
assertThat(resp.newsImageUrl()).isEqualTo("https://example.com/img.jpg");
// 주의: newsBody 는 entity.aiSummary 로 매핑됨
assertThat(resp.newsBody()).isEqualTo("요약 본문");
assertThat(resp.newsDate()).isEqualTo("2025-10-29");
assertThat(resp.regionList()).containsExactly("Seoul","Busan");
assertThat(resp.aiSolution()).isEqualTo("AI 솔루션");
assertThat(resp.aiRelated()).hasSize(3);
assertThat(StringUtils.hasText(resp.aiRelated().get(0).title())).isTrue();
assertThat(StringUtils.hasText(resp.aiRelated().get(0).url())).isTrue();
}

@Test
@DisplayName("newsId 조회 실패 시 NewsNotFoundException 발생")
void getNewsInfo_notFound() {
// given
when(newsRepository.findById(anyLong())).thenReturn(Optional.empty());

// when & then
assertThatThrownBy(() -> service.getNewsInfo(999L))
.isInstanceOf(NewsNotFoundException.class);
}

@Test
@DisplayName("AI 관련 필드들이 비어있으면 aiRelated는 빈 리스트이며 aiSolution/newsBody도 null을 그대로 반환한다")
void getNewsInfo_noRelatedOrSummary() {
// given: 관련 기사/솔루션/요약이 모두 비어있는 엔티티
News news = News.builder()
.newsUrl("https://example.com/news")
.imageUrl("https://example.com/img.jpg")
.newsTitle("제목 없음 가능")
.newsBody("원문 본문")
.newsDate(LocalDate.of(2025,10,30))
.climateList(List.of())
.build();

// aiResult/aiSummary 미설정 → 모두 null
when(newsRepository.findById(anyLong())).thenReturn(Optional.of(news));

// 매퍼 스텁: 정상 region/climate 세팅
when(newsMapperFactory.newsMappingFunction())
.thenReturn((News n) -> new NewsMappingResult(
List.of(), List.of("Seoul"), n
));

// when
GetNewsInfoResponse resp = service.getNewsInfo(2L);

// then
assertThat(resp).isNotNull();
// aiSummary가 null이면 dto.newsBody도 null (현재 매핑 로직 기준)
assertThat(resp.newsBody()).isNull();
assertThat(resp.aiSolution()).isNull();
assertThat(resp.aiRelated()).isEmpty(); // fromEntity가 아무것도 추가하지 않음
assertThat(resp.regionList()).containsExactly("Seoul");
}
}
Loading