Skip to content

Commit 39a223c

Browse files
committed
feat(be): TourClient 호출 구현 및 TEST 코드 작성 (#3)
1 parent af784b6 commit 39a223c

File tree

3 files changed

+233
-4
lines changed

3 files changed

+233
-4
lines changed
Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,80 @@
11
package com.back.koreaTravelGuide.domain.ai.tour.client
22

3-
// TODO: 관광청 API 클라이언트 - 관광 정보 API 호출 및 응답 처리
4-
class TourApiClient
3+
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse
4+
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourSearchParams
5+
import com.fasterxml.jackson.databind.ObjectMapper
6+
import org.springframework.beans.factory.annotation.Value
7+
import org.springframework.web.client.RestTemplate
8+
import org.springframework.web.util.UriComponentsBuilder
9+
import java.net.URI
10+
11+
// 09.25 양현준
12+
class TourApiClient (
13+
private val restTemplate: RestTemplate,
14+
private val objectMapper: ObjectMapper,
15+
@Value("\${tour.api.key}") private val serviceKey: String,
16+
@Value("\${tour.api.base-url}") private val apiUrl: String
17+
){
18+
// 요청 URL 구성
19+
private fun buildUrl(params: TourSearchParams): URI =
20+
UriComponentsBuilder.fromUri(URI.create(apiUrl))
21+
.path("/areaBasedList2")
22+
.queryParam("serviceKey", serviceKey)
23+
.queryParam("MobileOS", "WEB")
24+
.queryParam("MobileApp", "KoreaTravelGuide")
25+
.queryParam("_type", "json")
26+
.queryParam("numOfRows", params.numOfRows)
27+
.queryParam("pageNo", params.pageNo)
28+
.queryParam("contentTypeId", params.contentTypeId)
29+
.queryParam("areaCode", params.areaCode)
30+
.queryParam("sigunguCode", params.sigunguCode)
31+
.build()
32+
.encode()
33+
.toUri()
34+
35+
// 지역 기반 관광 정보 조회 (areaBasedList2)
36+
fun fetchTourInfo(params: TourSearchParams): TourResponse? {
37+
println("URL 생성")
38+
val url = buildUrl(params)
39+
40+
println("관광 정보 조회 API 호출: $url")
41+
42+
return try {
43+
val jsonResponse = restTemplate.getForObject(url, String::class.java)
44+
println("관광 정보 응답 길이: ${jsonResponse?.length ?: 0}")
45+
46+
if (jsonResponse.isNullOrBlank()) return null // HTTP 호출 결과가 null이거나 공백 문자열일 때
47+
48+
val root = objectMapper.readTree(jsonResponse) // 문자열을 Jackson 트리 구조(JsonNode)로 변환
49+
val itemsNode = root // path("키") 형태로 노드를 탐색, 응답 Json 형태의 순서에 따라 순차적으로 내려감
50+
.path("response")
51+
.path("body")
52+
.path("items")
53+
.path("item")
54+
55+
if (!itemsNode.isArray || itemsNode.isEmpty) return null // 탐색 결과가 비어 있는 경우
56+
57+
val firstItem = itemsNode.first()
58+
TourResponse(
59+
contentId = firstItem.path("contentid").asText(),
60+
contentTypeId = firstItem.path("contenttypeid").asText(),
61+
createdTime = firstItem.path("createdtime").asText(),
62+
modifiedTime = firstItem.path("modifiedtime").asText(),
63+
title = firstItem.path("title").asText(),
64+
addr1 = firstItem.path("addr1").takeIf { it.isTextual }?.asText(),
65+
areaCode = firstItem.path("areacode").takeIf { it.isTextual }?.asText(),
66+
firstimage = firstItem.path("firstimage").takeIf { it.isTextual }?.asText(),
67+
firstimage2 = firstItem.path("firstimage2").takeIf { it.isTextual }?.asText(),
68+
mapX = firstItem.path("mapx").takeIf { it.isTextual }?.asText(),
69+
mapY = firstItem.path("mapy").takeIf { it.isTextual }?.asText(),
70+
mlevel = firstItem.path("mlevel").takeIf { it.isTextual }?.asText(),
71+
sigunguCode = firstItem.path("sigungucode").takeIf { it.isTextual }?.asText(),
72+
lDongRegnCd = firstItem.path("lDongRegnCd").takeIf { it.isTextual }?.asText(),
73+
lDongSignguCd = firstItem.path("lDongSignguCd").takeIf { it.isTextual }?.asText()
74+
)
75+
} catch (e: Exception) {
76+
println("관광 정보 조회 오류: ${e.message}")
77+
null
78+
}
79+
}
80+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package com.back.koreaTravelGuide.domain.ai.tour.client
2+
3+
import com.back.koreaTravelGuide.application.KoreaTravelGuideApplication
4+
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourResponse
5+
import com.back.koreaTravelGuide.domain.ai.tour.dto.TourSearchParams
6+
import com.fasterxml.jackson.databind.ObjectMapper
7+
import org.junit.jupiter.api.BeforeEach
8+
import org.junit.jupiter.api.Test
9+
import org.junit.jupiter.api.extension.ExtendWith
10+
import org.springframework.beans.factory.annotation.Autowired
11+
import org.springframework.beans.factory.annotation.Value
12+
import org.springframework.boot.test.context.SpringBootTest
13+
import org.springframework.boot.web.client.RestTemplateBuilder
14+
import org.springframework.context.annotation.Bean
15+
import org.springframework.boot.test.context.TestConfiguration
16+
import org.springframework.http.HttpMethod
17+
import org.springframework.http.MediaType
18+
import org.springframework.test.context.ActiveProfiles
19+
import org.springframework.test.context.junit.jupiter.SpringExtension
20+
import org.springframework.test.web.client.MockRestServiceServer
21+
import org.springframework.test.web.client.match.MockRestRequestMatchers.method
22+
import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo
23+
import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess
24+
import org.springframework.web.client.RestTemplate
25+
import org.springframework.web.util.UriComponentsBuilder
26+
import java.net.URI
27+
import kotlin.test.assertEquals
28+
import kotlin.test.assertNotNull
29+
import kotlin.test.assertNull
30+
31+
//09.25 양현준
32+
// TourApiClient 테스트
33+
34+
@ExtendWith(SpringExtension::class)
35+
// 패키지 경로에서 메인 설정을 찾지 못하는 오류를 해결하기 위해 애플리케이션 클래스를 명시.
36+
@SpringBootTest(classes = [KoreaTravelGuideApplication::class])
37+
@ActiveProfiles("test")
38+
class TourApiClientTest {
39+
40+
@Autowired private lateinit var restTemplateBuilder: RestTemplateBuilder
41+
@Autowired private lateinit var objectMapper: ObjectMapper
42+
43+
@Value("\${tour.api.key}")
44+
private lateinit var serviceKey: String
45+
46+
@Value("\${tour.api.base-url}")
47+
private lateinit var apiUrl: String
48+
49+
private lateinit var restTemplate: RestTemplate
50+
private lateinit var mockServer: MockRestServiceServer
51+
private lateinit var tourApiClient: TourApiClient
52+
53+
@BeforeEach
54+
// 테스트마다 클라이언트와 Mock 서버를 새로 구성해 호출 상태를 초기화.
55+
fun setUp() {
56+
restTemplate = restTemplateBuilder.build()
57+
mockServer = MockRestServiceServer.createServer(restTemplate)
58+
tourApiClient = TourApiClient(restTemplate, objectMapper, serviceKey, apiUrl)
59+
}
60+
61+
@Test
62+
// TourApiClient.fetchTourInfo() 테스트
63+
// 첫 번째 관광 정보를 반환하는지.
64+
fun testReturnsFirstTourInfo() {
65+
val params = TourSearchParams(numOfRows = 2, pageNo = 1, areaCode = "1", sigunguCode = "7")
66+
expectTourRequest(params, responseWithItems(sampleTourItem()))
67+
68+
val result: TourResponse? = tourApiClient.fetchTourInfo(params)
69+
70+
mockServer.verify()
71+
assertNotNull(result)
72+
assertEquals("2591792", result.contentId)
73+
assertEquals("개봉유수지 생태공원", result.title)
74+
assertEquals("7", result.sigunguCode)
75+
}
76+
77+
@Test
78+
// TourApiClient.fetchTourInfo() 테스트
79+
// item 배열이 비어 있으면 null을 돌려주는지.
80+
fun testReturnsNullWhenItemsMissing() {
81+
val params = TourSearchParams(numOfRows = 1, pageNo = 1, areaCode = "1", sigunguCode = "7")
82+
expectTourRequest(params, responseWithItems())
83+
84+
val result = tourApiClient.fetchTourInfo(params)
85+
86+
mockServer.verify()
87+
assertNull(result)
88+
}
89+
90+
// 요청 URL과 응답 바디를 미리 세팅해 Mock 서버가 기대한 호출을 검증.
91+
private fun expectTourRequest(params: TourSearchParams, responseBody: String) {
92+
mockServer.expect(requestTo(buildExpectedUrl(params)))
93+
.andExpect(method(HttpMethod.GET))
94+
.andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON))
95+
}
96+
97+
// 실제 클라이언트가 조합하는 URL과 동일한 형태를 만들어 비교한다.
98+
private fun buildExpectedUrl(params: TourSearchParams): String =
99+
UriComponentsBuilder.fromUri(URI.create(apiUrl))
100+
.path("/areaBasedList2")
101+
.queryParam("serviceKey", serviceKey)
102+
.queryParam("MobileOS", "WEB")
103+
.queryParam("MobileApp", "KoreaTravelGuide")
104+
.queryParam("_type", "json")
105+
.queryParam("numOfRows", params.numOfRows)
106+
.queryParam("pageNo", params.pageNo)
107+
.queryParam("contentTypeId", params.contentTypeId)
108+
.queryParam("areaCode", params.areaCode)
109+
.queryParam("sigunguCode", params.sigunguCode)
110+
.build()
111+
.encode()
112+
.toUriString()
113+
114+
// Jackson을 활용해 테스트 응답 JSON을 손쉽게 조립한다.
115+
private fun responseWithItems(vararg items: Map<String, String>): String {
116+
val response = mapOf(
117+
"response" to mapOf(
118+
"header" to mapOf("resultCode" to "0000", "resultMsg" to "OK"),
119+
"body" to mapOf(
120+
"items" to mapOf("item" to items.toList())
121+
)
122+
)
123+
)
124+
125+
return objectMapper.writeValueAsString(response)
126+
}
127+
128+
// 테스트용 샘플 관광지 정의한다.
129+
private fun sampleTourItem(): Map<String, String> = mapOf(
130+
"contentid" to "2591792",
131+
"contenttypeid" to "12",
132+
"createdtime" to "20190313221125",
133+
"modifiedtime" to "20250316162225",
134+
"title" to "개봉유수지 생태공원",
135+
"addr1" to "서울특별시 구로구 개봉동",
136+
"areacode" to "1",
137+
"firstimage" to "",
138+
"firstimage2" to "",
139+
"mapx" to "126.8632141714",
140+
"mapy" to "37.4924524597",
141+
"mlevel" to "6",
142+
"sigungucode" to "7",
143+
"lDongRegnCd" to "11",
144+
"lDongSignguCd" to "530"
145+
)
146+
147+
@TestConfiguration
148+
// 테스트에서 RestTemplateBuilder 빈을 보장해 컨텍스트 로딩 실패 해결.
149+
class TestConfig {
150+
@Bean
151+
fun restTemplateBuilder(): RestTemplateBuilder = RestTemplateBuilder()
152+
}
153+
}

src/test/resources/application-test.properties

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ weather.api.key=8298f7760d58f7d4a0104d016d5792a33b0048953afda89d5c0bb3fef251141c
66
weather.api.base-url=https://apis.data.go.kr/1360000/MidFcstInfoService
77

88
# Tour API Configuration
9-
tour.api.key=${TOUR_API_KEY:test-key}
10-
tour.api.base-url=${TOUR_API_BASE_URL:https://apis.data.go.kr/B551011/KorService1}
9+
tour.api.key=${TOUR_API_KEY:Pp8aOoKZql09DdDfb4r9SsFWIepIqaocvCQzJphWcvmBj0ff9KuvikfKjgxrXqK03JNrmOIjOZyLyZhjlY43AQ==}
10+
tour.api.base-url=${TOUR_API_BASE_URL:https://apis.data.go.kr/B551011/KorService2}
1111

1212
# Spring AI Configuration
1313
spring.ai.openai.api-key=${OPENAI_API_KEY:test-key}

0 commit comments

Comments
 (0)