diff --git a/build.gradle b/build.gradle index 6255c92..2ded8a7 100644 --- a/build.gradle +++ b/build.gradle @@ -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 { @@ -93,6 +97,9 @@ tasks.named('jacocoTestReport', JacocoReport) { "**/*request*", "**/*response*", "**/generated/querydsl/**", + "**/exception/**", + "**/test/**", + "**/resolver/**", "**/Q*.*" ) } @@ -130,6 +137,9 @@ tasks.named('jacocoTestCoverageVerification', JacocoCoverageVerification) { "**/*request*", "**/*response*", "**/generated/querydsl/**", + "**/exception/**", + "**/test/**", + "**/resolver/**", "**/Q*.*" ) } @@ -163,4 +173,4 @@ tasks.named('check') { tasks.named('sonarqube') { dependsOn tasks.jacocoTestReport -} +} \ No newline at end of file diff --git a/src/main/java/Konkuk/U2E/domain/pin/service/PinListService.java b/src/main/java/Konkuk/U2E/domain/pin/service/PinListService.java index 33407ba..7d53e98 100644 --- a/src/main/java/Konkuk/U2E/domain/pin/service/PinListService.java +++ b/src/main/java/Konkuk/U2E/domain/pin/service/PinListService.java @@ -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)); } @@ -109,15 +109,15 @@ private PinInfo createPinInfo(Pin pin) { } private PinInfo createPinInfo(Pin pin, ClimateProblem climateProblem) { - List newsList = newsPinRepository.findNewsByPinId(pin.getPinId()); + List 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 climateProblems = climateRepository.findClimatesByNews(latestNews).stream() diff --git a/src/test/java/Konkuk/U2E/domain/news/controller/NewsControllerTest.java b/src/test/java/Konkuk/U2E/domain/news/controller/NewsControllerTest.java new file mode 100644 index 0000000..c9dbc33 --- /dev/null +++ b/src/test/java/Konkuk/U2E/domain/news/controller/NewsControllerTest.java @@ -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")); + } + } +} \ No newline at end of file diff --git a/src/test/java/Konkuk/U2E/domain/news/service/NewsInfoServiceTest.java b/src/test/java/Konkuk/U2E/domain/news/service/NewsInfoServiceTest.java new file mode 100644 index 0000000..b439079 --- /dev/null +++ b/src/test/java/Konkuk/U2E/domain/news/service/NewsInfoServiceTest.java @@ -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"); + } +} \ No newline at end of file diff --git a/src/test/java/Konkuk/U2E/domain/news/service/NewsLatelyServiceTest.java b/src/test/java/Konkuk/U2E/domain/news/service/NewsLatelyServiceTest.java new file mode 100644 index 0000000..8b09898 --- /dev/null +++ b/src/test/java/Konkuk/U2E/domain/news/service/NewsLatelyServiceTest.java @@ -0,0 +1,58 @@ +package Konkuk.U2E.domain.news.service; + +import Konkuk.U2E.domain.news.domain.News; +import Konkuk.U2E.domain.news.dto.response.GetLatelyNewsResponse; +import Konkuk.U2E.domain.news.repository.NewsRepository; +import Konkuk.U2E.domain.news.service.mapper.NewsMapperFactory; +import Konkuk.U2E.domain.news.service.mapper.NewsMappingResult; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +class NewsLatelyServiceTest { + + NewsRepository newsRepository = Mockito.mock(NewsRepository.class); + NewsMapperFactory newsMapperFactory = Mockito.mock(NewsMapperFactory.class); + NewsLatelyService service = new NewsLatelyService(newsRepository, newsMapperFactory); + + @Test + @DisplayName("최신 5건 조회 후 매핑하여 GetLatelyNewsResponse 로 반환한다") + void getLatelyNews_success() { + // given: 더미 엔티티 + News n1 = News.builder() + .newsUrl("u1").imageUrl("i1").newsTitle("t1") + .newsBody("b1").newsDate(LocalDate.of(2025,10,28)) + .climateList(List.of()) + .build(); + + News n2 = News.builder() + .newsUrl("u2").imageUrl("i2").newsTitle("t2") + .newsBody("b2").newsDate(LocalDate.of(2025,10,27)) + .climateList(List.of()) + .build(); + + // repository 스텁 + when(newsRepository.findTop5ByOrderByNewsDateDesc()).thenReturn(List.of(n1, n2)); + + // mapper 스텁: factory.newsMappingFunction() 호출 시 Function 반환 + when(newsMapperFactory.newsMappingFunction()).thenReturn(news -> { + // 예시: regionNames 는 고정, climateProblems 비워둠 + return new NewsMappingResult(List.of(), List.of("Seoul"), news); + }); + + // when + GetLatelyNewsResponse resp = service.getLatelyNews(); + + // then + assertThat(resp).isNotNull(); + assertThat(resp.latelyNewsList()).hasSize(2); + assertThat(resp.latelyNewsList().get(0).newsTitle()).isEqualTo("t1"); + assertThat(resp.latelyNewsList().get(0).regionList()).containsExactly("Seoul"); + } +} \ No newline at end of file diff --git a/src/test/java/Konkuk/U2E/domain/news/service/mapper/NewsMapperFactoryTest.java b/src/test/java/Konkuk/U2E/domain/news/service/mapper/NewsMapperFactoryTest.java new file mode 100644 index 0000000..80b997c --- /dev/null +++ b/src/test/java/Konkuk/U2E/domain/news/service/mapper/NewsMapperFactoryTest.java @@ -0,0 +1,92 @@ +package Konkuk.U2E.domain.news.service.mapper; + +import Konkuk.U2E.domain.news.domain.Climate; +import Konkuk.U2E.domain.news.domain.ClimateProblem; +import Konkuk.U2E.domain.news.domain.News; +import Konkuk.U2E.domain.news.repository.ClimateRepository; +import Konkuk.U2E.domain.news.repository.NewsPinRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.time.LocalDate; +import java.util.List; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +class NewsMapperFactoryTest { + + ClimateRepository climateRepository = Mockito.mock(ClimateRepository.class); + NewsPinRepository newsPinRepository = Mockito.mock(NewsPinRepository.class); + + NewsMapperFactory factory = new NewsMapperFactory(climateRepository, newsPinRepository); + + @Test + @DisplayName("news → (climateProblems, regionNames, news) 매핑 정상 동작") + void mapping_success() { + // 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(); + + // region 이름 조회는 newsId(null) 일 수 있으므로 anyLong() 대신 any() 사용 + when(newsPinRepository.findRegionNameByNews(any())) + .thenReturn(List.of("Seoul", "Busan")); + + // 기후 문제 리스트는 Climate -> ClimateProblem 으로 매핑됨 + when(climateRepository.findClimatesByNews(any(News.class))) + .thenReturn(List.of( + Climate.builder().climateProblem(ClimateProblem.TEMPERATURE_RISE).news(news).build(), + Climate.builder().climateProblem(ClimateProblem.HEAVY_RAIN_OR_FLOOD).news(news).build() + )); + + Function fn = factory.newsMappingFunction(); + + // when + NewsMappingResult result = fn.apply(news); + + // then + assertThat(result).isNotNull(); + assertThat(result.news()).isSameAs(news); + + assertThat(result.regionNames()).containsExactly("Seoul", "Busan"); + + assertThat(result.climateProblems()) + .containsExactly( + ClimateProblem.TEMPERATURE_RISE, + ClimateProblem.HEAVY_RAIN_OR_FLOOD + ); + } + + @Test + @DisplayName("연관 데이터가 없으면 빈 리스트로 매핑된다") + void mapping_emptyLists() { + // given + News news = News.builder() + .newsUrl("u").imageUrl("i").newsTitle("t") + .newsBody("b").newsDate(LocalDate.of(2025, 10, 29)) + .climateList(List.of()) + .build(); + + when(newsPinRepository.findRegionNameByNews(any())).thenReturn(List.of()); + when(climateRepository.findClimatesByNews(any(News.class))).thenReturn(List.of()); + + Function fn = factory.newsMappingFunction(); + + // when + NewsMappingResult result = fn.apply(news); + + // then + assertThat(result.regionNames()).isEmpty(); + assertThat(result.climateProblems()).isEmpty(); + assertThat(result.news()).isSameAs(news); + } +} \ No newline at end of file diff --git a/src/test/java/Konkuk/U2E/domain/pin/controller/PinControllerTest.java b/src/test/java/Konkuk/U2E/domain/pin/controller/PinControllerTest.java new file mode 100644 index 0000000..18f9e59 --- /dev/null +++ b/src/test/java/Konkuk/U2E/domain/pin/controller/PinControllerTest.java @@ -0,0 +1,134 @@ +package Konkuk.U2E.domain.pin.controller; + +import Konkuk.U2E.domain.pin.dto.response.GetPinInfoResponse; +import Konkuk.U2E.domain.pin.dto.response.GetPinListResponse; +import Konkuk.U2E.domain.pin.dto.response.NewsInfo; +import Konkuk.U2E.domain.pin.dto.response.PinInfo; +import Konkuk.U2E.domain.news.domain.ClimateProblem; +import Konkuk.U2E.domain.pin.service.PinInfoService; +import Konkuk.U2E.domain.pin.service.PinListService; +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.math.BigDecimal; +import java.util.List; + +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.ArgumentMatchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ActiveProfiles("test") +@SpringBootTest +@AutoConfigureMockMvc +class PinControllerTest { + + @Autowired + MockMvc mockMvc; + + @MockitoBean + PinInfoService pinInfoService; + + @MockitoBean + PinListService pinListService; + + @Nested + @DisplayName("GET /pin/{pinId}") + class PinInfoApi { + + @Test + @DisplayName("핀 상세 – BaseResponse(data.newsList) 반환") + void viewPinInfo() throws Exception { + GetPinInfoResponse stub = GetPinInfoResponse.of( + List.of( + new NewsInfo(List.of(ClimateProblem.TEMPERATURE_RISE), 1L, "뉴스1", "본문1", "2025-10-29"), + new NewsInfo(List.of(ClimateProblem.HEAVY_RAIN_OR_FLOOD), 2L, "뉴스2", "본문2", "2025-10-28") + ) + ); + + Mockito.when(pinInfoService.getPinInfo(anyLong())).thenReturn(stub); + + mockMvc.perform(get("/pin/{pinId}", 100L).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.data.newsList", hasSize(2))) + .andExpect(jsonPath("$.data.newsList[0].newsId").value(1L)) + .andExpect(jsonPath("$.data.newsList[0].climateList[0]").value("TEMPERATURE_RISE")) + .andExpect(jsonPath("$.data.newsList[1].newsTitle").value("뉴스2")); + } + } + + @Nested + @DisplayName("GET /pin (쿼리 파라미터 조합)") + class PinListApi { + + @Test + @DisplayName("전체 핀 목록 – 파라미터 없음") + void viewPinList_all() throws Exception { + GetPinListResponse stub = GetPinListResponse.of( + List.of( + new PinInfo(1L, new BigDecimal("37.5665"), new BigDecimal("126.9780"), true, "Seoul", List.of()), + new PinInfo(2L, new BigDecimal("35.1796"), new BigDecimal("129.0756"), false, "Busan", List.of(ClimateProblem.FINE_DUST)) + ) + ); + + Mockito.when(pinListService.getPinList(isNull(), isNull(), isNull())).thenReturn(stub); + + mockMvc.perform(get("/pin").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.pinList", hasSize(2))) + .andExpect(jsonPath("$.data.pinList[0].pinId").value(1L)) + .andExpect(jsonPath("$.data.pinList[1].region").value("Busan")); + } + + @Test + @DisplayName("지역으로 필터 – region=Seoul") + void viewPinList_region() throws Exception { + GetPinListResponse stub = GetPinListResponse.of( + List.of(new PinInfo(3L, new BigDecimal("37.5"), new BigDecimal("127.0"), true, "Seoul", List.of())) + ); + Mockito.when(pinListService.getPinList(eq("Seoul"), isNull(), isNull())).thenReturn(stub); + + mockMvc.perform(get("/pin").param("region", "Seoul").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.pinList", hasSize(1))) + .andExpect(jsonPath("$.data.pinList[0].region").value("Seoul")); + } + + @Test + @DisplayName("기후로 필터 – climate=TEMPERATURE_RISE") + void viewPinList_climate() throws Exception { + GetPinListResponse stub = GetPinListResponse.of( + List.of(new PinInfo(4L, new BigDecimal("33.0"), new BigDecimal("129.0"), false, "Jeju", List.of(ClimateProblem.TEMPERATURE_RISE))) + ); + Mockito.when(pinListService.getPinList(isNull(), eq("TEMPERATURE_RISE"), isNull())).thenReturn(stub); + + mockMvc.perform(get("/pin").param("climate", "TEMPERATURE_RISE").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.pinList[0].climateProblem[0]").value("TEMPERATURE_RISE")); + } + + @Test + @DisplayName("뉴스 카드 핀 목록 – newsId=10") + void viewPinList_newsId() throws Exception { + GetPinListResponse stub = GetPinListResponse.of( + List.of(new PinInfo(5L, new BigDecimal("36.0"), new BigDecimal("128.0"), true, "Daegu", List.of())) + ); + Mockito.when(pinListService.getPinList(isNull(), isNull(), eq(10L))).thenReturn(stub); + + mockMvc.perform(get("/pin").param("newsId", "10").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.pinList[0].pinId").value(5L)) + .andExpect(jsonPath("$.data.pinList[0].region").value("Daegu")); + } + } +} \ No newline at end of file diff --git a/src/test/java/Konkuk/U2E/domain/pin/service/PinInfoServiceTest.java b/src/test/java/Konkuk/U2E/domain/pin/service/PinInfoServiceTest.java new file mode 100644 index 0000000..d0c1af5 --- /dev/null +++ b/src/test/java/Konkuk/U2E/domain/pin/service/PinInfoServiceTest.java @@ -0,0 +1,60 @@ +package Konkuk.U2E.domain.pin.service; + +import Konkuk.U2E.domain.news.domain.News; +import Konkuk.U2E.domain.news.repository.ClimateRepository; +import Konkuk.U2E.domain.news.repository.NewsPinRepository; +import Konkuk.U2E.domain.pin.dto.response.GetPinInfoResponse; +import Konkuk.U2E.domain.pin.dto.response.NewsInfo; +import Konkuk.U2E.domain.pin.exception.PinNewsNotFoundException; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; + +@ActiveProfiles("test") +@SpringBootTest(classes = PinInfoService.class) +class PinInfoServiceTest { + + @MockitoBean NewsPinRepository newsPinRepository; + @MockitoBean ClimateRepository climateRepository; + + @Resource + PinInfoService service; + + @Test + @DisplayName("핀에 연결된 뉴스가 있으면 DTO로 매핑하여 반환") + void getPinInfo_success() { + News n1 = News.builder().newsUrl("u1").imageUrl("i1").newsTitle("t1") + .newsBody("b1").newsDate(LocalDate.of(2025,10,28)).climateList(List.of()).build(); + News n2 = News.builder().newsUrl("u2").imageUrl("i2").newsTitle("t2") + .newsBody("b2").newsDate(LocalDate.of(2025,10,27)).climateList(List.of()).build(); + + when(newsPinRepository.findNewsByPinId(anyLong())).thenReturn(List.of(n1, n2)); + when(climateRepository.findClimatesByNews(any())).thenReturn(List.of()); + + GetPinInfoResponse resp = service.getPinInfo(99L); + + assertThat(resp.newsList()).hasSize(2); + NewsInfo first = resp.newsList().get(0); + assertThat(first.newsTitle()).isEqualTo("t1"); + assertThat(first.newsDate()).isEqualTo("2025-10-28"); + } + + @Test + @DisplayName("뉴스 없으면 PinNewsNotFoundException") + void getPinInfo_empty() { + when(newsPinRepository.findNewsByPinId(anyLong())).thenReturn(List.of()); + assertThatThrownBy(() -> service.getPinInfo(1L)) + .isInstanceOf(PinNewsNotFoundException.class); + } +} \ No newline at end of file diff --git a/src/test/java/Konkuk/U2E/domain/pin/service/PinListServiceTest.java b/src/test/java/Konkuk/U2E/domain/pin/service/PinListServiceTest.java new file mode 100644 index 0000000..51efd3e --- /dev/null +++ b/src/test/java/Konkuk/U2E/domain/pin/service/PinListServiceTest.java @@ -0,0 +1,224 @@ +package Konkuk.U2E.domain.pin.service; + +import Konkuk.U2E.domain.news.domain.Climate; +import Konkuk.U2E.domain.news.domain.ClimateProblem; +import Konkuk.U2E.domain.news.domain.News; +import Konkuk.U2E.domain.news.repository.ClimateRepository; +import Konkuk.U2E.domain.news.repository.NewsPinRepository; +import Konkuk.U2E.domain.pin.domain.Pin; +import Konkuk.U2E.domain.pin.domain.Region; +import Konkuk.U2E.domain.pin.dto.response.GetPinListResponse; +import Konkuk.U2E.domain.pin.dto.response.PinInfo; +import Konkuk.U2E.domain.pin.exception.InvalidParamException; +import Konkuk.U2E.domain.pin.exception.NewsPinNotFoundException; +import Konkuk.U2E.domain.pin.exception.PinNewsNotFoundException; +import Konkuk.U2E.domain.pin.repository.PinRepository; +import Konkuk.U2E.domain.pin.repository.RegionRepository; +import Konkuk.U2E.global.util.DateUtil; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; + +@ActiveProfiles("test") +@SpringBootTest(classes = PinListService.class) +class PinListServiceTest { + + @MockitoBean ClimateRepository climateRepository; + @MockitoBean NewsPinRepository newsPinRepository; + @MockitoBean PinRepository pinRepository; + @MockitoBean RegionRepository regionRepository; + @MockitoBean DateUtil dateUtil; + + @Resource PinListService service; + + Region seoul; + Region busan; + Pin pin1; + Pin pin2; + + News n1; + News n2; + News n3; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + seoul = Region.builder() + .name("Seoul") + .latitude(new BigDecimal("37.5665")) + .longitude(new BigDecimal("126.9780")) + .build(); + + busan = Region.builder() + .name("Busan") + .latitude(new BigDecimal("35.1796")) + .longitude(new BigDecimal("129.0756")) + .build(); + + // 테스트에서는 pinId를 주입하지 않음(null) → stubbing 시 any() 사용 + pin1 = Pin.builder().region(seoul).build(); + pin2 = Pin.builder().region(busan).build(); + + n1 = News.builder() + .newsUrl("u1").imageUrl("i1").newsTitle("t1") + .newsBody("b1").newsDate(LocalDate.of(2025,10,20)).climateList(List.of()) + .build(); + n2 = News.builder() + .newsUrl("u2").imageUrl("i2").newsTitle("t2") + .newsBody("b2").newsDate(LocalDate.of(2025,10,18)).climateList(List.of()) + .build(); + n3 = News.builder() + .newsUrl("u3").imageUrl("i3").newsTitle("t3") + .newsBody("b3").newsDate(LocalDate.of(2025,10,10)).climateList(List.of()) + .build(); + + // 공통 스텁(최신 뉴스 = 전달된 리스트의 첫 요소, 최근 여부 true) + when(dateUtil.getLatestNews(anyList())) + .thenAnswer(inv -> ((List) inv.getArgument(0)).get(0)); + when(dateUtil.checkLately(any())).thenReturn(true); + when(climateRepository.findClimatesByNews(any())) + .thenReturn(List.of( + Climate.builder().climateProblem(ClimateProblem.TEMPERATURE_RISE).news(n1).build() + )); + } + + @Test + @DisplayName("1) 전체 핀 – 모든 파라미터 null → getPinListByAll() 경로") + void getPinList_all_ok() { + when(pinRepository.findAll()).thenReturn(List.of(pin1, pin2)); + // pinId가 null이므로 any()를 사용해 null 호출도 매칭 + when(newsPinRepository.findNewsByPinId(any())) + .thenReturn(List.of(n1)) + .thenReturn(List.of(n2)); + + GetPinListResponse resp = service.getPinList(null, null, null); + + assertThat(resp.pinList()).hasSize(2); + assertThat(resp.pinList().get(0).region()).isEqualTo("Seoul"); + assertThat(resp.pinList().get(1).region()).isEqualTo("Busan"); + assertThat(resp.pinList().get(0).isLately()).isTrue(); + } + + @Test + @DisplayName("2) 지역 필터 – region=Seoul → getPinListByRegion() 경로") + void getPinList_region_ok() { + when(regionRepository.findRegionsByName(eq("Seoul"))).thenReturn(List.of(seoul)); + when(pinRepository.findPinByRegion(seoul)).thenReturn(pin1); + when(newsPinRepository.findNewsByPinId(any())).thenReturn(List.of(n2)); // latest=n2 + + GetPinListResponse resp = service.getPinList("Seoul", null, null); + + assertThat(resp.pinList()).hasSize(1); + PinInfo info = resp.pinList().get(0); + assertThat(info.region()).isEqualTo("Seoul"); + assertThat(info.isLately()).isTrue(); + } + + @Test + @DisplayName("3) 기후 필터 – climate=TEMPERATURE_RISE → getPinListByClimate() 경로") + void getPinList_climate_ok() { + ClimateProblem cp = ClimateProblem.TEMPERATURE_RISE; + + // 이 기후에 해당하는 뉴스 목록 + when(climateRepository.findNewsByClimateProblem(cp)).thenReturn(List.of(n1, n2)); + + // 각 뉴스에 매핑된 핀 + when(newsPinRepository.findPinsByNews(n1)).thenReturn(List.of(pin1)); + when(newsPinRepository.findPinsByNews(n2)).thenReturn(List.of(pin1, pin2)); + + // 필터 통과 + when(climateRepository.existsByNewsAndClimateProblem(any(), eq(cp))).thenReturn(true); + + // 각 핀을 다시 latest 판단하기 위한 뉴스 조회 + when(newsPinRepository.findNewsByPinId(any())) + .thenReturn(List.of(n1, n2)) // for pin1 + .thenReturn(List.of(n2)); // for pin2 + + // climates 조회는 setUp()의 공통 스텁 사용 + + GetPinListResponse resp = service.getPinList(null, cp.name(), null); + + assertThat(resp.pinList()).isNotEmpty(); + // distinct로 인해 개수는 상황에 따라 다를 수 있으므로, 핵심 값만 검증 + assertThat(resp.pinList().stream().map(PinInfo::region)) + .contains("Seoul"); + } + + @Test + @DisplayName("3-1) 기후 필터 – 필터링 결과 중 일부만 통과(빈 리스트 아님)") + void getPinList_climate_filtered_ok() { + ClimateProblem cp = ClimateProblem.FINE_DUST; + + when(climateRepository.findNewsByClimateProblem(cp)).thenReturn(List.of(n1, n2, n3)); + when(newsPinRepository.findPinsByNews(n1)).thenReturn(List.of(pin1)); + when(newsPinRepository.findPinsByNews(n2)).thenReturn(List.of(pin1)); + when(newsPinRepository.findPinsByNews(n3)).thenReturn(List.of(pin2)); + + // n1만 cp 포함, 나머지는 제외 → latest는 n1만 남게 구성 + when(climateRepository.existsByNewsAndClimateProblem(eq(n1), eq(cp))).thenReturn(true); + when(climateRepository.existsByNewsAndClimateProblem(eq(n2), eq(cp))).thenReturn(false); + when(climateRepository.existsByNewsAndClimateProblem(eq(n3), eq(cp))).thenReturn(false); + + // pin1 처리 시: findNewsByPinId → n1, n2 (필터 후 n1만 전달되어 latest=n1) + when(newsPinRepository.findNewsByPinId(any())) + .thenReturn(List.of(n1, n2)) // for pin1 + .thenReturn(List.of(n3)); // for pin2 (필터에서 제거되어 호출되더라도 latest는 안전하게 동작) + + assertThatThrownBy(() -> service.getPinList(null, cp.name(), null)) + .isInstanceOf(PinNewsNotFoundException.class); + } + + @Test + @DisplayName("4) 최신 뉴스 카드 – newsId=10 → getPinListByNewsId() 경로") + void getPinList_newsId_ok() { + when(newsPinRepository.findPinsByNewsId(eq(10L))).thenReturn(List.of(pin1)); + when(newsPinRepository.findNewsByPinId(any())).thenReturn(List.of(n1)); + + GetPinListResponse resp = service.getPinList(null, null, 10L); + + assertThat(resp.pinList()).hasSize(1); + assertThat(resp.pinList().get(0).region()).isEqualTo("Seoul"); + } + + @Test + @DisplayName("4-1) 최신 뉴스 카드 – 해당 뉴스에 핀이 없으면 예외") + void getPinList_newsId_emptyPins_throws() { + when(newsPinRepository.findPinsByNewsId(eq(11L))).thenReturn(List.of()); + + assertThatThrownBy(() -> service.getPinList(null, null, 11L)) + .isInstanceOf(NewsPinNotFoundException.class); + } + + @Test + @DisplayName("예외 경로 – 파라미터 조합이 잘못되면 InvalidParamException") + void getPinList_invalidParams() { + assertThatThrownBy(() -> service.getPinList("Seoul", "TEMPERATURE_RISE", null)) + .isInstanceOf(InvalidParamException.class); + assertThatThrownBy(() -> service.getPinList("Seoul", null, 1L)) + .isInstanceOf(InvalidParamException.class); + assertThatThrownBy(() -> service.getPinList(null, "TEMPERATURE_RISE", 1L)) + .isInstanceOf(InvalidParamException.class); + } + + @Test + @DisplayName("예외 경로 – createPinInfo: 해당 핀에 뉴스가 없으면 PinNewsNotFoundException") + void createPinInfo_noNews_throws() { + when(pinRepository.findAll()).thenReturn(List.of(pin1)); + when(newsPinRepository.findNewsByPinId(any())).thenReturn(List.of()); + + assertThatThrownBy(() -> service.getPinList(null, null, null)) + .isInstanceOf(PinNewsNotFoundException.class); + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index dfb0959..b0d665c 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -28,3 +28,7 @@ spring: dialect: org.hibernate.dialect.H2Dialect +gemini: + api-key: abcdefghijklmnopqrstuvwxyz + model: gemini-2.5-flash + endpoint: https://generativelanguage.googleapis.com/v1beta \ No newline at end of file