diff --git a/build.gradle.kts b/build.gradle.kts index 6554833..6745ee5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -74,6 +74,7 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.springframework.security:spring-security-test") + testImplementation("io.mockk:mockk:1.13.12") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt index 3566346..32cb8c9 100644 --- a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/client/TourApiClientTest.kt @@ -2,67 +2,136 @@ package com.back.koreaTravelGuide.domain.ai.tour.client import com.back.koreaTravelGuide.KoreaTravelGuideApplication import com.back.koreaTravelGuide.domain.ai.tour.dto.TourParams -import org.junit.jupiter.api.Assumptions.assumeTrue +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.beans.factory.annotation.Value import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.client.ExpectedCount +import org.springframework.test.web.client.MockRestServiceServer +import org.springframework.test.web.client.match.MockRestRequestMatchers.method +import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo +import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus +import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess +import org.springframework.web.client.RestTemplate +import org.springframework.web.util.UriComponentsBuilder +import kotlin.test.assertEquals import kotlin.test.assertTrue -/** - * 실제 관광청 API 상태를 확인하기 위한 통합 테스트. - */ @SpringBootTest(classes = [KoreaTravelGuideApplication::class]) @ActiveProfiles("test") class TourApiClientTest { - @Autowired - private lateinit var tourApiClient: TourApiClient + // MockRestServiceServer 기반 단위 테스트 + @Nested + inner class MockServerTests { + private lateinit var restTemplate: RestTemplate + private lateinit var mockServer: MockRestServiceServer + private lateinit var mockClient: TourApiClient - @Value("\${tour.api.key}") - private lateinit var serviceKey: String + private val serviceKey = "test-service-key" + private val baseUrl = "https://example.com" - @DisplayName("fetchTourInfo - 실제 관광청 API 호출 (데이터 기대)") - @Test - fun fetchTourInfoTest() { - val params = - TourParams( - contentTypeId = "12", - areaCode = "1", - sigunguCode = "1", - ) + @BeforeEach + fun setUp() { + restTemplate = RestTemplate() + mockServer = MockRestServiceServer.createServer(restTemplate) + mockClient = TourApiClient(restTemplate, ObjectMapper(), serviceKey, baseUrl) + } + + @AfterEach + fun tearDown() { + mockServer.verify() + } - val result = tourApiClient.fetchTourInfo(params) + @DisplayName("fetchTourInfo - 외부 API가 정상 응답을 반환하면 파싱된 결과를 제공") + @Test + fun fetchTourInfoReturnsParsedItems() { + val params = + TourParams( + contentTypeId = "12", + areaCode = "1", + sigunguCode = "1", + ) - println("실제 API 응답 아이템 수: ${result.items.size}") - println("첫 번째 아이템: ${result.items.firstOrNull()}") + mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(SUCCESS_RESPONSE, MediaType.APPLICATION_JSON)) - assertTrue(result.items.isNotEmpty(), "실제 API 호출 결과가 비어 있습니다. 장애 여부를 확인하세요.") - } + val result = mockClient.fetchTourInfo(params) - @DisplayName("fetchTourInfo - 실제 관광청 API 장애 시 빈 결과 확인") - @Test - fun fetchTourInfoEmptyTest() { - val params = - TourParams( - contentTypeId = "12", - areaCode = "1", - sigunguCode = "1", - ) + assertEquals(1, result.items.size) + val firstItem = result.items.first() + assertEquals("12345", firstItem.contentId) + assertEquals("테스트 타이틀", firstItem.title) + } + + @DisplayName("fetchTourInfo - 외부 API가 404를 반환하면 빈 결과를 전달") + @Test + fun fetchTourInfoReturnsEmptyListWhenApiFails() { + val params = + TourParams( + contentTypeId = "12", + areaCode = "1", + sigunguCode = "1", + ) - val result = tourApiClient.fetchTourInfo(params) + mockServer.expect(ExpectedCount.once(), requestTo(expectedAreaBasedListUrl(params))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withStatus(HttpStatus.NOT_FOUND)) - println("실제 API 응답 아이템 수: ${result.items.size}") - println("첫 번째 아이템: ${result.items.firstOrNull()}") + val result = mockClient.fetchTourInfo(params) - // 장애가 아닐 경우, 테스트를 스킵 - assumeTrue(result.items.isEmpty()) { - "API가 정상 응답을 반환하고 있어 장애 시나리오 테스트를 건너뜁니다." + assertTrue(result.items.isEmpty()) } - // 장애 상황일 시 - println("실제 API가 비어 있는 응답을 반환했습니다.") - assertTrue(result.items.isEmpty()) + private fun expectedAreaBasedListUrl(params: TourParams): String = + UriComponentsBuilder.fromUriString(baseUrl) + .path("/areaBasedList2") + .queryParam("serviceKey", serviceKey) + .queryParam("MobileOS", "WEB") + .queryParam("MobileApp", "KoreaTravelGuide") + .queryParam("_type", "json") + .queryParam("contentTypeId", params.contentTypeId) + .queryParam("areaCode", params.areaCode) + .queryParam("sigunguCode", params.sigunguCode) + .build() + .encode() + .toUriString() + } + + companion object { + private val SUCCESS_RESPONSE = + """ + { + "response": { + "header": { + "resultCode": "0000", + "resultMsg": "OK" + }, + "body": { + "items": { + "item": [ + { + "contentid": "12345", + "contenttypeid": "12", + "createdtime": "202310010000", + "modifiedtime": "202310020000", + "title": "테스트 타이틀", + "addr1": "서울특별시 종로구", + "areacode": "1", + "firstimage": "https://example.com/image.jpg" + } + ] + } + } + } + } + """.trimIndent() } } diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt new file mode 100644 index 0000000..d000173 --- /dev/null +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/TourParamsParserTest.kt @@ -0,0 +1,50 @@ +package com.back.koreaTravelGuide.domain.ai.tour.service + +import kotlin.test.assertEquals +import kotlin.test.assertNull +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class TourParamsParserTest { + private val parser = TourParamsParser() + + @DisplayName("parse - 공백이 섞인 입력을 정리해 DTO를 만든다") + @Test + fun parseTrimsTokens() { + val result = parser.parse("12", " 6 , 10 ") + + assertEquals("12", result.contentTypeId) + assertEquals("6", result.areaCode) + assertEquals("10", result.sigunguCode) + } + + @DisplayName("parse - 시군구 코드가 없으면 null 로 남긴다") + @Test + fun parseWhenSigunguMissing() { + val result = parser.parse("15", "7") + + assertEquals("15", result.contentTypeId) + assertEquals("7", result.areaCode) + assertNull(result.sigunguCode) + } + + @DisplayName("parse - 콤마가 여러 번 등장하면 빈 문자열을 허용한다") + @Test + fun parseWhenCommaRepeated() { + val result = parser.parse("32", "1,,2") + + assertEquals("32", result.contentTypeId) + assertEquals("1", result.areaCode) + assertEquals("", result.sigunguCode) + } + + @DisplayName("parse - 완전히 비어 있는 입력은 빈 문자열과 null 로 파싱된다") + @Test + fun parseWhenInputBlank() { + val result = parser.parse("25", "") + + assertEquals("25", result.contentTypeId) + assertEquals("", result.areaCode) + assertNull(result.sigunguCode) + } +} diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourAreaBasedServiceCoreCacheTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourAreaBasedServiceCoreCacheTest.kt new file mode 100644 index 0000000..cd5e7c7 --- /dev/null +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/ai/tour/service/core/TourAreaBasedServiceCoreCacheTest.kt @@ -0,0 +1,81 @@ +package com.back.koreaTravelGuide.domain.ai.tour.service.core + +import com.back.koreaTravelGuide.domain.ai.tour.client.TourApiClient +import com.back.koreaTravelGuide.domain.ai.tour.dto.TourItem +import com.back.koreaTravelGuide.domain.ai.tour.dto.TourParams +import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse +import com.back.koreaTravelGuide.domain.ai.tour.service.usecase.TourAreaBasedUseCase +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.cache.CacheManager +import org.springframework.cache.annotation.EnableCaching +import org.springframework.cache.concurrent.ConcurrentMapCacheManager +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig +import kotlin.test.assertEquals + +@SpringJUnitConfig(TourAreaBasedServiceCoreCacheTest.Config::class) +class TourAreaBasedServiceCoreCacheTest { + @Autowired + private lateinit var service: TourAreaBasedUseCase + + @Autowired + private lateinit var tourApiClient: TourApiClient + + @DisplayName("fetchAreaBasedTours - 동일 파라미터 두 번 호출 시 API는 한 번만 호출된다") + @Test + fun cachesAreaBasedTours() { + val params = TourParams(contentTypeId = "15", areaCode = "3", sigunguCode = "5") + val apiResponse = + TourResponse( + items = + listOf( + TourItem( + contentId = "88888", + contentTypeId = "15", + createdTime = "202401010000", + modifiedTime = "202401020000", + title = "캐시 검증 관광지", + addr1 = "대전 어딘가", + areaCode = "3", + firstimage = null, + firstimage2 = null, + mapX = null, + mapY = null, + distance = null, + mlevel = null, + sigunguCode = "5", + lDongRegnCd = null, + lDongSignguCd = null, + ), + ), + ) + + every { tourApiClient.fetchTourInfo(params) } returns apiResponse + + val firstCall = service.fetchAreaBasedTours(params) + val secondCall = service.fetchAreaBasedTours(params) + + assertEquals(apiResponse, firstCall) + assertEquals(apiResponse, secondCall) + verify(exactly = 1) { tourApiClient.fetchTourInfo(params) } + } + + @Configuration + @EnableCaching + class Config { + @Bean + fun tourApiClient(): TourApiClient = mockk(relaxed = true) + + @Bean + fun cacheManager(): CacheManager = ConcurrentMapCacheManager("tourAreaBased", "tourLocationBased", "tourDetail") + + @Bean + fun tourAreaBasedServiceCore(tourApiClient: TourApiClient): TourAreaBasedServiceCore = TourAreaBasedServiceCore(tourApiClient) + } +}