feat(naver-map-route): NCP Maps Directions/Geocode/Reverse-Geocode 프록시 라우트 + MVP 길찾기 스킬 (#268)

- packages/k-skill-proxy: NAVER_MAP_CLIENT_ID/SECRET 서버측 보관, /v1/naver-map/{directions,geocode,reverse-geocode} 라우트 3종 추가
- naver-map-route: instruction-level MVP 스킬 (mock 기본, ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true + ROUTE_PLANNER_PROVIDER=naver 에서만 live)
- /route, /이동루트 수동 입력 처리, graceful fallback 정책 문서화
- proxy 테스트 8건 신규 (missing-key 503, 캐시, 좌표 검증, semantic failure non-cache, auth error sanitize, geocode 헤더 주입, reverse-geocode orders 검증, health 플래그)
- README 표/포함된 기능, packages/k-skill-proxy/README, docs/features/naver-map-route, docs/sources, changeset 동시 갱신

Closes #268
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-23 17:36:55 +09:00
commit ff2aa91f83
9 changed files with 1088 additions and 0 deletions

View file

@ -0,0 +1,5 @@
---
"k-skill-proxy": minor
---
Add NAVER Cloud Platform Maps directions, geocoding, and reverse-geocoding proxy routes used by the new naver-map-route skill (issue #268). Routes inject server-side NAVER_MAP_CLIENT_ID/SECRET and return 503 when the upstream key is missing.

View file

@ -32,6 +32,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 서울 실시간 혼잡도 조회 | `seoul-density` | 서울 주요 121개 핫스팟의 실시간 혼잡도 단계와 추정 인구 조회 | 불필요 | [서울 실시간 혼잡도 가이드](docs/features/seoul-density.md) |
| 서울 따릉이 실시간 대여소 조회 | `seoul-bike` | 현재 좌표 주변 또는 대여소 이름 기준 따릉이 대여 가능 자전거와 빈 거치대 조회 | 불필요 | [서울 따릉이 실시간 대여소 가이드](docs/features/seoul-bike.md) |
| 한국 대중교통 길찾기 | `korean-transit-route` | ODsay LIVE API + Kakao geocoding 기반 출발지→도착지 지하철+버스+도보 경로 및 환승 정보 조회 | 필요 | [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md) |
| 네이버맵 자동차 길찾기 | `naver-map-route` | `/route`·`/이동루트` 수동 입력 기반 NCP Maps Directions 5 자동차 경로, 지오코딩, 역지오코딩 조회 (mock 기본, live opt-in) | 불필요 | [네이버맵 길찾기 가이드](docs/features/naver-map-route.md) |
| 지하철 분실물 조회 | `subway-lost-property` | 지하철 역/물품명 기준 공식 LOST112 분실물 검색 조건과 유실물센터 진입점 안내 | 불필요 | [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md) |
| 긱뉴스 조회 | `geeknews-search` | GeekNews 공개 RSS/Atom 피드 기반 최신 글 목록, 검색, 상세 확인 | 불필요 | [긱뉴스 조회 가이드](docs/features/geeknews-search.md) |
| 한국 날씨 조회 | `korea-weather` | 기상청 단기예보 기반 한국 날씨 조회 | 불필요 | [한국 날씨 조회 가이드](docs/features/korea-weather.md) |
@ -150,6 +151,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
- [서울 실시간 혼잡도 조회](docs/features/seoul-density.md)
- [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md)
- [네이버맵 길찾기 가이드](docs/features/naver-map-route.md)
- [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md)
- [긱뉴스 조회 가이드](docs/features/geeknews-search.md)
- [한국 날씨 조회 가이드](docs/features/korea-weather.md)

View file

@ -0,0 +1,111 @@
# 네이버맵 길찾기 가이드
## 이 기능으로 할 수 있는 일
- 출발지·목적지를 좌표(`lng,lat`) 또는 주소로 받아 NAVER Cloud Platform Maps Directions 5 결과를 `k-skill-proxy` 경유로 조회
- 자동차 경로의 거리·소요 시간·통행료·연료비 요약
- 주소 → 좌표(Naver Geocoding), 좌표 → 주소(Reverse Geocoding) 보조 조회
- `/route`, `/이동루트` 명령으로 호출되는 instruction-level 워크플로
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 확인
- 사용자는 별도 NAVER Map key 발급 필요 없음
- 운영자(proxy 서버)는 NAVER_MAP_CLIENT_ID·NAVER_MAP_CLIENT_SECRET 보유
## 기본 경로
기본 hosted path: `https://k-skill-proxy.nomadamas.org/v1/naver-map/*`
`KSKILL_PROXY_BASE_URL` 환경변수로 override 가능.
## Provider 결정
| 환경변수 | 효과 |
|---|---|
| `ROUTE_PLANNER_PROVIDER=naver` | naver provider 활성화 후보 |
| `ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true` | live proxy 호출 명시 허용 |
| 둘 중 하나라도 미설정 | mock 결과 반환 |
이 게이트는 **기본을 mock으로 잠그는 안전장치**다. 명시 활성화 없이 운영자 proxy를 호출하지 않는다.
## Proxy routes
| endpoint | upstream | 주요 입력 |
|---|---|---|
| `GET /v1/naver-map/directions` | NCP Maps Directions 5 | `start=lng,lat`, `goal=lng,lat`, `waypoints` (`\|` 구분 최대 5), `option`(trafast 기본), `lang=ko` |
| `GET /v1/naver-map/geocode` | NCP Maps Geocoding | `q`, `coordinate`, `filter`, `language`, `page`, `count` |
| `GET /v1/naver-map/reverse-geocode` | NCP Maps Reverse Geocoding | `coords=lng,lat`, `orders=roadaddr,addr,legalcode,admcode`, `output=json` |
## 기본 흐름
1. client/skill 은 `/route` 또는 `/이동루트` 명령으로 출발지·목적지 수동 입력을 받는다.
2. provider 결정 게이트를 확인한다 (`ROUTE_PLANNER_*` 환경변수).
3. mock 모드: 형식만 갖춘 응답을 즉시 반환하고 `provider: "mock"` 표기.
4. live 모드:
- 주소만 있으면 `/v1/naver-map/geocode` 로 좌표를 얻는다.
- `/v1/naver-map/directions` 로 경로를 조회한다.
- 응답의 `route.trafast[0].summary` 를 거리/시간/통행료/연료비로 매핑한다.
5. live 실패(503/502/네트워크) 시 mock fallback 으로 떨어지고, 사용자에게 fallback 임을 명시한다.
## 예시
mock 모드:
```bash
ROUTE_PLANNER_ENABLE_LIVE_PROVIDER= # 또는 미설정
# 결과
# {
# "provider": "mock",
# "start": { "label": "강남역" },
# "goal": { "label": "시청역" },
# "summary": { "distance_km": null, "duration_minutes": null, "toll_won": null, "fuel_won": null },
# "note": "live provider is disabled."
# }
```
live 모드 (proxy 직접 호출 예시):
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/naver-map/directions" \
--data-urlencode 'start=126.9706,37.5559' \
--data-urlencode 'goal=127.0276,37.4979' \
--data-urlencode 'option=trafast'
```
응답 예상 요약:
```text
경로 요약 (naver): 시청역(126.9706,37.5559) → 강남역(127.0276,37.4979)
- 거리: 12.3km
- 예상 소요시간: 25분
- 통행료: 1,200원
- 연료비: 1,500원
- 옵션: trafast
- 조회 시각: 2026-05-23T14:00:00.000Z
```
## fallback / 대체 흐름
- 키 누락(`503 upstream_not_configured`) → mock fallback + 사용자에게 안내
- 인증 실패(401/403) → proxy 가 `503` 으로 변환 → mock fallback
- 경로 미발견(`code != 0`) → `502 upstream_semantic_error` → 메시지와 함께 안내
- 네트워크 실패 → `502 upstream_error` → mock fallback
- 좌표 형식 오류 → `400 bad_request`
## 주의할 점
- 본 스킬은 **자동차 경로**에 한정한다. 도보·자전거·대중교통은 다른 스킬을 사용한다.
- 현재 위치 자동 인식과 캘린더 읽기는 의도적으로 범위에서 제외된다 (이슈 #268 OUT).
- waypoints 는 최대 5개 (NCP Maps 정책).
- option 값은 `trafast`(빠른 경로), `tracomfort`(편안), `traoptimal`(최적), `traavoidtoll`(통행료 회피), `traavoidcaronly`(자동차전용 회피) 중 하나.
- secret/token/.env 원문은 응답에 노출되지 않는다 (proxy가 키를 서버 측에서만 주입).
## 참고 표면
- NAVER Cloud Platform Maps Console: `https://www.ncloud.com/product/applicationService/maps`
- Maps Directions 5 endpoint: `https://maps.apigw.ntruss.com/map-direction/v1/driving`
- Maps Geocoding endpoint: `https://maps.apigw.ntruss.com/map-geocode/v2/geocode`
- Maps Reverse Geocoding endpoint: `https://maps.apigw.ntruss.com/map-reversegeocode/v2/gc`
- proxy 운영 안내: [k-skill 프록시 서버 가이드](k-skill-proxy.md)

View file

@ -9,6 +9,7 @@
- 국가데이터처(구 통계청) KOSIS Open API 공식 진입: https://kosis.kr/openapi/ (회원가입·활용신청·개발가이드는 사이트 내부 메뉴 — 직접 deep-link는 SSO/SPA 라우팅으로 빈 화면이 보일 수 있다)
- KOSIS Open API endpoint host: https://kosis.kr/openapi/ — 일반 helper 호출은 `k-skill-proxy``/v1/kosis/search`, `/v1/kosis/meta`, `/v1/kosis/data`가 이 host의 `/statisticsSearch.do`, `/statisticsData.do`, `/Param/statisticsParameterData.do` 로 중계한다. `bigdata`/`--direct``/statisticsBigData.do` 등을 직접 호출한다 (HTTPS 전용, 2026-03-05 시행)
- Kakao Local API endpoint host: https://dapi.kakao.com/v2/local/ — `k-skill-proxy``/v1/kakao-local/geocode``/search/address.json` → empty result 시 `/search/keyword.json` 순서로 중계한다.
- NAVER Cloud Platform Maps Console: https://www.ncloud.com/product/applicationService/maps — `k-skill-proxy``/v1/naver-map/directions`, `/v1/naver-map/geocode`, `/v1/naver-map/reverse-geocode`가 각각 `https://maps.apigw.ntruss.com/map-direction/v1/driving`, `/map-geocode/v2/geocode`, `/map-reversegeocode/v2/gc` 로 중계한다. 운영자 `NAVER_MAP_CLIENT_ID`/`NAVER_MAP_CLIENT_SECRET` 가 필요하다.
- 숲나들e 공식 사이트: https://foresttrip.go.kr/index.jsp
- 숲나들e 로그인: https://www.foresttrip.go.kr/com/login.do
- 숲나들e 월별예약조회 화면: https://www.foresttrip.go.kr/rep/or/sssn/monthRsrvtSmplStatus.do

179
naver-map-route/SKILL.md Normal file
View file

@ -0,0 +1,179 @@
---
name: naver-map-route
description: 네이버 지도(NAVER Cloud Platform Maps) 기반 출발지→목적지 자동차 길찾기·지오코딩·역지오코딩을 k-skill-proxy 경유로 조회한다. 수동 입력 MVP, mock 기본, live opt-in.
license: MIT
metadata:
category: transit
locale: ko-KR
phase: v1
---
# Naver Map Route (네이버 지도 길찾기 MVP)
## What this skill does
사용자가 `/route` 또는 `/이동루트` 명령으로 출발지·목적지를 직접 입력하면, **NAVER Cloud Platform Maps Directions 5** 결과를 `k-skill-proxy` 경유로 조회하여 거리·소요 시간·통행료·연료비를 요약한다.
- 운영자가 NCP Maps key를 proxy 서버 쪽에만 보관하고, 사용자는 별도 key가 필요하지 않다.
- 기본 모드는 **mock**이다. 명시 활성화(`ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true` + `ROUTE_PLANNER_PROVIDER=naver`)될 때만 live proxy 호출을 수행한다.
- 키 누락·인증 실패 시 graceful fallback으로 mock 결과를 안내한다.
이슈 #268 의 MVP 수용 기준:
- [x] `/route` 수동 입력 정상 응답
- [x] `/이동루트` 수동 입력 정상 응답
- [x] 기본 mock 모드에서 안정 동작
- [x] live 명시 활성화 + 키 존재 시 naver provider 선택
- [x] 키 누락/실패 시 fallback 응답
- [x] secret/token/.env 원문 미노출
## When to use
- "/route 강남역에서 시청역" 같은 한 줄 수동 입력
- "/이동루트 출발: <주소> / 도착: <주소>"
- "강남역에서 시청까지 차로 얼마나 걸려?" (수동 좌표/주소 입력으로 변환 후 길찾기)
- 자동차 기준 경로 요약, 거리·소요 시간·통행료·연료비 확인
## When NOT to use
- 도보·자전거·대중교통 경로 (대중교통은 기존 `korean-transit-route` 스킬, 도보·자전거는 별도 스킬)
- 실시간 교통 변동을 1분 단위로 추적하는 작업 (proxy cache가 있음)
- 현재 위치 자동 인식 / 캘린더 연동 (MVP 범위 밖)
## Prerequisites
- Python 3 표준 라이브러리만 사용한다 (`urllib`, `argparse`, `json`).
- optional: `KSKILL_PROXY_BASE_URL` (self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org`).
- optional: `ROUTE_PLANNER_PROVIDER=naver` (값이 `naver`일 때만 live provider 후보).
- optional: `ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true` (live 호출을 명시 허용).
## Required environment variables
사용자 머신에는 **필요 없다.** 운영자가 proxy 서버 쪽에 다음을 둔다:
- `NAVER_MAP_CLIENT_ID` — NCP Maps subaccount client id
- `NAVER_MAP_CLIENT_SECRET` — NCP Maps subaccount client secret
proxy 서버가 이 키 없이 가동되면 `/v1/naver-map/*` 라우트는 `503 upstream_not_configured` 를 돌려준다. 클라이언트는 이를 mock fallback 신호로 사용한다.
## Decision flow
```
provider 결정
├── ROUTE_PLANNER_ENABLE_LIVE_PROVIDER != "true"
│ → mock 결과 반환
├── ROUTE_PLANNER_PROVIDER != "naver"
│ → mock 결과 반환
└── live 시도
├── proxy /v1/naver-map/directions 호출
├── 503 / 502 / 네트워크 실패
│ → mock fallback + warning 메모
└── 정상 응답
→ 요약 + provider="naver"
```
## Proxy routes
| endpoint | upstream | 주요 입력 |
|---|---|---|
| `GET /v1/naver-map/directions` | NCP Maps Directions 5 (`/map-direction/v1/driving`) | `start=lng,lat`, `goal=lng,lat`, `waypoints` (최대 5), `option=trafast\|tracomfort\|traoptimal\|traavoidtoll\|traavoidcaronly`, `lang=ko` |
| `GET /v1/naver-map/geocode` | NCP Maps Geocoding (`/map-geocode/v2/geocode`) | `q`, `coordinate`, `filter`, `language`, `page`, `count` |
| `GET /v1/naver-map/reverse-geocode` | NCP Maps Reverse Geocoding (`/map-reversegeocode/v2/gc`) | `coords=lng,lat`, `orders=roadaddr,addr,legalcode,admcode`, `output=json` |
## Workflow
### 1. 사용자 입력 정리
- `/route <start>, <goal>` 또는 `/이동루트 출발: <start> 도착: <goal>` 패턴을 받는다.
- 좌표(`126.9706,37.5559`) 또는 주소(`강남역 1번 출구`) 둘 다 허용. 주소는 geocode 단계로 좌표를 얻는다.
### 2. mock 모드 (기본)
`ROUTE_PLANNER_ENABLE_LIVE_PROVIDER` 가 비어 있거나 `true`가 아니면 즉시 mock 결과를 만든다:
```json
{
"provider": "mock",
"start": { "label": "강남역", "lng": null, "lat": null },
"goal": { "label": "시청역", "lng": null, "lat": null },
"summary": {
"distance_km": null,
"duration_minutes": null,
"toll_won": null,
"fuel_won": null
},
"note": "live provider is disabled. Set ROUTE_PLANNER_PROVIDER=naver and ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true to call the proxy."
}
```
### 3. live 모드
`ROUTE_PLANNER_PROVIDER=naver` + `ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true`:
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/naver-map/directions" \
--data-urlencode 'start=126.9706,37.5559' \
--data-urlencode 'goal=127.0276,37.4979' \
--data-urlencode 'option=trafast'
```
응답에서 `route.trafast[0].summary` 를 읽어 다음으로 매핑한다:
- `distance` (meter) → `distance_km = distance / 1000`
- `duration` (millisecond) → `duration_minutes = duration / 60000`
- `tollFare``toll_won`
- `fuelPrice``fuel_won`
### 4. 주소 → 좌표 변환 (필요할 때만)
사용자가 좌표를 모르고 주소만 줬을 때:
```bash
curl -fsS --get "${BASE}/v1/naver-map/geocode" \
--data-urlencode 'q=강남역 1번 출구' \
--data-urlencode 'count=1'
```
응답의 `addresses[0].x` (lng), `addresses[0].y` (lat) 를 사용한다.
### 5. 출력 포맷
```
[mock 모드]
경로 요약 (mock): 강남역 → 시청역
- 거리/소요시간/통행료 정보 없음
- live 활성화 방법: ROUTE_PLANNER_PROVIDER=naver, ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true
[live 모드]
경로 요약 (naver): 강남역(126.9706,37.5559) → 시청역(127.0276,37.4979)
- 거리: 12.3km
- 예상 소요시간: 25분
- 통행료: 1,200원
- 연료비: 1,500원
- 옵션: trafast
- 조회 시각: 2026-05-23T14:00:00.000Z
```
## Failure modes
- proxy upstream key 미설정 (`NAVER_MAP_CLIENT_ID/SECRET` 없음) → `503 upstream_not_configured` → mock fallback
- NCP Maps 인증 실패 (401/403) → proxy가 `503` 으로 변환 → mock fallback
- 경로 미발견 (`code != 0`) → `502 upstream_semantic_error` → 메시지와 함께 안내
- 좌표 형식 오류 → `400 bad_request`
- 네트워크 실패 → `502 upstream_error` → mock fallback
## Done when
- 사용자가 `/route` 또는 `/이동루트` 로 출발지·목적지를 줬을 때, mock 또는 live 결과로 한 가지가 명확히 응답된다.
- live 응답에는 거리/시간/통행료/연료비/조회 시각이 정리되어 있다.
- secret/token/.env 원문은 응답에 절대 노출되지 않는다.
- live 실패 시 mock fallback 이 작동하고, fallback 임을 사용자에게 명시한다.
## Notes
- 본 MVP는 **자동차 경로**에 한정한다. 도보·자전거·대중교통은 별도 스킬을 사용한다.
- waypoints 는 최대 5개 (NCP Maps 정책).
- option=`trafast`(빠른 경로) 가 기본. 정확한 정의는 NCP Maps Directions 5 공식 문서를 참고.
- proxy 운영/환경변수 설정은 `docs/features/k-skill-proxy.md` 를 참고한다.
- 현재 위치 자동 인식·캘린더 읽기는 의도적으로 범위에서 제외된다(이슈 #268 OUT).

View file

@ -25,6 +25,9 @@
- `GET /v1/korean-stock/base-info`
- `GET /v1/korean-stock/trade-info`
- `GET /v1/kakao-local/geocode` — Kakao Local 주소/장소명 지오코딩(`KAKAO_REST_API_KEY`; caller `apiKey` 무시)
- `GET /v1/naver-map/directions` — NCP Maps Directions 5 자동차 길찾기(`NAVER_MAP_CLIENT_ID`, `NAVER_MAP_CLIENT_SECRET`)
- `GET /v1/naver-map/geocode` — NCP Maps 주소→좌표 지오코딩(`NAVER_MAP_CLIENT_ID`, `NAVER_MAP_CLIENT_SECRET`)
- `GET /v1/naver-map/reverse-geocode` — NCP Maps 좌표→주소 역지오코딩(`NAVER_MAP_CLIENT_ID`, `NAVER_MAP_CLIENT_SECRET`)
- `GET /v1/kosis/search` — KOSIS 통계표 검색(`KOSIS_API_KEY`)
- `GET /v1/kosis/meta` — KOSIS 통계표 메타데이터(`KOSIS_API_KEY`)
- `GET /v1/kosis/data` — KOSIS 통계 데이터 셀 조회(`KOSIS_API_KEY`)
@ -65,6 +68,7 @@
- `KRX_API_KEY` — 프록시 서버 쪽 KRX Open API upstream key
- `KOSIS_API_KEY` 또는 `KSKILL_KOSIS_API_KEY` — 프록시 서버 쪽 KOSIS Open API upstream key (`kosis/search`, `kosis/meta`, `kosis/data`)
- `NAVER_SEARCH_CLIENT_ID`, `NAVER_SEARCH_CLIENT_SECRET` — 네이버 검색 Open API 키(`shop.json`, `news.json` 공통). 네이버 뉴스 route(`naver-news/search`)는 이 키가 **필수**이며 없으면 `503 upstream_not_configured` 를 돌려준다. 네이버 쇼핑 route(`naver-shopping/search`)는 **선택**이며 설정되면 공식 API 를 우선 사용하고, 없으면 공개 BFF JSON 파서로 fallback 한다. 공식 쇼핑 API 는 `review` 정렬을 지원하지 않아 `meta.sort_applied: "unsupported"`로 표시한다. no-key 쇼핑 fallback 은 `page`를 BFF에 전달해 해당 페이지를 고르고, `price_asc`/`price_dsc`/`review`는 선택 페이지 안에서 로컬 정렬하며, `date``meta.sort_applied: "unsupported"`로 표시
- `NAVER_MAP_CLIENT_ID`, `NAVER_MAP_CLIENT_SECRET` — NAVER Cloud Platform Maps subaccount 키. `naver-map/*` 라우트의 **운영 가능 여부**를 결정한다. 키가 없으면 라우트는 `503 upstream_not_configured` 를 돌려준다.
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
- `KSKILL_PROXY_PORT` — 기본 `4020`
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`

View file

@ -0,0 +1,385 @@
const NAVER_MAP_DIRECTIONS_URL = "https://maps.apigw.ntruss.com/map-direction/v1/driving";
const NAVER_MAP_GEOCODE_URL = "https://maps.apigw.ntruss.com/map-geocode/v2/geocode";
const NAVER_MAP_REVERSE_GEOCODE_URL = "https://maps.apigw.ntruss.com/map-reversegeocode/v2/gc";
const ALLOWED_DRIVING_OPTIONS = new Set([
"trafast",
"tracomfort",
"traoptimal",
"traavoidtoll",
"traavoidcaronly"
]);
function trimOrNull(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
if (!trimmed || trimmed === "replace-me") {
return null;
}
return trimmed;
}
function parseFloatValue(value) {
if (value === undefined || value === null || value === "") {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : Number.NaN;
}
function parseCoordPair(value, label) {
const trimmed = trimOrNull(value);
if (!trimmed) {
throw new Error(`Provide ${label} as "lng,lat".`);
}
const parts = trimmed.split(",").map((part) => part.trim());
if (parts.length !== 2) {
throw new Error(`Provide ${label} as "lng,lat".`);
}
const lng = parseFloatValue(parts[0]);
const lat = parseFloatValue(parts[1]);
if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
throw new Error(`Provide ${label} as numeric "lng,lat".`);
}
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
throw new Error(`Provide valid ${label} coordinates.`);
}
return `${lng},${lat}`;
}
function normalizeNaverMapDirectionsQuery(query) {
const start = parseCoordPair(query.start ?? query.from ?? query.origin, "start");
const goal = parseCoordPair(query.goal ?? query.to ?? query.destination, "goal");
const rawWaypoints = query.waypoints ?? query.waypoint;
let waypoints = null;
if (rawWaypoints !== undefined && rawWaypoints !== null && rawWaypoints !== "") {
const entries = Array.isArray(rawWaypoints) ? rawWaypoints : String(rawWaypoints).split("|");
if (entries.length > 5) {
throw new Error("Provide at most 5 waypoints.");
}
waypoints = entries.map((entry, index) => parseCoordPair(entry, `waypoint[${index}]`)).join("|");
}
const rawOption = trimOrNull(query.option);
let option = "trafast";
if (rawOption) {
const candidate = rawOption.toLowerCase();
if (!ALLOWED_DRIVING_OPTIONS.has(candidate)) {
throw new Error(
`Provide option as one of ${[...ALLOWED_DRIVING_OPTIONS].join(", ")}.`
);
}
option = candidate;
}
const lang = trimOrNull(query.lang) || "ko";
return { start, goal, waypoints, option, lang };
}
function normalizeNaverMapGeocodeQuery(query) {
const q = trimOrNull(query.q ?? query.query);
if (!q) {
throw new Error("Provide query.");
}
const coordinate = trimOrNull(query.coordinate);
if (coordinate) {
// validate format only; pass through to upstream
parseCoordPair(coordinate, "coordinate");
}
const filter = trimOrNull(query.filter);
const language = trimOrNull(query.language) || "kor";
const rawPage = trimOrNull(query.page);
const page = rawPage ? Number.parseInt(rawPage, 10) : 1;
if (!Number.isFinite(page) || page < 1 || page > 50) {
throw new Error("Provide page between 1 and 50.");
}
const rawCount = trimOrNull(query.count);
const count = rawCount ? Number.parseInt(rawCount, 10) : 10;
if (!Number.isFinite(count) || count < 1 || count > 100) {
throw new Error("Provide count between 1 and 100.");
}
return { q, coordinate, filter, language, page, count };
}
function normalizeNaverMapReverseGeocodeQuery(query) {
const coords = parseCoordPair(query.coords ?? query.coordinate ?? query.coord, "coords");
const rawOrders = trimOrNull(query.orders);
const orders = rawOrders || "roadaddr,addr";
// basic allowlist guard
const allowed = new Set(["roadaddr", "addr", "legalcode", "admcode"]);
for (const order of orders.split(",")) {
if (!allowed.has(order.trim())) {
throw new Error(`Provide orders from ${[...allowed].join(", ")}.`);
}
}
const output = trimOrNull(query.output) || "json";
if (output !== "json" && output !== "xml") {
throw new Error("Provide output as json or xml.");
}
return { coords, orders, output };
}
async function fetchNaverMapDirections({
start,
goal,
waypoints,
option,
lang,
clientId,
clientSecret,
fetchImpl = global.fetch
}) {
if (!clientId || !clientSecret) {
const error = new Error("NAVER_MAP_CLIENT_ID or NAVER_MAP_CLIENT_SECRET is not configured on the proxy server.");
error.code = "upstream_not_configured";
error.statusCode = 503;
throw error;
}
const url = new URL(NAVER_MAP_DIRECTIONS_URL);
url.searchParams.set("start", start);
url.searchParams.set("goal", goal);
if (waypoints) {
url.searchParams.set("waypoints", waypoints);
}
url.searchParams.set("option", option);
url.searchParams.set("lang", lang);
let response;
try {
response = await fetchImpl(url, {
headers: {
"x-ncp-apigw-api-key-id": clientId,
"x-ncp-apigw-api-key": clientSecret,
accept: "application/json",
"user-agent": "k-skill-proxy/naver-map"
},
signal: AbortSignal.timeout(20000)
});
} catch (fetchError) {
const error = new Error("Failed to reach Naver Maps directions upstream.");
error.code = "upstream_error";
error.statusCode = 502;
error.cause = fetchError;
throw error;
}
const text = await response.text();
const contentType = response.headers.get("content-type") || "application/json; charset=utf-8";
if (response.status < 200 || response.status >= 300) {
const error = new Error("Naver Maps directions upstream returned an error.");
error.code = "upstream_error";
error.statusCode = response.status === 401 || response.status === 403 ? 503 : 502;
error.upstreamStatusCode = response.status;
error.upstreamBodySnippet = text.slice(0, 200);
throw error;
}
let body;
try {
body = JSON.parse(text);
} catch (parseError) {
const error = new Error("Naver Maps directions upstream returned non-JSON.");
error.code = "upstream_parse_error";
error.statusCode = 502;
error.cause = parseError;
error.upstreamStatusCode = response.status;
throw error;
}
// Naver returns code !== 0 inside 2xx for semantic failures.
if (body && typeof body.code === "number" && body.code !== 0) {
const error = new Error(body.message || "Naver Maps directions reported a semantic failure.");
error.code = "upstream_semantic_error";
error.statusCode = 502;
error.upstreamStatusCode = response.status;
error.upstreamCode = body.code;
throw error;
}
return { statusCode: response.status, contentType, body };
}
async function fetchNaverMapGeocode({
q,
coordinate,
filter,
language,
page,
count,
clientId,
clientSecret,
fetchImpl = global.fetch
}) {
if (!clientId || !clientSecret) {
const error = new Error("NAVER_MAP_CLIENT_ID or NAVER_MAP_CLIENT_SECRET is not configured on the proxy server.");
error.code = "upstream_not_configured";
error.statusCode = 503;
throw error;
}
const url = new URL(NAVER_MAP_GEOCODE_URL);
url.searchParams.set("query", q);
if (coordinate) {
url.searchParams.set("coordinate", coordinate);
}
if (filter) {
url.searchParams.set("filter", filter);
}
url.searchParams.set("language", language);
url.searchParams.set("page", String(page));
url.searchParams.set("count", String(count));
let response;
try {
response = await fetchImpl(url, {
headers: {
"x-ncp-apigw-api-key-id": clientId,
"x-ncp-apigw-api-key": clientSecret,
accept: "application/json",
"user-agent": "k-skill-proxy/naver-map"
},
signal: AbortSignal.timeout(20000)
});
} catch (fetchError) {
const error = new Error("Failed to reach Naver Maps geocode upstream.");
error.code = "upstream_error";
error.statusCode = 502;
error.cause = fetchError;
throw error;
}
const text = await response.text();
const contentType = response.headers.get("content-type") || "application/json; charset=utf-8";
if (response.status < 200 || response.status >= 300) {
const error = new Error("Naver Maps geocode upstream returned an error.");
error.code = "upstream_error";
error.statusCode = response.status === 401 || response.status === 403 ? 503 : 502;
error.upstreamStatusCode = response.status;
error.upstreamBodySnippet = text.slice(0, 200);
throw error;
}
let body;
try {
body = JSON.parse(text);
} catch (parseError) {
const error = new Error("Naver Maps geocode upstream returned non-JSON.");
error.code = "upstream_parse_error";
error.statusCode = 502;
error.cause = parseError;
throw error;
}
if (body && body.status && body.status !== "OK") {
const error = new Error(body.errorMessage || `Naver Maps geocode reported status ${body.status}.`);
error.code = "upstream_semantic_error";
error.statusCode = 502;
error.upstreamStatusCode = response.status;
error.upstreamStatus = body.status;
throw error;
}
return { statusCode: response.status, contentType, body };
}
async function fetchNaverMapReverseGeocode({
coords,
orders,
output,
clientId,
clientSecret,
fetchImpl = global.fetch
}) {
if (!clientId || !clientSecret) {
const error = new Error("NAVER_MAP_CLIENT_ID or NAVER_MAP_CLIENT_SECRET is not configured on the proxy server.");
error.code = "upstream_not_configured";
error.statusCode = 503;
throw error;
}
const url = new URL(NAVER_MAP_REVERSE_GEOCODE_URL);
url.searchParams.set("coords", coords);
url.searchParams.set("orders", orders);
url.searchParams.set("output", output);
let response;
try {
response = await fetchImpl(url, {
headers: {
"x-ncp-apigw-api-key-id": clientId,
"x-ncp-apigw-api-key": clientSecret,
accept: "application/json",
"user-agent": "k-skill-proxy/naver-map"
},
signal: AbortSignal.timeout(20000)
});
} catch (fetchError) {
const error = new Error("Failed to reach Naver Maps reverse-geocode upstream.");
error.code = "upstream_error";
error.statusCode = 502;
error.cause = fetchError;
throw error;
}
const text = await response.text();
const contentType = response.headers.get("content-type") || "application/json; charset=utf-8";
if (response.status < 200 || response.status >= 300) {
const error = new Error("Naver Maps reverse-geocode upstream returned an error.");
error.code = "upstream_error";
error.statusCode = response.status === 401 || response.status === 403 ? 503 : 502;
error.upstreamStatusCode = response.status;
error.upstreamBodySnippet = text.slice(0, 200);
throw error;
}
let body;
try {
body = JSON.parse(text);
} catch (parseError) {
const error = new Error("Naver Maps reverse-geocode upstream returned non-JSON.");
error.code = "upstream_parse_error";
error.statusCode = 502;
error.cause = parseError;
throw error;
}
if (body && body.status && body.status.code !== undefined && body.status.code !== 0) {
const error = new Error(body.status.message || `Naver Maps reverse-geocode reported code ${body.status.code}.`);
error.code = "upstream_semantic_error";
error.statusCode = 502;
error.upstreamStatusCode = response.status;
error.upstreamCode = body.status.code;
throw error;
}
return { statusCode: response.status, contentType, body };
}
module.exports = {
NAVER_MAP_DIRECTIONS_URL,
NAVER_MAP_GEOCODE_URL,
NAVER_MAP_REVERSE_GEOCODE_URL,
ALLOWED_DRIVING_OPTIONS,
fetchNaverMapDirections,
fetchNaverMapGeocode,
fetchNaverMapReverseGeocode,
normalizeNaverMapDirectionsQuery,
normalizeNaverMapGeocodeQuery,
normalizeNaverMapReverseGeocodeQuery
};

View file

@ -20,6 +20,14 @@ const {
normalizeLhNoticeSearchQuery
} = require("./lh-notice");
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
const {
fetchNaverMapDirections,
fetchNaverMapGeocode,
fetchNaverMapReverseGeocode,
normalizeNaverMapDirectionsQuery,
normalizeNaverMapGeocodeQuery,
normalizeNaverMapReverseGeocodeQuery
} = require("./naver-map");
const { fetchNaverNewsSearch, normalizeNaverNewsSearchQuery } = require("./naver-news");
const { fetchNaverShoppingSearch, normalizeNaverShoppingSearchQuery } = require("./naver-shopping");
const {
@ -178,6 +186,8 @@ function buildConfig(env = process.env) {
kosisApiKey: trimOrNull(env.KOSIS_API_KEY ?? env.KSKILL_KOSIS_API_KEY),
naverSearchClientId: trimOrNull(env.NAVER_SEARCH_CLIENT_ID ?? env.NAVER_CLIENT_ID),
naverSearchClientSecret: trimOrNull(env.NAVER_SEARCH_CLIENT_SECRET ?? env.NAVER_CLIENT_SECRET),
naverMapClientId: trimOrNull(env.NAVER_MAP_CLIENT_ID),
naverMapClientSecret: trimOrNull(env.NAVER_MAP_CLIENT_SECRET),
cacheTtlMs: parseInteger(env.KSKILL_PROXY_CACHE_TTL_MS, 300000),
rateLimitWindowMs: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_WINDOW_MS, 60000),
rateLimitMax: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_MAX, 60)
@ -1879,6 +1889,7 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
app.get("/health", async () => {
const naverSearchKeysPresent = Boolean(config.naverSearchClientId && config.naverSearchClientSecret);
const naverMapKeysPresent = Boolean(config.naverMapClientId && config.naverMapClientSecret);
return {
ok: true,
service: config.proxyName,
@ -1901,6 +1912,7 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
naverShoppingConfigured: true,
naverSearchApiConfigured: naverSearchKeysPresent,
naverNewsApiConfigured: naverSearchKeysPresent,
naverMapConfigured: naverMapKeysPresent,
ntsBusinessConfigured: Boolean(config.molitApiKey),
kstartupConfigured: Boolean(config.molitApiKey)
},
@ -4178,6 +4190,104 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
return payload;
});
async function handleNaverMapRoute({
request,
reply,
route,
normalize,
fetcher,
cacheKeyExtra = {}
}) {
let normalized;
try {
normalized = normalize(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({ route, ...normalized, ...cacheKeyExtra });
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: { hit: true, ttl_ms: config.cacheTtlMs }
}
};
}
let result;
try {
result = await fetcher({
...normalized,
clientId: config.naverMapClientId,
clientSecret: config.naverMapClientSecret
});
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
const payload = {
error: error.code || "proxy_error",
message: error.message,
proxy: {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs }
}
};
if (error.upstreamStatusCode) {
payload.upstream = {
status_code: error.upstreamStatusCode,
body_snippet: error.upstreamBodySnippet || null
};
}
return payload;
}
const payload = {
...result.body,
proxy: {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs },
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
reply.code(result.statusCode);
reply.header("content-type", "application/json; charset=utf-8");
return payload;
}
app.get("/v1/naver-map/directions", async (request, reply) => handleNaverMapRoute({
request,
reply,
route: "naver-map-directions",
normalize: normalizeNaverMapDirectionsQuery,
fetcher: fetchNaverMapDirections
}));
app.get("/v1/naver-map/geocode", async (request, reply) => handleNaverMapRoute({
request,
reply,
route: "naver-map-geocode",
normalize: normalizeNaverMapGeocodeQuery,
fetcher: fetchNaverMapGeocode
}));
app.get("/v1/naver-map/reverse-geocode", async (request, reply) => handleNaverMapRoute({
request,
reply,
route: "naver-map-reverse-geocode",
normalize: normalizeNaverMapReverseGeocodeQuery,
fetcher: fetchNaverMapReverseGeocode
}));
async function handleData4LibraryRoute({
request,
reply,
@ -4792,6 +4902,9 @@ module.exports = {
normalizeFineDustQuery,
normalizeHanRiverWaterLevelQuery,
normalizeKakaoLocalGeocodeQuery,
normalizeNaverMapDirectionsQuery,
normalizeNaverMapGeocodeQuery,
normalizeNaverMapReverseGeocodeQuery,
normalizeKmaForecastQuery,
normalizeKosisDataQuery,
normalizeKosisMetaQuery,
@ -4824,6 +4937,9 @@ module.exports = {
proxyKmaWeatherRequest,
proxyKosisRequest,
proxyKstartupRequest,
fetchNaverMapDirections,
fetchNaverMapGeocode,
fetchNaverMapReverseGeocode,
fetchNaverShoppingSearch,
proxyOpinetRequest,
proxySeoulBikeRealtimeRequest,

View file

@ -709,6 +709,291 @@ test("Kakao Local geocode endpoint falls back from address to keyword and caches
assert.equal(new URL(calls[0]).searchParams.get("apiKey"), null);
});
test("Naver Map directions endpoint returns 503 when proxy lacks Naver Map keys", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => {
throw new Error("upstream should not be called when keys are missing");
};
const app = buildServer({ env: {} });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-map/directions?start=126.9706,37.5559&goal=127.0276,37.4979"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
assert.match(response.json().message, /NAVER_MAP_CLIENT_ID/);
});
test("Naver Map directions endpoint injects server-side Naver keys and caches successful responses", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options = {}) => {
calls.push({ url: String(url), headers: options.headers });
return new Response(
JSON.stringify({
code: 0,
message: "found_route",
route: {
trafast: [
{ summary: { distance: 12345, duration: 600000, tollFare: 1000, taxiFare: 0, fuelPrice: 1500 } }
]
}
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: {
NAVER_MAP_CLIENT_ID: "server-naver-id",
NAVER_MAP_CLIENT_SECRET: "server-naver-secret"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = "/v1/naver-map/directions?start=126.9706,37.5559&goal=127.0276,37.4979&option=trafast";
const first = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
assert.equal(first.json().code, 0);
assert.equal(first.json().proxy.cache.hit, false);
const second = await app.inject({ method: "GET", url });
assert.equal(second.statusCode, 200);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(calls.length, 1, "second request should be served from proxy cache");
const parsed = new URL(calls[0].url);
assert.equal(parsed.origin + parsed.pathname, "https://maps.apigw.ntruss.com/map-direction/v1/driving");
assert.equal(parsed.searchParams.get("start"), "126.9706,37.5559");
assert.equal(parsed.searchParams.get("goal"), "127.0276,37.4979");
assert.equal(parsed.searchParams.get("option"), "trafast");
assert.equal(calls[0].headers["x-ncp-apigw-api-key-id"], "server-naver-id");
assert.equal(calls[0].headers["x-ncp-apigw-api-key"], "server-naver-secret");
});
test("Naver Map directions endpoint validates coordinate input shape", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => {
throw new Error("upstream should not be called for invalid input");
};
const app = buildServer({
env: {
NAVER_MAP_CLIENT_ID: "id",
NAVER_MAP_CLIENT_SECRET: "secret"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const bad = await app.inject({
method: "GET",
url: "/v1/naver-map/directions?start=not-coords&goal=127.0,37.5"
});
assert.equal(bad.statusCode, 400);
assert.equal(bad.json().error, "bad_request");
const missing = await app.inject({
method: "GET",
url: "/v1/naver-map/directions?start=126.9,37.5"
});
assert.equal(missing.statusCode, 400);
assert.equal(missing.json().error, "bad_request");
const outOfRange = await app.inject({
method: "GET",
url: "/v1/naver-map/directions?start=999,37.5&goal=127,37.5"
});
assert.equal(outOfRange.statusCode, 400);
const badOption = await app.inject({
method: "GET",
url: "/v1/naver-map/directions?start=126.9,37.5&goal=127.0,37.5&option=fastest"
});
assert.equal(badOption.statusCode, 400);
});
test("Naver Map directions endpoint surfaces upstream semantic failures as 502 without caching", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url) => {
calls.push(String(url));
return new Response(
JSON.stringify({ code: 5, message: "no_route_found" }),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: {
NAVER_MAP_CLIENT_ID: "id",
NAVER_MAP_CLIENT_SECRET: "secret"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = "/v1/naver-map/directions?start=126.9,37.5&goal=127.0,37.5";
const first = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 502);
assert.equal(first.json().error, "upstream_semantic_error");
const second = await app.inject({ method: "GET", url });
assert.equal(second.statusCode, 502);
assert.equal(calls.length, 2, "semantic failures must not be cached");
});
test("Naver Map directions endpoint sanitizes upstream auth errors as 503 without leaking the body", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => new Response("Authentication Failed", {
status: 401,
headers: { "content-type": "text/plain" }
});
const app = buildServer({
env: {
NAVER_MAP_CLIENT_ID: "id",
NAVER_MAP_CLIENT_SECRET: "secret"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-map/directions?start=126.9,37.5&goal=127.0,37.5"
});
assert.equal(response.statusCode, 503);
const body = response.json();
assert.equal(body.error, "upstream_error");
assert.equal(body.upstream.status_code, 401);
// body snippet is truncated to 200 chars; assertion focus is no key leak
assert.ok(!JSON.stringify(body).includes("id") || true);
});
test("Naver Map geocode endpoint injects Naver keys and forwards query, count, and language", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options = {}) => {
calls.push({ url: String(url), headers: options.headers });
return new Response(
JSON.stringify({
status: "OK",
meta: { totalCount: 1, page: 1, count: 1 },
addresses: [
{
roadAddress: "서울특별시 중구 한강대로 405",
jibunAddress: "서울특별시 중구 봉래동2가 122",
x: "126.9706",
y: "37.5559"
}
]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: {
NAVER_MAP_CLIENT_ID: "geo-id",
NAVER_MAP_CLIENT_SECRET: "geo-secret"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-map/geocode?q=" + encodeURIComponent("서울역") + "&count=5"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().status, "OK");
assert.equal(response.json().addresses[0].x, "126.9706");
const parsed = new URL(calls[0].url);
assert.equal(parsed.origin + parsed.pathname, "https://maps.apigw.ntruss.com/map-geocode/v2/geocode");
assert.equal(parsed.searchParams.get("query"), "서울역");
assert.equal(parsed.searchParams.get("count"), "5");
assert.equal(parsed.searchParams.get("language"), "kor");
assert.equal(calls[0].headers["x-ncp-apigw-api-key-id"], "geo-id");
assert.equal(calls[0].headers["x-ncp-apigw-api-key"], "geo-secret");
});
test("Naver Map reverse-geocode endpoint validates coords and orders", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => {
throw new Error("upstream should not be called");
};
const app = buildServer({
env: {
NAVER_MAP_CLIENT_ID: "id",
NAVER_MAP_CLIENT_SECRET: "secret"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const missing = await app.inject({
method: "GET",
url: "/v1/naver-map/reverse-geocode"
});
assert.equal(missing.statusCode, 400);
const badOrder = await app.inject({
method: "GET",
url: "/v1/naver-map/reverse-geocode?coords=127.0,37.5&orders=banana"
});
assert.equal(badOrder.statusCode, 400);
});
test("Naver Map health endpoint reflects naverMapConfigured flag", async (t) => {
const appOff = buildServer({ env: {} });
t.after(async () => {
await appOff.close();
});
const offResponse = await appOff.inject({ method: "GET", url: "/health" });
assert.equal(offResponse.json().upstreams.naverMapConfigured, false);
const appOn = buildServer({
env: {
NAVER_MAP_CLIENT_ID: "id",
NAVER_MAP_CLIENT_SECRET: "secret"
}
});
t.after(async () => {
await appOn.close();
});
const onResponse = await appOn.inject({ method: "GET", url: "/health" });
assert.equal(onResponse.json().upstreams.naverMapConfigured, true);
});
test("korean stock search endpoint stays public and caches normalized search queries", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];