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
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}