diff --git a/.env.example b/.env.example index 994b8c5a..525f4015 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ # Zyte 유료 호출은 중지되어 있으며 키를 설정해도 활성화되지 않습니다. # ZYTE_API_KEY=legacy_key_unused -GOOGLE_MAPS_API_KEY=your_google_maps_api_key_here +# 카카오맵 무료 쿼터 대상 앱에서 Maps API를 활성화한 뒤 설정합니다. +KAKAO_REST_API_KEY=your_kakao_rest_api_key_here +# GOOGLE_MAPS_API_KEY는 위치 검색에서 사용하지 않습니다. NAVER_CLIENT_ID=your_naver_client_id_here NAVER_CLIENT_SECRET=your_naver_client_secret_here OPINET_API_KEY=your_opinet_api_key_here diff --git a/README.md b/README.md index 00c9f60c..9a491817 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ # Daiso MCP 및 Skill -다이소(제품/매장/재고), 상품 가격 비교, 주변 음식점/카페, 주유소/유가, 개발자 요청 제출, 롯데마트(매장/상품), GS25(매장/상품/재고), 세븐일레븐(상품/매장/재고/인기검색어/카탈로그), CU(매장/재고), 이마트24(매장/상품/재고), 올리브영(매장/재고), 메가박스(지점/영화/시간표/좌석), 롯데시네마(지점/영화/좌석), CGV(극장/영화/시간표), 디트릭스(독립·예술영화관/상영작/잔여 좌석) 조회 기능을 MCP, CLI, Codex Skill로 AI에 연결합니다. +다이소(제품/매장/재고), 상품 가격 비교, 주변 음식점/카페, 주유소/유가, 개발자 요청 제출, GS25(매장/상품/재고), 세븐일레븐(상품/매장/재고/인기검색어/카탈로그), CU(매장/재고), 이마트24(매장/상품/재고), 올리브영(매장/재고), 메가박스(지점/영화/시간표/좌석), 롯데시네마(지점/영화/좌석), CGV(극장/영화/시간표), 디트릭스(독립·예술영화관/상영작/잔여 좌석) 조회 기능을 MCP, CLI, Codex Skill로 AI에 연결합니다. 디트릭스의 지원 범위와 CLI 예시는 [독립·예술영화관 조회 가이드](docs/dtryx.md)를 참고하세요. @@ -57,7 +57,7 @@ 리테일 - 다이소, 올리브영, 롯데마트 + 다이소, 올리브영 상품, 매장, 재고 @@ -95,7 +95,7 @@ > [!IMPORTANT] -> 최근 공개 서버 사용량이 크게 증가하여 2026년 7월 18일부터 올리브영·CGV·CU·GS25·롯데마트의 검색을 포함한 공개 GET API에 IP당 하루 합산 3,000회(KST 기준)의 호출 제한을 적용합니다. 한도를 초과하는 사용이 필요하다면 Daiso MCP는 오픈 소스이므로 이 저장소를 직접 배포해 이용해 주세요. +> 최근 공개 서버 사용량이 크게 증가하여 2026년 7월 18일부터 올리브영·CGV·CU·GS25의 검색을 포함한 공개 GET API에 IP당 하루 합산 3,000회(KST 기준)의 호출 제한을 적용합니다. 한도를 초과하는 사용이 필요하다면 Daiso MCP는 오픈 소스이므로 이 저장소를 직접 배포해 이용해 주세요.
@@ -136,8 +136,6 @@ ChatGPT, Claude, Home Assistant, Grok 같은 AI 앱에서 바로 연결해 사 콜라 어디가 싼지 비교해줘 올리브영 mcp로 명동 근처 매장 찾아줘 이마트24 mcp로 강남 근처 매장과 두바이 재고 알려줘 -롯데마트 mcp로 잠실 근처 매장 찾아줘 -롯데마트 mcp로 강변점에서 콜라 재고 알려줘 GS25 mcp로 강남 근처 매장과 오감자 재고 알려줘 세븐일레븐 mcp로 삼각김밥 검색해줘 세븐일레븐 mcp로 안산 중앙역 근처 매장 찾아줘 @@ -183,8 +181,6 @@ CGV mcp로 강남 상영 영화와 시간표 알려줘 올리브영 mcp를 사용해서 명동 근처 매장 찾아줘 올리브영 mcp를 사용해서 선크림 재고 확인해줘 이마트24 mcp를 사용해서 강남 매장 찾고 두바이 재고 확인해줘 -롯데마트 mcp를 사용해서 잠실 근처 매장 찾아줘 -롯데마트 mcp를 사용해서 강변점에서 콜라 검색해줘 GS25 mcp를 사용해서 강남 매장 찾고 오감자 재고 확인해줘 세븐일레븐 mcp를 사용해서 안산 중앙역 근처 매장 찾고 핫식스 재고 확인해줘 메가박스 mcp를 사용해서 강남역 근처 지점 찾아줘 @@ -318,8 +314,6 @@ npx daiso cu-inventory 과자 --storeKeyword 강남 npx daiso cgv-theaters 강남 --limit 10 npx daiso cgv-movies --playDate 20260307 --theaterCode 0056 npx daiso cgv-timetable --playDate 20260307 --theaterCode 0056 -npx daiso lottemart-stores 잠실 --area 서울 --limit 10 -npx daiso lottemart-products 콜라 --storeName 강변점 --area 서울 npx daiso emart24-stores 강남 --service24h true npx daiso emart24-products 두바이 --pageSize 20 npx daiso emart24-inventory 8800244010504 --bizNoArr 28339,05015 @@ -404,7 +398,7 @@ GET /api/opinet/stations/around?x=314681.8&y=544837&radiusMeters=3000&fuelCode=B GET /api/opinet/station?id=A0010207 ``` -오피넷 API 키는 `OPINET_API_KEY` 환경 변수 또는 Cloudflare Worker Secret으로 설정합니다. 키 발급은 오피넷 웹사이트의 `유가관련정보 > 유가정보 API > 인증키 발급`에서 진행합니다. 반경 검색은 `lat/lng`, `location`, KATEC `x/y`를 모두 지원하며, `location` 검색에는 `GOOGLE_MAPS_API_KEY`가 필요합니다. +오피넷 API 키는 `OPINET_API_KEY` 환경 변수 또는 Cloudflare Worker Secret으로 설정합니다. 키 발급은 오피넷 웹사이트의 `유가관련정보 > 유가정보 API > 인증키 발급`에서 진행합니다. 반경 검색은 `lat/lng`, `location`, KATEC `x/y`를 모두 지원하며, `location` 검색에는 `KAKAO_REST_API_KEY`를 설정합니다. 역명·상호명은 네이버 지역 검색으로 보완합니다. 자세한 설정은 [무료 위치 검색 가이드](./docs/free-location-search.md)를 확인하세요. 운영 제약 및 캐싱 정책: @@ -415,7 +409,7 @@ GET /api/opinet/station?id=A0010207 - `GET /api/opinet/lowest`: 30분 캐시, 10분 stale-while-revalidate - `GET /api/opinet/stations/around`: 20분 캐시, 5분 stale-while-revalidate - `GET /api/opinet/station`: 60분 캐시, 10분 stale-while-revalidate -- `location` 키워드의 Google Geocoding 결과는 Worker 인스턴스 메모리에서 24시간 캐시합니다. +- 주소·장소명 변환은 카카오 주소/키워드 검색을 사용합니다. 직접 좌표를 입력하면 위치 변환 API를 호출하지 않습니다. - 호출 한도에 자주 도달하면 운영자가 담당 기관인 한국석유공사/오피넷에 문의해 추가 할당량 또는 별도 이용 조건을 협의할 예정입니다. ### 개발자 요청 제출 @@ -437,6 +431,10 @@ GET /api/feedback/requests?type=bug&title=제목&description=설명 로컬 실행이나 배포 환경에는 `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`를 설정하세요. 테이블 스키마는 [supabase-agent-requests.sql](./docs/supabase-agent-requests.sql)에 있습니다. +### 무료 위치 검색 + +주소·역명·상호명을 좌표로 바꾸는 기능은 카카오맵 무료 API를 사용합니다. Google Geocoding과 Zyte는 호출하지 않습니다. 카카오 무료 쿼터 대상 확인, 환경 변수와 실패 시 동작은 [무료 위치 검색 가이드](./docs/free-location-search.md)에 있습니다. + ### 운영 헬스 체크 공개 상태 페이지: **[Daiso MCP Status](https://aka-page.betteruptime.com/)** @@ -453,7 +451,7 @@ curl -H "Authorization: Bearer $HEALTH_CHECK_SECRET" \ 주요 쿼리: - `service=gs25`: 특정 서비스만 확인 -- `check=lottemart.products`: 특정 체크만 확인 +- `check=daiso.products`: 특정 체크만 확인 - `mode=quick|deep|full`: 체크 모드 선택 - `fresh=true`: 60초 캐시 우회 - `includeSamples=true`: 첫 결과 이름 샘플 포함 @@ -484,7 +482,7 @@ curl -H "x-health-check-key: $HEALTH_CHECK_SECRET" \ "https://mcp.aka.page/api/rate-limit/stats?service=cgv" ``` -쿼리 필터는 `from`, `to`, `service`입니다. `from`과 `to`는 함께 지정하거나 둘 다 생략해야 하며 날짜 형식은 `YYYY-MM-DD`입니다. `service`에는 `oliveyoung`, `cgv`, `cu`, `gs25`, `lottemart`만 사용할 수 있습니다. 날짜를 생략하면 현재 KST 일자를 포함한 최근 7일을 조회합니다. 조회 가능한 보관 범위는 현재 KST 일자와 그 이전 29일이며, 한 번에 KST 달력 날짜 기준 최대 30일을 요청할 수 있습니다. +쿼리 필터는 `from`, `to`, `service`입니다. `from`과 `to`는 함께 지정하거나 둘 다 생략해야 하며 날짜 형식은 `YYYY-MM-DD`입니다. `service`에는 `oliveyoung`, `cgv`, `cu`, `gs25`, `lottemart`만 사용할 수 있습니다. `lottemart` 필터는 지원 중단 전 호출 제한 통계를 조회하기 위해 유지합니다. 날짜를 생략하면 현재 KST 일자를 포함한 최근 7일을 조회합니다. 조회 가능한 보관 범위는 현재 KST 일자와 그 이전 29일이며, 한 번에 KST 달력 날짜 기준 최대 30일을 요청할 수 있습니다. 성공 응답은 전체 합계와 일별·서비스별 차단 요청 수와 고유 차단 주체 수를 제공합니다. @@ -640,7 +638,9 @@ daiso 인터랙티브 모드 - [이마트24 앱 스크래핑 준비 가이드](./docs/emart24-app-scraping-preparation-guide.md) - [이마트24 앱 스크래핑 리플레이 가이드](./docs/emart24-app-scraping-replay-guide.md) -### 롯데마트 +### 롯데마트 과거 분석 + +롯데마트 지원은 중단했습니다. 기존 REST 경로는 `410 SERVICE_RETIRED`, 기존 CLI 명령은 지원 중단 오류를 반환합니다. - [롯데마트 모바일 도와센터 스크래핑 리플레이 계획](./docs/lottemart-mobile-scraping-replay-plan.md) diff --git a/docs/ai-instruction.md b/docs/ai-instruction.md index 29aa60a0..acd03be8 100644 --- a/docs/ai-instruction.md +++ b/docs/ai-instruction.md @@ -1,9 +1,9 @@ -당신은 다이소/통합 가격 후보 비교/주변 음식점·카페/롯데마트/GS25/세븐일레븐/CU/이마트24/올리브영/메가박스/롯데시네마/CGV 생활·쇼핑·영화 도우미입니다. +당신은 다이소/통합 가격 후보 비교/주변 음식점·카페/GS25/세븐일레븐/CU/이마트24/올리브영/메가박스/롯데시네마/CGV 생활·쇼핑·영화 도우미입니다. -다이소/통합 가격 후보 비교/주변 음식점·카페/롯데마트/GS25/세븐일레븐/CU/이마트24/올리브영/메가박스/롯데시네마/CGV 관련 정보는 모두 mcp.aka.page 작업 기능만 +다이소/통합 가격 후보 비교/주변 음식점·카페/GS25/세븐일레븐/CU/이마트24/올리브영/메가박스/롯데시네마/CGV 관련 정보는 모두 mcp.aka.page 작업 기능만 사용하고, 웹검색은 하지 마세요. mcp.aka.page 작업 기능 호출 중 에러가 나면 아래 문구로 안내하세요: -"ChatGPT 계정 로그인이 필요합니다. (다이소/통합 가격 후보 비교/주변 음식점·카페/롯데마트/GS25/세븐일레븐/CU/이마트24/올리브영/메가박스/롯데시네마/CGV 서비스 +"ChatGPT 계정 로그인이 필요합니다. (다이소/통합 가격 후보 비교/주변 음식점·카페/GS25/세븐일레븐/CU/이마트24/올리브영/메가박스/롯데시네마/CGV 서비스 자체 로그인은 필요 없습니다.)" ## 언어 감지 및 역할 설정 @@ -32,7 +32,6 @@ mcp.aka.page 작업 기능 호출 중 에러가 나면 아래 문구로 안내 - 세븐일레븐: `seveneleven_*` 도구만 사용 - CU: `cu_*` 도구만 사용 - 이마트24: `emart24_*` 도구만 사용 -- 롯데마트: `lottemart_*` 도구만 사용 - 올리브영: `oliveyoung_*` 도구만 사용 - 통합 가격 후보 비교: `compare_products` 또는 `compareProducts` action 사용 - 개발자 요청 제출: `submit_developer_request` 또는 `submitDeveloperRequest` action 사용 @@ -78,8 +77,6 @@ mcp.aka.page 작업 기능 호출 중 에러가 나면 아래 문구로 안내 - 이마트24 매장 탐색: 키워드/지역 조건 기반 이마트24 매장 조회 - 이마트24 상품 검색: 키워드 기반 상품 목록 조회 - 이마트24 재고 확인: PLU 코드 + 매장 코드 목록 기반 재고 조회 -- 롯데마트 주변 매장 탐색: 지역/키워드/좌표 기반 롯데마트 계열 매장 조회 -- 롯데마트 상품 검색: 특정 매장을 기준으로 상품 가격/재고 조회 - 올리브영 주변 매장 탐색: 사용자 위치 기반 매장 조회 - 올리브영 재고 파악: 상품 키워드 기준 재고/품절 여부 및 주변 매장 정보 조회 - 메가박스 주변 지점 탐색: 사용자 위치 기준 가까운 영화관 조회 @@ -94,7 +91,7 @@ mcp.aka.page 작업 기능 호출 중 에러가 나면 아래 문구로 안내 ## 워크플로우 -1. 사용자가 브랜드(다이소/롯데마트/GS25/세븐일레븐/CU/이마트24/올리브영/메가박스/롯데시네마/CGV)를 명시하면 품목상 어울리지 않아 보여도 먼저 해당 서비스 도구로 실제 조회를 수행 +1. 사용자가 브랜드(다이소/GS25/세븐일레븐/CU/이마트24/올리브영/메가박스/롯데시네마/CGV)를 명시하면 품목상 어울리지 않아 보여도 먼저 해당 서비스 도구로 실제 조회를 수행 - 금지 예시: "핫식스는 다이소가 아니라 편의점 상품이라서 다이소 조회를 생략하겠습니다." - 올바른 예시: "먼저 다이소 기준으로 검색해보고, 없으면 그때 다른 브랜드를 제안하겠습니다." - 예시 해석: "안산 중앙역 주변 다이소 찾아주시고 핫식스 재고 찾아주세요"는 뒤의 핫식스 재고 요청도 다이소 기준으로 처리 @@ -114,7 +111,6 @@ mcp.aka.page 작업 기능 호출 중 에러가 나면 아래 문구로 안내 - 세븐일레븐: `keyword`로 재고 조회, 필요 시 `storeKeyword`를 함께 사용 - CU: `keyword`로 재고 조회, 필요 시 `storeKeyword`/위치(`lat`,`lng`) 반영 - 이마트24: 먼저 상품 검색으로 `pluCd`를 확인하고 `storeKeyword` 또는 `bizNoArr`와 함께 재고 조회 - - 롯데마트: 먼저 `lottemart_find_nearby_stores` 또는 매장명으로 점포를 특정한 뒤 `lottemart_search_products` 호출 - 올리브영: 상품 후보/종류 질문은 먼저 상품 검색, 재고 질문은 `keyword`로 재고 조회, 필요 시 `storeKeyword`/위치(`lat`,`lng`) 반영 - 메가박스: `keyword`나 좌표가 있으면 먼저 가까운 지점을 특정하고, 상영작/회차 조회 후 필요 시 `theaterId`/`movieId`로 잔여 좌석 조회 @@ -122,7 +118,7 @@ mcp.aka.page 작업 기능 호출 중 에러가 나면 아래 문구로 안내 - CGV: `keyword`나 좌표가 있으면 먼저 가까운 극장을 특정하고, 상영작/회차 조회 후 필요 시 `theaterCode`/`movieCode`로 잔여 좌석 조회 4. 결과는 핵심 정보 중심으로 보기 쉽게 정리 - - 다이소/롯데마트/GS25/세븐일레븐/CU/이마트24/올리브영: 상품명, 가격, 재고 여부, 매장명/거리 + - 다이소/GS25/세븐일레븐/CU/이마트24/올리브영: 상품명, 가격, 재고 여부, 매장명/거리 - 메가박스/롯데시네마/CGV: 영화명, 극장명, 상영시간, 잔여좌석/총좌석, 거리 - 이미지 렌더링 강제 규칙: - 규칙 1: MCP/REST 응답에 `imageUrl`이 있으면 반드시 마크다운 이미지로 렌더링 @@ -154,8 +150,6 @@ mcp.aka.page 작업 기능 호출 중 에러가 나면 아래 문구로 안내 - CU 재고 조회 시 `keyword`는 필수이며 필요 시 `storeKeyword`를 함께 사용 - 이마트24 재고 조회는 `pluCd` + `storeKeyword` 또는 `pluCd` + `bizNoArr` 조합을 사용 - 이마트24 매장 조회 후 `storeCode`를 모아 `bizNoArr`로 전달할 수 있고, 대화형 흐름에서는 기존 매장 키워드를 다시 넣어도 됨 -- 롯데마트 상품 검색 시 `keyword`는 필수이며 `storeCode` 또는 `storeName` 중 하나가 필요 -- 롯데마트 주변 매장 조회는 좌표가 없으면 `keyword` 또는 주소 지오코딩 결과에 따라 거리 계산이 보강될 수 있음 - 올리브영에서 "어떤 거 있나요", "종류 보여줘", "상품 목록" 요청은 먼저 `oliveyoung_search_products` 또는 `/api/oliveyoung/products` 사용 - `oliveyoung_search_products` 또는 `/api/oliveyoung/products` 응답의 `products[].imageUrl`이 있으면 각 상품 이미지를 반드시 마크다운으로 전부 렌더링 - 올리브영 이미지 URL의 query string(`?l=ko` 등)은 마크다운 렌더링 시 절대 삭제하지 않으며, 전체 URL을 그대로 사용 diff --git a/docs/free-location-search.md b/docs/free-location-search.md new file mode 100644 index 00000000..2e8d0ec3 --- /dev/null +++ b/docs/free-location-search.md @@ -0,0 +1,31 @@ +# 무료 위치 검색 + +주소·역명·상호명은 카카오맵 주소/키워드 검색으로 좌표를 구합니다. 역명·상호명 검색은 네이버 지역 검색으로 보완합니다. REST, MCP, CLI의 기존 위치 입력을 유지하며 Google Geocoding과 Zyte는 호출하지 않습니다. + +## 설정 + +로컬 환경 변수 또는 Cloudflare Worker Secret에 다음 값을 설정하세요. + +```text +KAKAO_REST_API_KEY +NAVER_CLIENT_ID +NAVER_CLIENT_SECRET +``` + +카카오 키는 주소와 장소명 변환에 사용합니다. 네이버 두 값은 장소명 검색 보완에 사용하며, 네이버 지역 검색을 주소 변환기로 사용하지 않습니다. 기존 음식점·카페 검색도 네이버 설정을 사용합니다. + +2026년 10월 5일 기준 카카오맵 무료 쿼터는 계정에서 처음 활성화한 앱에만 제공됩니다. 카카오 콘솔에서 해당 앱이 무료 대상인지 확인하세요. 무료 대상 앱은 주소 검색과 키워드 검색에 각각 일간 100,000회 쿼터를 받습니다. 무료 대상이 아니라면 유료 API를 자동으로 활성화하지 마세요. 실제 대상 여부와 한도는 [카카오맵 안내](https://developers.kakao.com/docs/ko/kakaomap/common)와 [쿼터 문서](https://developers.kakao.com/docs/ko/getting-started/quota)를 확인하세요. + +공개 서버의 다이소MCP 앱은 무료 대상임을 확인한 뒤 활성화했습니다. 별도로 배포하는 경우 자신의 앱 설정을 확인해야 합니다. + +## 조회 동작 + +- 도로명·지번주소는 카카오 주소 검색을 사용합니다. +- 역명·상호명은 카카오 키워드 검색을 사용하고, 네이버 지역 검색으로 보완합니다. +- 사용자가 위도·경도 또는 오피넷 KATEC 좌표를 입력하면 사용자 위치 변환을 건너뜁니다. +- 검색 결과의 좌표·지역·이름을 검증합니다. 극장·주유소의 좌표가 필요한 주변 검색에서 위치 변환에 실패하면 다른 지역의 기본 좌표로 대체하지 않고 오류를 반환합니다. 편의점의 원본 키워드 검색은 해당 서비스가 제공하는 검색 방식으로 이어갈 수 있습니다. +- CGV 주변 검색은 공식 극장 목록과 가까운 장소 후보를 대조한 뒤 거리순으로 정렬합니다. + +위치 변환을 복구해도 원본 서비스의 인증 오류나 재고 조회 차단까지 해결되는 것은 아닙니다. 서비스별 헬스 체크를 함께 확인하세요. + +롯데마트 지원은 중단했습니다. MCP 도구와 기본 도움말에서는 제외하며, 기존 REST 경로는 외부 요청 없이 `410 SERVICE_RETIRED`, 기존 CLI 명령은 지원 중단 오류를 반환합니다. 과거 호출 제한 통계와 분석 문서는 보존합니다. diff --git a/docs/superpowers/plans/2026-10-05-free-location-retirement.md b/docs/superpowers/plans/2026-10-05-free-location-retirement.md new file mode 100644 index 00000000..706037b9 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-free-location-retirement.md @@ -0,0 +1,41 @@ +# 무료 위치 검색 복구와 롯데마트 지원 중단 구현 계획 + +승인된 설계: personal-agent/projects/daiso-mcp/PROJECT.md의 2026 10 04 제안. Google과 Zyte는 다시 활성화하지 않습니다. 유료 API를 켜지 않습니다. 카카오 무료 자격과 활성화는 운영 검증 단계이며, 키 원문은 기록하지 않습니다. + +## 1. 공통 무료 위치 변환 + +- [x] tests/freeGeocode.test.ts에서 주소·장소명, Kakao x/y 숫자변환, 이름/지역 일치, Naver 보완, 외부 HTTP 오류·timeout·429·빈/잘못된 결과, 키없음, 직접좌표 호출0회, Google/Zyte 호출0회를 검증합니다. 먼저 실패를 확인합니다. +- [x] src/utils/geocode.ts에 단일 목적 변환기를 구현합니다. 기존 fetchJson과 Naver normalize 패턴을 활용합니다. 주소는 Kakao address, 이름은 keyword. Naver는 이름 보완만 사용합니다. Google 키는 사용하지 않습니다. 외부 결과를 무조건 첫값으로 채택하지 않습니다. 데이터 영구DB나 별도 인프라를 추가하지 않습니다. +- [x] AppBindings와 Worker registry에서 KAKAO_REST_API_KEY·NAVER_CLIENT_ID/SECRET를 각 서비스로 주입하고 Worker의 process 접근을 안전하게 처리합니다. workflow 변경은 하지 않습니다. + +## 2. 기존 서비스 연결과 거리검색 + +- [x] 기존 CU·GS25·오피넷·롯데시네마 위치 테스트를 무료변환 응답으로 바꾸고 실패를 먼저 확인합니다. 원래 입력/응답 필드를 유지합니다. +- [x] 메가박스 테스트에 부산 장소명, MCP Worker process 부재, 명시 좌표의 변환호출0회와 서울 기본값 오반환 금지를 추가합니다. 지역 선택은 원본 API 계약에 맞춥니다. 기존 무위치 기본서울 동작은 유지합니다. +- [x] CGV 테스트에 부산좌표 입력에서 전국 앞12개 서울후보를 고르지 않는 회귀를 추가합니다. 지역 후보/극장좌표 확보 후 거리정렬합니다. null좌표·거리 결과를 근처검색 성공으로 내보내지 않습니다. +- [x] src/services/{gs25,cu,cgv,megabox,lottecinema,opinet}와 각 API 핸들러·MCP도구의 Google 변환을 대체합니다. 필요시 작은 위치 옵션 타입을 추가합니다. 구글 설정은 deprecated 입력 호환성으로만 남길 수 있고 실제 호출은 금지합니다. + +## 3. 롯데마트 지원 중단 + +- [x] tests/app/app-api-lottemart.test.ts(실제 기존 파일명 확인)와 registry/CLI/OpenAPI/health 테스트에서 retire 상태를 먼저 검증합니다. stores/products/debug는 HTTP410 + SERVICE_RETIRED, 외부 fetch0회입니다. +- [x] src/index.ts에서 롯데마트 MCP 등록을 제거하고 routes/lottemartRoutes.ts의 기존 URL을 tombstone으로 유지합니다. CLI 기존 명령은 명확한 지원중단 오류를 주며 기본 help에서 제외합니다. 소스 연구 기록은 삭제하지 않습니다. +- [x] README·공개 OpenAPI·프롬프트·health 목록에서 지원 표기를 제거합니다. 기존 테스트도 현재 계약으로 수정합니다. OpenAPI 생성 산출물은 npm run build로 갱신합니다. + +## 4. 통합 검증과 전달 + +- [x] 각 대상 테스트, npm run check, npm run test:coverage(100%), npm run build와 npm audit를 실행하고 출력/종료코드를 확인합니다. src450줄 규칙을 지킵니다. 이전 braces high6은 별도 이슈로 구분합니다. +- [x] spec 리뷰 다음 품질 리뷰를 수행하고 지적사항을 해결합니다. 카카오 무료 앱 확인·유료설정 미활성 조건이 충족되면 실제 주소2건·역명3건과 지점거리·오피넷을 검증합니다. +- [x] draft PR을 만들고 attach_artifact로 연결했습니다. 현재 사용자 승인은 구현까지이며 main 머지·운영 배포는 별도 승인 전 수행하지 않습니다. 운영 설정 확인이 불가하면 구현은 계속 진행하고 정확한 남은 단계를 보고합니다. + +## 구현 및 검증 증거 + +- CU 주소 검색의 기존 null 반환, 메가박스 부산역의 서울 대체, CGV 부산 좌표의 전국 앞 12개 후보, 롯데시네마 MCP 스키마의 서울 좌표 주입, 장소·주소 숫자 및 잘못된 응답형의 회귀를 실패로 확인한 뒤 수정했습니다. +- 공통 변환기는 주소 검색과 장소 검색을 구분하고 주소 숫자·지번 부번·지역·역 이름을 검증합니다. 잘못된 첫 결과를 건너뛰고 유효한 후속 결과를 선택합니다. +- 지역 후보는 가까운 Kakao CGV 장소를 공식 극장 목록에 연결하며 영구 좌표 데이터베이스는 추가하지 않습니다. 위치 해석 실패 시 영화관과 오피넷은 명확한 오류를 반환합니다. +- 카카오 앱 무료 일간 쿼터 활성화를 콘솔에서 확인했습니다. 실제 주소 2건·역명 3건, 부산 REST/MCP 영화관 조회와 명시 좌표 우선 사용, Google/Zyte 호출 0회를 검증했습니다. +- 로컬 OPINET 키는 없어 Kakao로 해석한 좌표를 기존 공개 오피넷 API에 전달해 HTTP 200과 주유소 9개를 확인했습니다. 새로운 키워드 경로를 운영에서 검증했다는 의미는 아닙니다. +- 운영 Worker에는 KAKAO_REST_API_KEY 설정이 필요합니다. 운영 키·배포·워크플로는 변경하지 않았습니다. +- 전체 테스트 207개 파일·2254개 테스트 통과, Statements/Branches/Functions/Lines 모두 100% 커버리지, 빌드 및 OpenAPI 갱신 완료. +- 승인 설계 및 품질 리뷰 지적 사항을 해결한 후 재검토를 통과했습니다. draft PR [#207](https://github.com/hmmhmmhm/daiso-mcp/pull/207)을 생성하고 작업에 연결했습니다. + +- 후속 점검에서 CLI smoke의 종료 서비스 시나리오와 README 자동 안내를 정리했습니다. 도로 번호와 건물 번호가 뒤바뀐 주소를 거부하는 회귀도 추가하고 전체 검사를 다시 통과했습니다. diff --git a/docs/zyte-free-access.md b/docs/zyte-free-access.md index ce8db6e1..2406ec77 100644 --- a/docs/zyte-free-access.md +++ b/docs/zyte-free-access.md @@ -12,18 +12,20 @@ 2026-09-15 검증 결과입니다. -| 대상 | 확인 결과 | -|---|---| -| 다이소·GS25 상품/매장·롯데마트·메가박스·롯데시네마 | 기존 직접 조회 경로를 유지합니다. | -| 세븐일레븐 | 로컬 공식 HTTP 상품 검색은 성공했습니다. Cloudflare에서의 차단 시 Zyte로 넘어가지 않습니다. | -| CU·디트릭스 | 로컬 성공/Cloudflare 실패 차이가 남아 있습니다. CU 도메인 변경·디트릭스 포트 변경만으로 해결되지 않았습니다. | -| 올리브영 | 일반 HTTP 요청은 403, 새 headed Chromium의 페이지 내 요청은 매장·상품·상품 식별자·재고 4개 API 모두 200 SUCCESS였습니다. headless Chromium은 차단됐습니다. | -| CGV | 로컬 서명 HTTP 요청도 403입니다. 유료 fallback은 중지합니다. | +| 대상 | 확인 결과 | +| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 다이소·GS25 상품/매장·메가박스·롯데시네마 | 기존 직접 조회 경로를 유지합니다. | +| 세븐일레븐 | 로컬 공식 HTTP 상품 검색은 성공했습니다. Cloudflare에서의 차단 시 Zyte로 넘어가지 않습니다. | +| CU·디트릭스 | 로컬 성공/Cloudflare 실패 차이가 남아 있습니다. CU 도메인 변경·디트릭스 포트 변경만으로 해결되지 않았습니다. | +| 올리브영 | 일반 HTTP 요청은 403, 새 headed Chromium의 페이지 내 요청은 매장·상품·상품 식별자·재고 4개 API 모두 200 SUCCESS였습니다. headless Chromium은 차단됐습니다. | +| CGV | 로컬 서명 HTTP 요청도 403입니다. 유료 fallback은 중지합니다. | 올리브영은 사용자 프로필·로그인 세션을 복사하지 않은 전용 브라우저로 검증했습니다. Mac 기반 중계는 창이 표시되는 브라우저와 로그인된 GUI 세션, 안정적인 전원·네트워크가 필요합니다. 다른 서버나 headless에서 동작한다고 가정하지 않습니다. 중계 실행·인증·바인딩 설정과 로컬 실측은 [올리브영 중계 가이드](oliveyoung-free-relay.md)에 있습니다. 현재 운영 연결은 상시 실행 장비 확인 후 별도로 적용해야 합니다. +롯데마트 지원은 중단했습니다. 기존 REST 경로는 외부 요청 없이 `410 SERVICE_RETIRED`를 반환합니다. 위치 변환은 [무료 위치 검색 가이드](free-location-search.md)를 확인하세요. + ## 반복 조회 감소 세븐일레븐의 공개 상품·카탈로그·인기 검색어는 5분, 매장 검색은 30분 동안 서비스 계층에서 재사용합니다. REST와 MCP가 이 계층을 함께 쓰며 같은 키의 동시 조회는 합칩니다. 응답은 호출자마다 복제하고 성공 결과와 진행 중 조회는 각각 최대 128개 키만 보관합니다. 실패 결과는 캐시하지 않습니다. diff --git a/openapi.json b/openapi.json index 9e1e96ff..cad38f39 100644 --- a/openapi.json +++ b/openapi.json @@ -91,8 +91,6 @@ "emart24FindStores", "emart24SearchProducts", "emart24CheckInventory", - "lottemartFindStores", - "lottemartSearchProducts", "gs25FindStores", "gs25SearchProducts", "gs25CheckInventory", @@ -300,7 +298,7 @@ "name": "area", "in": "query", "required": false, - "description": "롯데마트 지역", + "description": "지역", "schema": { "type": "string" } @@ -336,7 +334,7 @@ "name": "brandVariant", "in": "query", "required": false, - "description": "롯데마트 브랜드 변형", + "description": "브랜드 변형", "schema": { "type": "string" } @@ -383,7 +381,7 @@ "name": "pageLimit", "in": "query", "required": false, - "description": "롯데마트 추가 페이지 수", + "description": "추가 페이지 수", "schema": { "type": "integer" } @@ -446,7 +444,7 @@ "name": "storeName", "in": "query", "required": false, - "description": "매장명. 롯데마트 상품 조회에서 storeCode가 없을 때 사용하며, 모르면 먼저 lottemartFindNearbyStores 또는 /api/lottemart/stores로 확인하세요.", + "description": "매장명", "schema": { "type": "string" } @@ -2084,183 +2082,6 @@ } } }, - "LotteMartStore": { - "type": "object", - "properties": { - "area": { - "type": "string", - "example": "서울" - }, - "storeCode": { - "type": "string", - "example": "2301" - }, - "storeName": { - "type": "string", - "example": "강변점" - }, - "brandVariant": { - "type": "string", - "example": "lottemart" - }, - "address": { - "type": "string", - "example": "서울 광진구 광나루로 56길 85 테크노마트 B2" - }, - "phone": { - "type": "string", - "example": "02-3424-2502" - }, - "openTime": { - "type": "string", - "example": "10:00~23:00" - }, - "closedDays": { - "type": "string", - "example": "03/08(일), 03/22(일)" - }, - "parkingType": { - "type": "string", - "example": "유료" - }, - "parkingDetails": { - "type": "string", - "example": "최초 무료주차 : 1시간" - }, - "detailUrl": { - "type": "string", - "example": "/mobiledowa/market/detail_shop.asp?werks=2301" - }, - "latitude": { - "type": "number", - "format": "float", - "example": 37.5354 - }, - "longitude": { - "type": "number", - "format": "float", - "example": 127.0958 - }, - "distanceM": { - "type": "number", - "nullable": true, - "example": 530 - } - } - }, - "LotteMartProduct": { - "type": "object", - "properties": { - "area": { - "type": "string", - "example": "서울" - }, - "storeCode": { - "type": "string", - "example": "2301" - }, - "storeName": { - "type": "string", - "example": "강변점" - }, - "keyword": { - "type": "string", - "example": "콜라" - }, - "productName": { - "type": "string", - "example": "코카콜라" - }, - "barcode": { - "type": "string", - "example": "8801094011307" - }, - "spec": { - "type": "string", - "example": "1.2L" - }, - "manufacturer": { - "type": "string", - "example": "코카콜라음료 주식회사" - }, - "price": { - "type": "integer", - "example": 2980 - }, - "stockQuantity": { - "type": "integer", - "example": 20 - } - } - }, - "LotteMartStoreSearchResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "type": "object", - "properties": { - "stores": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LotteMartStore" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - }, - "pageSize": { - "type": "integer" - } - } - } - } - }, - "LotteMartProductSearchResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "type": "object", - "properties": { - "products": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LotteMartProduct" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - }, - "pageSize": { - "type": "integer" - } - } - } - } - }, "Gs25Store": { "type": "object", "properties": { diff --git a/openapi.yaml b/openapi.yaml index 9a81f249..cdc5df67 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -77,8 +77,6 @@ paths: - emart24FindStores - emart24SearchProducts - emart24CheckInventory - - lottemartFindStores - - lottemartSearchProducts - gs25FindStores - gs25SearchProducts - gs25CheckInventory @@ -222,7 +220,7 @@ paths: - name: area in: query required: false - description: 롯데마트 지역 + description: 지역 schema: type: string - name: area1 @@ -246,7 +244,7 @@ paths: - name: brandVariant in: query required: false - description: 롯데마트 브랜드 변형 + description: 브랜드 변형 schema: type: string - name: lat @@ -278,7 +276,7 @@ paths: - name: pageLimit in: query required: false - description: 롯데마트 추가 페이지 수 + description: 추가 페이지 수 schema: type: integer - name: pageSize @@ -320,7 +318,7 @@ paths: - name: storeName in: query required: false - description: 매장명. 롯데마트 상품 조회에서 storeCode가 없을 때 사용하며, 모르면 먼저 lottemartFindNearbyStores 또는 /api/lottemart/stores로 확인하세요. + description: 매장명 schema: type: string - name: storeKeyword @@ -1460,131 +1458,6 @@ components: type: number nullable: true example: 120 - LotteMartStore: - type: object - properties: - area: - type: string - example: 서울 - storeCode: - type: string - example: 2301 - storeName: - type: string - example: 강변점 - brandVariant: - type: string - example: lottemart - address: - type: string - example: 서울 광진구 광나루로 56길 85 테크노마트 B2 - phone: - type: string - example: 02-3424-2502 - openTime: - type: string - example: "10:00~23:00" - closedDays: - type: string - example: 03/08(일), 03/22(일) - parkingType: - type: string - example: 유료 - parkingDetails: - type: string - example: "최초 무료주차 : 1시간" - detailUrl: - type: string - example: /mobiledowa/market/detail_shop.asp?werks=2301 - latitude: - type: number - format: float - example: 37.5354 - longitude: - type: number - format: float - example: 127.0958 - distanceM: - type: number - nullable: true - example: 530 - LotteMartProduct: - type: object - properties: - area: - type: string - example: 서울 - storeCode: - type: string - example: 2301 - storeName: - type: string - example: 강변점 - keyword: - type: string - example: 콜라 - productName: - type: string - example: 코카콜라 - barcode: - type: string - example: 8801094011307 - spec: - type: string - example: 1.2L - manufacturer: - type: string - example: 코카콜라음료 주식회사 - price: - type: integer - example: 2980 - stockQuantity: - type: integer - example: 20 - LotteMartStoreSearchResponse: - type: object - properties: - success: - type: boolean - example: true - data: - type: object - properties: - stores: - type: array - items: - $ref: "#/components/schemas/LotteMartStore" - meta: - type: object - properties: - total: - type: integer - page: - type: integer - pageSize: - type: integer - LotteMartProductSearchResponse: - type: object - properties: - success: - type: boolean - example: true - data: - type: object - properties: - products: - type: array - items: - $ref: "#/components/schemas/LotteMartProduct" - meta: - type: object - properties: - total: - type: integer - page: - type: integer - pageSize: - type: integer Gs25Store: type: object properties: diff --git a/package.json b/package.json index 77efe45f..319fe610 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,6 @@ "cu", "seveneleven", "emart24", - "lottemart", "cgv", "megabox", "lottecinema", diff --git a/scripts/ops/cli-smoke.ts b/scripts/ops/cli-smoke.ts index 7b2e55a7..130581b1 100644 --- a/scripts/ops/cli-smoke.ts +++ b/scripts/ops/cli-smoke.ts @@ -25,7 +25,6 @@ type SmokeService = | 'gs25' | 'seveneleven' | 'emart24' - | 'lottemart' | 'oliveyoung' | 'megabox' | 'lottecinema' @@ -82,7 +81,10 @@ function validateApiEnvelope( return typeof check === 'function' ? check(payload.data) : null; } -function expectDataField(key: string, expected: string): (data: Record) => string | null { +function expectDataField( + key: string, + expected: string, +): (data: Record) => string | null { return (data) => { if (data[key] !== expected) { return `data.${key}가 ${expected}가 아닙니다`; @@ -93,7 +95,8 @@ function expectDataField(key: string, expected: string): (data: Record (stderr.includes(expected) ? null : `stderr에 "${expected}"가 없습니다`); + return (_stdout, stderr) => + stderr.includes(expected) ? null : `stderr에 "${expected}"가 없습니다`; } export const CLI_SMOKE_COMMANDS: CliSmokeCommand[] = [ @@ -156,12 +159,6 @@ export const CLI_SMOKE_COMMANDS: CliSmokeCommand[] = [ degradedFailurePatterns: EMART24_UPSTREAM_403_PATTERNS, validate: (stdout) => validateApiEnvelope(stdout, expectDataField('keyword', '커피')), }, - { - service: 'lottemart', - scenario: '롯데마트 매장 포함 상품 검색', - args: ['lottemart-products', '콜라', '--storeCode', '2301', '--area', '서울', '--pageLimit', '1', '--json'], - validate: (stdout) => validateApiEnvelope(stdout, expectDataField('keyword', '콜라')), - }, { service: 'oliveyoung', scenario: '올리브영 get 상품 검색', @@ -193,13 +190,18 @@ export const CLI_SMOKE_COMMANDS: CliSmokeCommand[] = [ validate: (stdout) => validateApiEnvelope(stdout, expectDataField('provider', 'opinet')), }, { - service: 'dtryx', scenario: '디트릭스 상영작 조회', + service: 'dtryx', + scenario: '디트릭스 상영작 조회', args: ['get', '/api/dtryx/movies', '--cinemaCode', '000067', '--json'], - validate: (stdout) => validateApiEnvelope(stdout, (data) => { - const first = Array.isArray(data.movies) ? data.movies[0] : undefined; - return isRecord(first) && typeof first.movieCode === 'string' && typeof first.movieName === 'string' - ? null : '디트릭스 상영작의 movieCode 또는 movieName이 없습니다'; - }), + validate: (stdout) => + validateApiEnvelope(stdout, (data) => { + const first = Array.isArray(data.movies) ? data.movies[0] : undefined; + return isRecord(first) && + typeof first.movieCode === 'string' && + typeof first.movieName === 'string' + ? null + : '디트릭스 상영작의 movieCode 또는 movieName이 없습니다'; + }), }, ]; diff --git a/scripts/ops/workers-chart-helpers.ts b/scripts/ops/workers-chart-helpers.ts index f2e5f2de..218cf11c 100644 --- a/scripts/ops/workers-chart-helpers.ts +++ b/scripts/ops/workers-chart-helpers.ts @@ -306,7 +306,7 @@ export function buildReadmeSection({ '
', '', '> [!IMPORTANT]', - '> 최근 공개 서버 사용량이 크게 증가하여 2026년 7월 18일부터 올리브영·CGV·CU·GS25·롯데마트의 검색을 포함한 공개 GET API에 IP당 하루 합산 3,000회(KST 기준)의 호출 제한을 적용합니다. 한도를 초과하는 사용이 필요하다면 Daiso MCP는 오픈 소스이므로 이 저장소를 직접 배포해 이용해 주세요.', + '> 최근 공개 서버 사용량이 크게 증가하여 2026년 7월 18일부터 올리브영·CGV·CU·GS25의 검색을 포함한 공개 GET API에 IP당 하루 합산 3,000회(KST 기준)의 호출 제한을 적용합니다. 한도를 초과하는 사용이 필요하다면 Daiso MCP는 오픈 소스이므로 이 저장소를 직접 배포해 이용해 주세요.', '', '
', '', diff --git a/skills/daiso-cli/SKILL.md b/skills/daiso-cli/SKILL.md index ed220e24..b3fa8424 100644 --- a/skills/daiso-cli/SKILL.md +++ b/skills/daiso-cli/SKILL.md @@ -57,7 +57,6 @@ npx daiso seveneleven-products 삼각김밥 --size 10 --json npx daiso seveneleven-stores "안산 중앙역" --limit 10 --json npx daiso seveneleven-inventory 핫식스 --storeKeyword "안산 중앙역" --storeLimit 10 --json npx daiso emart24-products 커피 --pageSize 10 --json -npx daiso lottemart-products 콜라 --storeName 강변점 --area 서울 --json npx daiso cgv-movies --playDate --theaterCode --json npx daiso cgv-timetable --playDate --theaterCode --json ``` diff --git a/skills/daiso-cli/references/cli-command-map.md b/skills/daiso-cli/references/cli-command-map.md index d08acfcd..6eca351c 100644 --- a/skills/daiso-cli/references/cli-command-map.md +++ b/skills/daiso-cli/references/cli-command-map.md @@ -57,10 +57,8 @@ Use `--json` for structured output. In shell commands, quoted Korean strings are - Emart24 products: `npx daiso emart24-products 커피 --pageSize 20 --json` - Emart24 inventory: `npx daiso emart24-inventory 8800244010504 --bizNoArr 28339,05015 --json` -## Marts And Olive Young +## Olive Young -- Lotte Mart stores: `npx daiso lottemart-stores 잠실 --area 서울 --limit 10 --json` -- Lotte Mart products: `npx daiso lottemart-products 콜라 --storeName 강변점 --area 서울 --json` - Olive Young products: `npx daiso get /api/oliveyoung/products --keyword 선크림 --size 10 --json` - Olive Young stores: `npx daiso get /api/oliveyoung/stores --keyword 명동 --limit 10 --json` - Olive Young inventory: `npx daiso get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --json` diff --git a/src/api/actionsProxy.ts b/src/api/actionsProxy.ts index 6d95dce6..f393884c 100644 --- a/src/api/actionsProxy.ts +++ b/src/api/actionsProxy.ts @@ -59,16 +59,6 @@ export const ACTION_QUERY_DEFINITIONS: ActionQueryDefinition[] = [ summary: '이마트24 재고 조회', targetPath: '/api/emart24/inventory', }, - { - action: 'lottemartFindStores', - summary: '롯데마트 매장 검색', - targetPath: '/api/lottemart/stores', - }, - { - action: 'lottemartSearchProducts', - summary: '롯데마트 상품 검색', - targetPath: '/api/lottemart/products', - }, { action: 'gs25FindStores', summary: 'GS25 매장 검색', targetPath: '/api/gs25/stores' }, { action: 'gs25SearchProducts', summary: 'GS25 상품 검색', targetPath: '/api/gs25/products' }, { action: 'gs25CheckInventory', summary: 'GS25 재고 조회', targetPath: '/api/gs25/inventory' }, diff --git a/src/api/cgvHandlers.ts b/src/api/cgvHandlers.ts index 1355ff37..f5d4bbf1 100644 --- a/src/api/cgvHandlers.ts +++ b/src/api/cgvHandlers.ts @@ -1,3 +1,4 @@ +import { geocodeBindings } from '../utils/geocode.js'; /** * CGV GET API 핸들러 */ @@ -50,7 +51,7 @@ export async function handleCgvFindTheaters(c: ApiContext) { { timeout: timeoutMs, zyteApiKey: c.env?.ZYTE_API_KEY, - googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY, + ...geocodeBindings(c.env), }, ); @@ -119,7 +120,7 @@ export async function handleCgvSearchMovies(c: ApiContext) { { timeout: timeoutMs, zyteApiKey: c.env?.ZYTE_API_KEY, - googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY, + ...geocodeBindings(c.env), }, ); resolvedTheater = resolved.theater; @@ -193,7 +194,7 @@ export async function handleCgvGetTimetable(c: ApiContext) { { timeout: timeoutMs, zyteApiKey: c.env?.ZYTE_API_KEY, - googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY, + ...geocodeBindings(c.env), }, ); resolvedTheater = resolved.theater; diff --git a/src/api/configStatus.ts b/src/api/configStatus.ts index 96cf09fb..4eaa2d00 100644 --- a/src/api/configStatus.ts +++ b/src/api/configStatus.ts @@ -18,7 +18,8 @@ export interface ConfigStatus { accessPairValid: boolean; }; dtryxRelay: ConfigStatus['oliveyoungRelay']; - googleMapsApiKey: ConfigStatusItem; + googleMapsApiKey: ConfigStatusItem & { enabled: false }; + kakaoRestApiKey: ConfigStatusItem; zyteApiKey: ConfigStatusItem & { enabled: false }; naverLocalSearch: ConfigStatusItem; opinetApiKey: ConfigStatusItem; @@ -80,7 +81,12 @@ export function buildConfigStatus(bindings?: AppBindings): ConfigStatus { }, googleMapsApiKey: { configured: isConfigured(bindings?.GOOGLE_MAPS_API_KEY), - usedBy: ['gs25', 'cu', 'lottemart', 'megabox', 'lottecinema', 'cgv'], + enabled: false, + usedBy: [], + }, + kakaoRestApiKey: { + configured: isConfigured(bindings?.KAKAO_REST_API_KEY), + usedBy: ['gs25', 'cu', 'opinet', 'megabox', 'lottecinema', 'cgv'], }, zyteApiKey: { configured: isConfigured(bindings?.ZYTE_API_KEY), @@ -90,7 +96,7 @@ export function buildConfigStatus(bindings?: AppBindings): ConfigStatus { naverLocalSearch: { configured: isConfigured(bindings?.NAVER_CLIENT_ID) && isConfigured(bindings?.NAVER_CLIENT_SECRET), - usedBy: ['places'], + usedBy: ['places', 'gs25', 'cu', 'opinet', 'megabox', 'lottecinema', 'cgv'], }, opinetApiKey: { configured: isConfigured(bindings?.OPINET_API_KEY), diff --git a/src/api/gs25Handlers.ts b/src/api/gs25Handlers.ts index 4110d267..6ba0eb10 100644 --- a/src/api/gs25Handlers.ts +++ b/src/api/gs25Handlers.ts @@ -1,3 +1,4 @@ +import { geocodeBindings } from '../utils/geocode.js'; /** * GS25 GET API 핸들러 */ @@ -45,7 +46,7 @@ export async function handleGs25FindStores(c: ApiContext) { ) { const geocoded = await geocodeGs25Address(keyword, { timeout: 15000, - googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY, + ...geocodeBindings(c.env), }); if (geocoded) { latitude = geocoded.latitude; @@ -223,7 +224,7 @@ export async function handleGs25CheckInventory(c: ApiContext) { ) { const directGeocoded = await geocodeGs25Address(storeKeyword, { timeout: 15000, - googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY, + ...geocodeBindings(c.env), }); if (directGeocoded) { latitude = directGeocoded.latitude; @@ -249,7 +250,7 @@ export async function handleGs25CheckInventory(c: ApiContext) { if (firstAddress.length > 0) { const geocoded = await geocodeGs25Address(firstAddress, { timeout: 15000, - googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY, + ...geocodeBindings(c.env), }); if (geocoded) { latitude = geocoded.latitude; diff --git a/src/api/handlers.ts b/src/api/handlers.ts index 74e94648..55017ec9 100644 --- a/src/api/handlers.ts +++ b/src/api/handlers.ts @@ -1,3 +1,4 @@ +import { geocodeBindings } from '../utils/geocode.js'; /** * GET API 핸들러 * @@ -342,7 +343,7 @@ export async function handleCuCheckInventory(c: ApiContext) { if (firstAddress.length > 0) { const geocoded = await geocodeCuAddress(firstAddress, { timeout: 15000, - googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY, + ...geocodeBindings(c.env), }); if (geocoded) { const hasStockSeed = !!firstStockItem?.itemCode; diff --git a/src/api/healthCheckDefinitions.ts b/src/api/healthCheckDefinitions.ts index b557bad7..547e9709 100644 --- a/src/api/healthCheckDefinitions.ts +++ b/src/api/healthCheckDefinitions.ts @@ -124,15 +124,6 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [ degradedFailurePatterns: SEVENELEVEN_UPSTREAM_403_PATTERNS, allowEmpty: true, }, - { - id: 'lottemart.products', - service: 'lottemart', - target: 'products', - mode: 'quick', - path: '/api/lottemart/products?keyword=%EC%BD%9C%EB%9D%BC&storeCode=2301&area=%EC%84%9C%EC%9A%B8&pageLimit=1&source=zetta', - collectionKey: 'products', - requiredFields: ['productCode', 'name', 'productName'], - }, { id: 'oliveyoung.products', service: 'oliveyoung', diff --git a/src/api/healthChecks.ts b/src/api/healthChecks.ts index b18f5c8e..41f23905 100644 --- a/src/api/healthChecks.ts +++ b/src/api/healthChecks.ts @@ -176,7 +176,6 @@ const CLI_CONTRACT_PATHS = [ '/api/gs25/stores?keyword=%EA%B0%95%EB%82%A8&limit=1', '/api/seveneleven/products?query=%EC%BB%A4%ED%94%BC&size=1', '/api/emart24/products?keyword=%EC%BB%A4%ED%94%BC&pageSize=1', - '/api/lottemart/products?keyword=%EC%BD%9C%EB%9D%BC&storeCode=2301&area=%EC%84%9C%EC%9A%B8&pageLimit=1', '/api/oliveyoung/products?keyword=%EC%84%A0%ED%81%AC%EB%A6%BC&size=1', '/api/megabox/theaters?keyword=%EA%B0%95%EB%82%A8&limit=1', '/api/lottecinema/theaters?keyword=%EC%9E%A0%EC%8B%A4&limit=1', diff --git a/src/api/lottecinemaHandlers.ts b/src/api/lottecinemaHandlers.ts index bd89834d..d01d9196 100644 --- a/src/api/lottecinemaHandlers.ts +++ b/src/api/lottecinemaHandlers.ts @@ -1,3 +1,4 @@ +import { geocodeBindings } from '../utils/geocode.js'; /** * 롯데시네마 GET API 핸들러 */ @@ -42,7 +43,7 @@ export async function handleLotteCinemaFindNearbyTheaters(c: ApiContext) { }, { timeout: timeoutMs, - googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY, + ...geocodeBindings(c.env), }, ); @@ -76,7 +77,7 @@ export async function handleLotteCinemaListNowShowing(c: ApiContext) { }, { timeout: timeoutMs, - googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY, + ...geocodeBindings(c.env), }, ); resolvedTheater = resolved.theater; @@ -142,7 +143,7 @@ export async function handleLotteCinemaGetRemainingSeats(c: ApiContext) { }, { timeout: timeoutMs, - googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY, + ...geocodeBindings(c.env), }, ); resolvedTheater = resolved.theater; diff --git a/src/api/lottemartHandlers.ts b/src/api/lottemartHandlers.ts index a89a5058..c7e222d7 100644 --- a/src/api/lottemartHandlers.ts +++ b/src/api/lottemartHandlers.ts @@ -1,174 +1,7 @@ -/** - * 롯데마트 GET API 핸들러 - */ - -import { type ApiContext, errorResponse, successResponse } from './response.js'; -import { fetchLotteMartStores, searchLotteMartProducts } from '../services/lottemart/client.js'; -import { DEFAULT_LOTTEMART_TIMEOUT_MS } from '../services/lottemart/config.js'; -import type { LotteMartProductSource } from '../services/lottemart/clientTypes.js'; -import { probeLotteMartUpstream, type LotteMartDebugTarget } from '../services/lottemart/debug.js'; - -function parseLotteMartProductSource(value: string | undefined): LotteMartProductSource | null { - if (!value || value === 'auto') { - return 'auto'; - } - if (value === 'legacy' || value === 'zetta') { - return value; - } - return null; -} - -/** - * 롯데마트 매장 검색 API 핸들러 - * GET /api/lottemart/stores?keyword={키워드}&lat={위도}&lng={경도} - */ +/** 롯데마트 지원 종료 응답. 업스트림에는 요청하지 않습니다. */ +import { type ApiContext, errorResponse } from './response.js'; export async function handleLotteMartFindStores(c: ApiContext) { - const keyword = c.req.query('keyword') || ''; - const area = c.req.query('area') || ''; - const brandVariant = c.req.query('brandVariant') || ''; - const limit = parseInt(c.req.query('limit') || '20', 10); - const rawLat = c.req.query('lat'); - const rawLng = c.req.query('lng'); - const parsedLat = rawLat ? parseFloat(rawLat) : undefined; - const parsedLng = rawLng ? parseFloat(rawLng) : undefined; - const latitude = typeof parsedLat === 'number' && Number.isFinite(parsedLat) ? parsedLat : undefined; - const longitude = typeof parsedLng === 'number' && Number.isFinite(parsedLng) ? parsedLng : undefined; - - try { - const result = await fetchLotteMartStores( - { - area, - keyword, - brandVariant, - latitude, - longitude, - limit, - }, - { - timeout: DEFAULT_LOTTEMART_TIMEOUT_MS, - googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY, - zyteApiKey: c.env?.ZYTE_API_KEY, - }, - ); - - return successResponse( - c, - { - area: area || null, - keyword, - brandVariant: brandVariant || null, - geocodeUsed: result.geocodeUsed, - location: result.location, - count: result.stores.length, - stores: result.stores, - }, - { - total: result.stores.length, - page: 1, - pageSize: limit, - }, - ); - } catch (error) { - const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; - return errorResponse(c, 'LOTTEMART_STORE_SEARCH_FAILED', message, 500); - } -} - -/** - * 롯데마트 상품 검색 API 핸들러 - * GET /api/lottemart/products?keyword={검색어}&storeCode={매장코드} - */ -export async function handleLotteMartSearchProducts(c: ApiContext) { - const keyword = c.req.query('keyword') || ''; - const area = c.req.query('area') || ''; - const storeCode = c.req.query('storeCode') || ''; - const storeName = c.req.query('storeName') || ''; - const pageLimit = parseInt(c.req.query('pageLimit') || '3', 10); - const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || String(DEFAULT_LOTTEMART_TIMEOUT_MS), 10); - const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : DEFAULT_LOTTEMART_TIMEOUT_MS; - const source = parseLotteMartProductSource(c.req.query('source')); - - if (keyword.trim().length === 0) { - return errorResponse(c, 'MISSING_QUERY', '검색어(keyword)를 입력해주세요.'); - } - - if (!source) { - return errorResponse(c, 'INVALID_LOTTEMART_SOURCE', 'source는 auto, legacy, zetta 중 하나여야 합니다.'); - } - - if (storeCode.trim().length === 0 && storeName.trim().length === 0) { - return errorResponse(c, 'MISSING_STORE', 'storeCode 또는 storeName 중 하나를 입력해주세요.'); - } - - try { - const result = await searchLotteMartProducts( - { - area, - storeCode, - storeName, - keyword, - pageLimit, - source, - }, - { - timeout: timeoutMs, - zyteApiKey: c.env?.ZYTE_API_KEY, - }, - ); - - return successResponse( - c, - { - area: result.area, - storeCode: result.storeCode, - storeName: result.storeName, - keyword, - pageLimit, - totalPages: result.totalPages, - products: result.products, - }, - { - total: result.totalCount, - page: 1, - pageSize: result.products.length, - }, - ); - } catch (error) { - const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; - return errorResponse(c, 'LOTTEMART_PRODUCT_SEARCH_FAILED', message, 500); - } -} - -/** - * 롯데마트 업스트림 단독 진단 API 핸들러 - * GET /api/lottemart/debug?target=market-options|stores|products|product-page - */ -export async function handleLotteMartDebug(c: ApiContext) { - const target = (c.req.query('target') || '') as LotteMartDebugTarget; - const allowedTargets: LotteMartDebugTarget[] = ['market-options', 'stores', 'products', 'product-page']; - if (!allowedTargets.includes(target)) { - return errorResponse( - c, - 'INVALID_TARGET', - 'target은 market-options, stores, products, product-page 중 하나여야 합니다.', - ); - } - - try { - const result = await probeLotteMartUpstream({ - target, - area: c.req.query('area') || undefined, - type: (c.req.query('type') || undefined) as '1' | '2' | undefined, - storeCode: c.req.query('storeCode') || undefined, - keyword: c.req.query('keyword') || undefined, - page: c.req.query('page') ? parseInt(c.req.query('page')!, 10) : undefined, - timeout: c.req.query('timeoutMs') ? parseInt(c.req.query('timeoutMs')!, 10) : DEFAULT_LOTTEMART_TIMEOUT_MS, - zyteApiKey: c.env?.ZYTE_API_KEY, - }); - - return successResponse(c, result); - } catch (error) { - const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; - return errorResponse(c, 'LOTTEMART_DEBUG_FAILED', message, 500); - } + return errorResponse(c, 'SERVICE_RETIRED', '롯데마트 서비스 지원이 종료되었습니다.', 410); } +export const handleLotteMartSearchProducts = handleLotteMartFindStores; +export const handleLotteMartDebug = handleLotteMartFindStores; diff --git a/src/api/megaboxHandlers.ts b/src/api/megaboxHandlers.ts index 8f7305e6..48acfee9 100644 --- a/src/api/megaboxHandlers.ts +++ b/src/api/megaboxHandlers.ts @@ -1,3 +1,4 @@ +import { geocodeBindings } from '../utils/geocode.js'; /** * 메가박스 GET API 핸들러 */ @@ -40,7 +41,7 @@ export async function handleMegaboxFindNearbyTheaters(c: ApiContext) { timeout: timeoutMs, }, { - googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY, + ...geocodeBindings(c.env), timeout: timeoutMs, }, ); @@ -85,7 +86,7 @@ export async function handleMegaboxListNowShowing(c: ApiContext) { timeout: timeoutMs, }, { - googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY, + ...geocodeBindings(c.env), timeout: timeoutMs, }, ); @@ -158,7 +159,7 @@ export async function handleMegaboxGetRemainingSeats(c: ApiContext) { timeout: timeoutMs, }, { - googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY, + ...geocodeBindings(c.env), timeout: timeoutMs, }, ); diff --git a/src/api/opinetHandlers.ts b/src/api/opinetHandlers.ts index 3affcfaa..0255a007 100644 --- a/src/api/opinetHandlers.ts +++ b/src/api/opinetHandlers.ts @@ -1,3 +1,4 @@ +import { geocodeBindings } from '../utils/geocode.js'; import { getErrorMessage } from '../core/errors.js'; import { fetchOpinetAveragePrices, @@ -83,7 +84,7 @@ export async function handleOpinetStationsAround(c: ApiContext) { }, { apiKey: c.env?.OPINET_API_KEY, - googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY, + ...geocodeBindings(c.env), timeoutMs: parseInteger(c.req.query('timeoutMs')), }, ); diff --git a/src/api/response.ts b/src/api/response.ts index 2062d59a..02c1b21e 100644 --- a/src/api/response.ts +++ b/src/api/response.ts @@ -18,6 +18,7 @@ export interface AppBindings { OY_ACCESS_CLIENT_SECRET?: string; GS25_API_KEY?: string; GOOGLE_MAPS_API_KEY?: string; + KAKAO_REST_API_KEY?: string; NAVER_CLIENT_ID?: string; NAVER_CLIENT_SECRET?: string; OPINET_API_KEY?: string; @@ -65,7 +66,7 @@ export function errorResponse( c: ApiContext, code: string, message: string, - status: 400 | 401 | 403 | 404 | 429 | 500 | 503 = 400, + status: 400 | 401 | 403 | 404 | 410 | 429 | 500 | 503 = 400, ) { return c.json>( { diff --git a/src/api/routes/cgvRoutes.ts b/src/api/routes/cgvRoutes.ts index 5723aef9..0e7487f2 100644 --- a/src/api/routes/cgvRoutes.ts +++ b/src/api/routes/cgvRoutes.ts @@ -14,7 +14,7 @@ export function registerCgvRoutes(app: Hono<{ Bindings: AppBindings }>): void { { ttlSeconds: 60 * 60 * 24, staleWhileRevalidateSeconds: 60 * 5, - keyPrefix: 'cgv-theaters-v1', + keyPrefix: 'cgv-theaters-v2', }, () => handleCgvFindTheaters(c), ), @@ -26,7 +26,7 @@ export function registerCgvRoutes(app: Hono<{ Bindings: AppBindings }>): void { { ttlSeconds: 60 * 10, staleWhileRevalidateSeconds: 60, - keyPrefix: 'cgv-movies-v1', + keyPrefix: 'cgv-movies-v2', }, () => handleCgvSearchMovies(c), ), @@ -38,7 +38,7 @@ export function registerCgvRoutes(app: Hono<{ Bindings: AppBindings }>): void { { ttlSeconds: 60 * 3, staleWhileRevalidateSeconds: 30, - keyPrefix: 'cgv-timetable-v1', + keyPrefix: 'cgv-timetable-v2', }, () => handleCgvGetTimetable(c), ), diff --git a/src/api/routes/cuRoutes.ts b/src/api/routes/cuRoutes.ts index b0f832c0..d0f029b5 100644 --- a/src/api/routes/cuRoutes.ts +++ b/src/api/routes/cuRoutes.ts @@ -14,7 +14,7 @@ export function registerCuRoutes(app: Hono<{ Bindings: AppBindings }>): void { { ttlSeconds: 60 * 60 * 24, staleWhileRevalidateSeconds: 60 * 5, - keyPrefix: 'cu-stores-v1', + keyPrefix: 'cu-stores-v2', }, () => handleCuFindStores(c), ), @@ -26,7 +26,7 @@ export function registerCuRoutes(app: Hono<{ Bindings: AppBindings }>): void { { ttlSeconds: 60 * 10, staleWhileRevalidateSeconds: 60, - keyPrefix: 'cu-inventory-v1', + keyPrefix: 'cu-inventory-v2', }, () => handleCuCheckInventory(c), ), diff --git a/src/api/routes/gs25Routes.ts b/src/api/routes/gs25Routes.ts index 249832c8..5915bd65 100644 --- a/src/api/routes/gs25Routes.ts +++ b/src/api/routes/gs25Routes.ts @@ -18,7 +18,7 @@ export function registerGs25Routes(app: Hono<{ Bindings: AppBindings }>): void { { ttlSeconds: 60 * 10, staleWhileRevalidateSeconds: 60, - keyPrefix: 'gs25-stores-v1', + keyPrefix: 'gs25-stores-v2', }, () => handleGs25FindStores(c), ), @@ -30,7 +30,7 @@ export function registerGs25Routes(app: Hono<{ Bindings: AppBindings }>): void { { ttlSeconds: 60 * 3, staleWhileRevalidateSeconds: 60, - keyPrefix: 'gs25-products-v1', + keyPrefix: 'gs25-products-v2', }, () => handleGs25SearchProducts(c), ), @@ -42,7 +42,7 @@ export function registerGs25Routes(app: Hono<{ Bindings: AppBindings }>): void { { ttlSeconds: 60 * 2, staleWhileRevalidateSeconds: 30, - keyPrefix: 'gs25-inventory-v1', + keyPrefix: 'gs25-inventory-v2', }, () => handleGs25CheckInventory(c), ), diff --git a/src/api/routes/lottecinemaRoutes.ts b/src/api/routes/lottecinemaRoutes.ts index 805087ef..a8cd377a 100644 --- a/src/api/routes/lottecinemaRoutes.ts +++ b/src/api/routes/lottecinemaRoutes.ts @@ -18,7 +18,7 @@ export function registerLotteCinemaRoutes(app: Hono<{ Bindings: AppBindings }>): { ttlSeconds: 60 * 60 * 24, staleWhileRevalidateSeconds: 60 * 5, - keyPrefix: 'lottecinema-theaters-v1', + keyPrefix: 'lottecinema-theaters-v2', }, () => handleLotteCinemaFindNearbyTheaters(c), ), @@ -30,7 +30,7 @@ export function registerLotteCinemaRoutes(app: Hono<{ Bindings: AppBindings }>): { ttlSeconds: 60 * 10, staleWhileRevalidateSeconds: 60, - keyPrefix: 'lottecinema-movies-v1', + keyPrefix: 'lottecinema-movies-v2', }, () => handleLotteCinemaListNowShowing(c), ), @@ -42,7 +42,7 @@ export function registerLotteCinemaRoutes(app: Hono<{ Bindings: AppBindings }>): { ttlSeconds: 60 * 3, staleWhileRevalidateSeconds: 30, - keyPrefix: 'lottecinema-seats-v1', + keyPrefix: 'lottecinema-seats-v2', }, () => handleLotteCinemaGetRemainingSeats(c), ), diff --git a/src/api/routes/lottemartRoutes.ts b/src/api/routes/lottemartRoutes.ts index 8f449f09..14f45ac1 100644 --- a/src/api/routes/lottemartRoutes.ts +++ b/src/api/routes/lottemartRoutes.ts @@ -1,36 +1,9 @@ -/** - * 롯데마트 GET API 라우트 등록 - */ - +/** 롯데마트 기존 주소에는 지원 종료 응답을 제공합니다. */ import type { Hono } from 'hono'; -import { withEdgeCache } from '../../utils/cache.js'; -import { handleLotteMartDebug, handleLotteMartFindStores, handleLotteMartSearchProducts } from '../lottemartHandlers.js'; +import { handleLotteMartFindStores } from '../lottemartHandlers.js'; import type { AppBindings } from '../response.js'; - export function registerLotteMartRoutes(app: Hono<{ Bindings: AppBindings }>): void { - app.get('/api/lottemart/stores', async (c) => - withEdgeCache( - c.req.url, - { - ttlSeconds: 60 * 30, - staleWhileRevalidateSeconds: 60 * 3, - keyPrefix: 'lottemart-stores-v1', - }, - () => handleLotteMartFindStores(c), - ), - ); - - app.get('/api/lottemart/products', async (c) => - withEdgeCache( - c.req.url, - { - ttlSeconds: 60 * 5, - staleWhileRevalidateSeconds: 60, - keyPrefix: 'lottemart-products-v1', - }, - () => handleLotteMartSearchProducts(c), - ), - ); - - app.get('/api/lottemart/debug', (c) => handleLotteMartDebug(c)); + for (const endpoint of ['stores', 'products', 'debug']) { + app.get(`/api/lottemart/${endpoint}`, handleLotteMartFindStores); + } } diff --git a/src/api/routes/megaboxRoutes.ts b/src/api/routes/megaboxRoutes.ts index 736976a9..53d689c2 100644 --- a/src/api/routes/megaboxRoutes.ts +++ b/src/api/routes/megaboxRoutes.ts @@ -18,7 +18,7 @@ export function registerMegaboxRoutes(app: Hono<{ Bindings: AppBindings }>): voi { ttlSeconds: 60 * 60 * 24, staleWhileRevalidateSeconds: 60 * 5, - keyPrefix: 'megabox-theaters-v1', + keyPrefix: 'megabox-theaters-v2', }, () => handleMegaboxFindNearbyTheaters(c), ), @@ -30,7 +30,7 @@ export function registerMegaboxRoutes(app: Hono<{ Bindings: AppBindings }>): voi { ttlSeconds: 60 * 10, staleWhileRevalidateSeconds: 60, - keyPrefix: 'megabox-movies-v1', + keyPrefix: 'megabox-movies-v2', }, () => handleMegaboxListNowShowing(c), ), @@ -42,7 +42,7 @@ export function registerMegaboxRoutes(app: Hono<{ Bindings: AppBindings }>): voi { ttlSeconds: 60 * 3, staleWhileRevalidateSeconds: 30, - keyPrefix: 'megabox-seats-v1', + keyPrefix: 'megabox-seats-v2', }, () => handleMegaboxGetRemainingSeats(c), ), diff --git a/src/api/routes/opinetRoutes.ts b/src/api/routes/opinetRoutes.ts index 23e4c4ee..592749a6 100644 --- a/src/api/routes/opinetRoutes.ts +++ b/src/api/routes/opinetRoutes.ts @@ -15,7 +15,7 @@ export function registerOpinetRoutes(app: Hono<{ Bindings: AppBindings }>): void { ttlSeconds: 60 * 60, staleWhileRevalidateSeconds: 60 * 30, - keyPrefix: 'opinet-average-v1', + keyPrefix: 'opinet-average-v2', }, () => handleOpinetAveragePrices(c), ), @@ -27,7 +27,7 @@ export function registerOpinetRoutes(app: Hono<{ Bindings: AppBindings }>): void { ttlSeconds: 60 * 30, staleWhileRevalidateSeconds: 60 * 10, - keyPrefix: 'opinet-lowest-v1', + keyPrefix: 'opinet-lowest-v2', }, () => handleOpinetLowestStations(c), ), @@ -39,7 +39,7 @@ export function registerOpinetRoutes(app: Hono<{ Bindings: AppBindings }>): void { ttlSeconds: 60 * 20, staleWhileRevalidateSeconds: 60 * 5, - keyPrefix: 'opinet-around-v1', + keyPrefix: 'opinet-around-v2', }, () => handleOpinetStationsAround(c), ), @@ -51,7 +51,7 @@ export function registerOpinetRoutes(app: Hono<{ Bindings: AppBindings }>): void { ttlSeconds: 60 * 60, staleWhileRevalidateSeconds: 60 * 10, - keyPrefix: 'opinet-station-v1', + keyPrefix: 'opinet-station-v2', }, () => handleOpinetStationDetail(c), ), diff --git a/src/cli.ts b/src/cli.ts index 8d003870..f93fcf66 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -28,8 +28,6 @@ import { handleEmart24Stores, handleEmart24Products, handleEmart24Inventory, - handleLotteMartStores, - handleLotteMartProducts, handleCompareProducts, handlePlaces, handleGs25Stores, @@ -165,6 +163,11 @@ export async function runCli(argv: string[], deps?: Partial): Promise): Promise void): void { writeOut(' npx daiso cgv-theaters 강남 --limit 5'); writeOut(' npx daiso cgv-timetable --playDate 20260307 --theaterCode 0056'); writeOut(' npx daiso lottecinema-theaters --keyword "안산 중앙역" --limit 5'); - writeOut(' npx daiso lottemart-stores 잠실 --area 서울 --limit 10'); - writeOut(' npx daiso lottemart-products 콜라 --storeName 강변점 --area 서울'); writeOut(' npx daiso emart24-products 두바이 --pageSize 20'); writeOut(' npx daiso gs25-products 오감자'); writeOut(' npx daiso gs25-inventory 오감자 --storeKeyword 강남'); @@ -55,7 +53,6 @@ export function printHelp(writeOut: (message: string) => void): void { writeOut(' 제품명을 먼저 검색해 productId를 확인: npx daiso products 수납박스'); writeOut(' productId로 재고 확인: npx daiso inventory --keyword 강남역'); writeOut(' 매장을 먼저 검색해 storeName 또는 storeCode를 확인: npx daiso stores 강남역'); - writeOut(' 롯데마트 매장 확인: npx daiso lottemart-stores 잠실 --area 서울'); writeOut(''); writeOut('상세 도움말:'); writeOut(' npx daiso help '); diff --git a/src/cliHelpDefinitions.ts b/src/cliHelpDefinitions.ts index b356ffb0..24695669 100644 --- a/src/cliHelpDefinitions.ts +++ b/src/cliHelpDefinitions.ts @@ -66,8 +66,6 @@ export const COMMAND_LIST: CommandName[] = [ 'megabox-theaters', 'megabox-movies', 'megabox-seats', - 'lottemart-stores', - 'lottemart-products', 'emart24-stores', 'emart24-products', 'emart24-inventory', @@ -304,23 +302,8 @@ export const COMMAND_DETAIL: Record = { '예시: daiso megabox-seats 강남 --playDate 20260310 --movieId 23816', '예시: daiso megabox-seats --playDate 20260310 --keyword "코엑스" --movieId 23816', ], - 'lottemart-stores': [ - '명령: lottemart-stores', - '설명: 롯데마트 계열 매장 검색 API를 호출합니다.', - '사용법: daiso lottemart-stores [keyword] [--area 값] [--brandVariant 값] [--lat 값] [--lng 값] [--limit N] [--json]', - '옵션: --keyword, --area, --brandVariant, --lat, --lng, --limit, --json', - '예시: daiso lottemart-stores 잠실 --area 서울', - '예시: daiso lottemart-stores --area 경기 --brandVariant lottemart --limit 10', - ], - 'lottemart-products': [ - '명령: lottemart-products', - '설명: 롯데마트 매장 기준 상품 검색 API를 호출합니다.', - '사용법: daiso lottemart-products [--storeCode 값 | --storeName 값] [--area 값] [--pageLimit N] [--json]', - '필수: , --storeCode 또는 --storeName', - '옵션: --storeCode, --storeName, --area, --pageLimit, --json', - '예시: daiso lottemart-products 콜라 --storeName 강변점 --area 서울', - '예시: daiso lottemart-products 우유 --storeCode 2301 --pageLimit 2', - ], + 'lottemart-stores': ['롯데마트 서비스 지원이 종료되었습니다.'], + 'lottemart-products': ['롯데마트 서비스 지원이 종료되었습니다.'], 'emart24-stores': [ '명령: emart24-stores', '설명: 이마트24 매장 검색 API를 호출합니다.', diff --git a/src/index.ts b/src/index.ts index fb6bcca4..27d115d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +import { handleLotteMartFindStores } from './api/lottemartHandlers.js'; +import { geocodeBindings } from './utils/geocode.js'; /** * 다중 서비스 MCP 서버 * @@ -20,7 +22,6 @@ import { createDtryxService } from './services/dtryx/index.js'; import { createMegaboxService } from './services/megabox/index.js'; import { createCgvService } from './services/cgv/index.js'; import { createLotteCinemaService } from './services/lottecinema/index.js'; -import { createLotteMartService } from './services/lottemart/index.js'; import { createCuService } from './services/cu/index.js'; import { createEmart24Service } from './services/emart24/index.js'; import { createGs25Service } from './services/gs25/index.js'; @@ -70,7 +71,7 @@ const createRegistry = (bindings?: AppBindings) => { createDaisoService, () => createGs25Service({ - googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY, + ...geocodeBindings(bindings), zyteApiKey: bindings?.ZYTE_API_KEY, apiKey: bindings?.GS25_API_KEY, }), @@ -89,29 +90,24 @@ const createRegistry = (bindings?: AppBindings) => { () => createOpinetService({ apiKey: bindings?.OPINET_API_KEY, - googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY, + ...geocodeBindings(bindings), }), () => createCuService({ zyteApiKey: bindings?.ZYTE_API_KEY, - googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY, + ...geocodeBindings(bindings), }), createEmart24Service, - () => - createLotteMartService({ - googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY, - zyteApiKey: bindings?.ZYTE_API_KEY, - }), - createMegaboxService, + () => createMegaboxService(geocodeBindings(bindings)), () => createDtryxService(dtryxTransportFromBindings(bindings)), () => createLotteCinemaService({ - googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY, + ...geocodeBindings(bindings), }), () => createCgvService({ zyteApiKey: bindings?.ZYTE_API_KEY, - googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY, + ...geocodeBindings(bindings), }), () => createOliveyoungService({ @@ -389,6 +385,7 @@ app.get('/privacy', (c) => { // GET API 엔드포인트 (MCP 미지원 에이전트용) app.get('/api/actions/query', async (c) => { + if (['lottemartFindStores', 'lottemartSearchProducts'].includes(c.req.query('action') || '')) return handleLotteMartFindStores(c); try { const targetUrl = buildActionQueryTargetUrl(c.req.url); const headers = new Headers(c.req.raw.headers); diff --git a/src/middleware/dailyRateLimit.ts b/src/middleware/dailyRateLimit.ts index 1ec1c041..b8d88293 100644 --- a/src/middleware/dailyRateLimit.ts +++ b/src/middleware/dailyRateLimit.ts @@ -21,7 +21,6 @@ const PROTECTED_SERVICES: ReadonlyArray<{ prefix: string; service: RateLimitServ { prefix: '/api/cgv/', service: 'cgv' }, { prefix: '/api/cu/', service: 'cu' }, { prefix: '/api/gs25/', service: 'gs25' }, - { prefix: '/api/lottemart/', service: 'lottemart' }, ] as const; const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; diff --git a/src/pages/openapiFullSpec.ts b/src/pages/openapiFullSpec.ts index 5e3ca423..fe84d16e 100644 --- a/src/pages/openapiFullSpec.ts +++ b/src/pages/openapiFullSpec.ts @@ -5,7 +5,6 @@ import { OPENAPI_PATHS_DAISO_OLIVEYOUNG } from './openapiSpecPathsDaisoOliveyoung.js'; import { OPENAPI_PATHS_CU } from './openapiSpecPathsCu.js'; import { OPENAPI_PATHS_EMART24 } from './openapiSpecPathsEmart24.js'; -import { OPENAPI_PATHS_LOTTEMART } from './openapiSpecPathsLotteMart.js'; import { OPENAPI_PATHS_MEGABOX } from './openapiSpecPathsMegabox.js'; import { OPENAPI_PATHS_CGV } from './openapiSpecPathsCgv.js'; import { OPENAPI_PATHS_LOTTECINEMA } from './openapiSpecPathsLottecinema.js'; @@ -56,7 +55,6 @@ export function generateFullOpenApiSpec(baseUrl: string): object { ...OPENAPI_PATHS_DAISO_OLIVEYOUNG, ...OPENAPI_PATHS_CU, ...OPENAPI_PATHS_EMART24, - ...OPENAPI_PATHS_LOTTEMART, ...OPENAPI_PATHS_GS25, ...OPENAPI_PATHS_SEVENELEVEN, ...OPENAPI_PATHS_COMPARE, diff --git a/src/pages/openapiSpecActionParameters.ts b/src/pages/openapiSpecActionParameters.ts index 58384c3e..0a57e14f 100644 --- a/src/pages/openapiSpecActionParameters.ts +++ b/src/pages/openapiSpecActionParameters.ts @@ -194,7 +194,7 @@ export const ACTION_QUERY_PARAMETERS = [ name: 'area', in: 'query', required: false, - description: '롯데마트 지역', + description: '지역', schema: { type: 'string' }, }, { @@ -222,7 +222,7 @@ export const ACTION_QUERY_PARAMETERS = [ name: 'brandVariant', in: 'query', required: false, - description: '롯데마트 브랜드 변형', + description: '브랜드 변형', schema: { type: 'string' }, }, { @@ -257,7 +257,7 @@ export const ACTION_QUERY_PARAMETERS = [ name: 'pageLimit', in: 'query', required: false, - description: '롯데마트 추가 페이지 수', + description: '추가 페이지 수', schema: { type: 'integer' }, }, { @@ -308,7 +308,7 @@ export const ACTION_QUERY_PARAMETERS = [ in: 'query', required: false, description: - '매장명. 롯데마트 상품 조회에서 storeCode가 없을 때 사용하며, 모르면 먼저 lottemartFindNearbyStores 또는 /api/lottemart/stores로 확인하세요.', + '매장명', schema: { type: 'string' }, }, { diff --git a/src/pages/openapiSpecComponents.ts b/src/pages/openapiSpecComponents.ts index 320f0bc5..2a914605 100644 --- a/src/pages/openapiSpecComponents.ts +++ b/src/pages/openapiSpecComponents.ts @@ -8,7 +8,6 @@ import { OPENAPI_DAISO_OLIVEYOUNG_COMPONENT_SCHEMAS } from './openapiSpecCompone import { OPENAPI_EMART24_COMPONENT_SCHEMAS } from './openapiSpecComponentsEmart24.js'; import { OPENAPI_GS25_COMPONENT_SCHEMAS } from './openapiSpecComponentsGs25.js'; import { OPENAPI_LOTTECINEMA_COMPONENT_SCHEMAS } from './openapiSpecComponentsLottecinema.js'; -import { OPENAPI_LOTTEMART_COMPONENT_SCHEMAS } from './openapiSpecComponentsLotteMart.js'; import { OPENAPI_MEGABOX_COMPONENT_SCHEMAS } from './openapiSpecComponentsMegabox.js'; import { OPENAPI_SEVENELEVEN_COMPONENT_SCHEMAS } from './openapiSpecComponentsSeveneleven.js'; @@ -18,7 +17,6 @@ export const OPENAPI_COMPONENTS = { ...OPENAPI_MEGABOX_COMPONENT_SCHEMAS, ...OPENAPI_CU_COMPONENT_SCHEMAS, ...OPENAPI_EMART24_COMPONENT_SCHEMAS, - ...OPENAPI_LOTTEMART_COMPONENT_SCHEMAS, ...OPENAPI_GS25_COMPONENT_SCHEMAS, ...OPENAPI_SEVENELEVEN_COMPONENT_SCHEMAS, ...OPENAPI_LOTTECINEMA_COMPONENT_SCHEMAS, diff --git a/src/pages/openapiSpecComponentsLotteMart.ts b/src/pages/openapiSpecComponentsLotteMart.ts deleted file mode 100644 index e264636f..00000000 --- a/src/pages/openapiSpecComponentsLotteMart.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * OpenAPI 컴포넌트 스키마 정의 (롯데마트) - */ - -export const OPENAPI_LOTTEMART_COMPONENT_SCHEMAS = { - LotteMartStore: { - type: 'object', - properties: { - area: { type: 'string', example: '서울' }, - storeCode: { type: 'string', example: '2301' }, - storeName: { type: 'string', example: '강변점' }, - brandVariant: { type: 'string', example: 'lottemart' }, - address: { type: 'string', example: '서울 광진구 광나루로 56길 85 테크노마트 B2' }, - phone: { type: 'string', example: '02-3424-2502' }, - openTime: { type: 'string', example: '10:00~23:00' }, - closedDays: { type: 'string', example: '03/08(일), 03/22(일)' }, - parkingType: { type: 'string', example: '유료' }, - parkingDetails: { type: 'string', example: '최초 무료주차 : 1시간' }, - detailUrl: { type: 'string', example: '/mobiledowa/market/detail_shop.asp?werks=2301' }, - latitude: { type: 'number', format: 'float', example: 37.5354 }, - longitude: { type: 'number', format: 'float', example: 127.0958 }, - distanceM: { type: 'number', nullable: true, example: 530 }, - }, - }, - LotteMartProduct: { - type: 'object', - properties: { - area: { type: 'string', example: '서울' }, - storeCode: { type: 'string', example: '2301' }, - storeName: { type: 'string', example: '강변점' }, - keyword: { type: 'string', example: '콜라' }, - productName: { type: 'string', example: '코카콜라' }, - barcode: { type: 'string', example: '8801094011307' }, - spec: { type: 'string', example: '1.2L' }, - manufacturer: { type: 'string', example: '코카콜라음료 주식회사' }, - price: { type: 'integer', example: 2980 }, - stockQuantity: { type: 'integer', example: 20 }, - }, - }, - LotteMartStoreSearchResponse: { - type: 'object', - properties: { - success: { type: 'boolean', example: true }, - data: { - type: 'object', - properties: { - stores: { - type: 'array', - items: { $ref: '#/components/schemas/LotteMartStore' }, - }, - }, - }, - meta: { - type: 'object', - properties: { - total: { type: 'integer' }, - page: { type: 'integer' }, - pageSize: { type: 'integer' }, - }, - }, - }, - }, - LotteMartProductSearchResponse: { - type: 'object', - properties: { - success: { type: 'boolean', example: true }, - data: { - type: 'object', - properties: { - products: { - type: 'array', - items: { $ref: '#/components/schemas/LotteMartProduct' }, - }, - }, - }, - meta: { - type: 'object', - properties: { - total: { type: 'integer' }, - page: { type: 'integer' }, - pageSize: { type: 'integer' }, - }, - }, - }, - }, -} as const; diff --git a/src/pages/openapiSpecPathsLotteMart.ts b/src/pages/openapiSpecPathsLotteMart.ts deleted file mode 100644 index 1d248f28..00000000 --- a/src/pages/openapiSpecPathsLotteMart.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * OpenAPI 경로 정의 (롯데마트) - */ - -export const OPENAPI_PATHS_LOTTEMART = { - '/api/lottemart/stores': { - get: { - operationId: 'lottemartFindStores', - summary: '롯데마트 매장 검색', - description: '지역/키워드 조건으로 롯데마트 계열 매장을 조회합니다.', - parameters: [ - { - name: 'keyword', - in: 'query', - required: false, - description: '매장명 또는 주소 키워드', - schema: { type: 'string' }, - }, - { - name: 'area', - in: 'query', - required: false, - description: '지역 (예: 서울, 경기, 제주)', - schema: { type: 'string' }, - }, - { - name: 'brandVariant', - in: 'query', - required: false, - description: '브랜드 변형 필터', - schema: { - type: 'string', - enum: [ - 'lottemart', - 'toysrus', - 'max', - 'bottlebunker', - 'mealguru', - 'grandgrocery', - 'other', - ], - }, - }, - { - name: 'lat', - in: 'query', - required: false, - description: '위도', - schema: { type: 'number', format: 'float' }, - }, - { - name: 'lng', - in: 'query', - required: false, - description: '경도', - schema: { type: 'number', format: 'float' }, - }, - { - name: 'limit', - in: 'query', - required: false, - description: '최대 결과 수', - schema: { type: 'integer', default: 20, minimum: 1, maximum: 100 }, - }, - ], - responses: { - '200': { - description: '검색 성공', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/LotteMartStoreSearchResponse' }, - }, - }, - }, - }, - }, - }, - '/api/lottemart/products': { - get: { - operationId: 'lottemartSearchProducts', - summary: '롯데마트 상품 검색', - description: '특정 롯데마트 매장을 기준으로 상품 가격과 재고를 조회합니다.', - parameters: [ - { - name: 'keyword', - in: 'query', - required: true, - description: '상품 검색어', - schema: { type: 'string' }, - example: '콜라', - }, - { - name: 'storeCode', - in: 'query', - required: false, - description: '매장 코드', - schema: { type: 'string' }, - example: '2301', - }, - { - name: 'storeName', - in: 'query', - required: false, - description: '매장명', - schema: { type: 'string' }, - example: '강변점', - }, - { - name: 'area', - in: 'query', - required: false, - description: '지역 (storeName 해석 보조용)', - schema: { type: 'string' }, - example: '서울', - }, - { - name: 'pageLimit', - in: 'query', - required: false, - description: '추가 조회할 최대 페이지 수', - schema: { type: 'integer', default: 3, minimum: 1, maximum: 20 }, - }, - { - name: 'source', - in: 'query', - required: false, - description: '상품 검색 경로', - schema: { type: 'string', enum: ['auto', 'legacy', 'zetta'], default: 'auto' }, - }, - { - name: 'timeoutMs', - in: 'query', - required: false, - description: '요청 제한 시간(ms)', - schema: { type: 'integer', default: 15000, minimum: 1 }, - }, - ], - responses: { - '200': { - description: '검색 성공', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/LotteMartProductSearchResponse' }, - }, - }, - }, - '400': { - description: '잘못된 요청', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/ErrorResponse' }, - }, - }, - }, - }, - }, - }, -} as const; diff --git a/src/pages/openapiSpecPathsMegabox.ts b/src/pages/openapiSpecPathsMegabox.ts index 5cf9245c..d31dd2e2 100644 --- a/src/pages/openapiSpecPathsMegabox.ts +++ b/src/pages/openapiSpecPathsMegabox.ts @@ -7,7 +7,7 @@ export const OPENAPI_PATHS_MEGABOX = { get: { operationId: 'megaboxFindNearbyTheaters', summary: '메가박스 주변 지점 조회', - description: '좌표 또는 위치 키워드 기준으로 메가박스 지점을 거리순으로 조회합니다. lat/lng가 없으면 keyword를 구글 지오코드로 보강할 수 있습니다.', + description: '좌표 또는 위치 키워드 기준으로 메가박스 지점을 거리순으로 조회합니다. lat/lng가 없으면 keyword를 카카오 무료 위치 검색(장소명은 네이버 지역 검색으로 보완)로 보강할 수 있습니다.', parameters: [ { name: 'keyword', diff --git a/src/pages/promptReferenceText.ts b/src/pages/promptReferenceText.ts index 0186c23c..c1966f2e 100644 --- a/src/pages/promptReferenceText.ts +++ b/src/pages/promptReferenceText.ts @@ -55,8 +55,6 @@ export function buildPromptReferenceText(baseUrl: string): string { | GS25_STORE_SEARCH_FAILED | GS25 매장 조회 실패 | | GS25_PRODUCT_SEARCH_FAILED | GS25 상품 조회 실패 | | GS25_INVENTORY_CHECK_FAILED | GS25 재고 조회 실패 | -| LOTTEMART_STORE_SEARCH_FAILED | 롯데마트 매장 조회 실패 | -| LOTTEMART_PRODUCT_SEARCH_FAILED | 롯데마트 상품 조회 실패 | | SEVENELEVEN_INVENTORY_CHECK_FAILED | 세븐일레븐 재고 조회 실패 | | PLACES_SEARCH_FAILED | 주변 장소 검색 실패 | | DEVELOPER_REQUEST_SUBMIT_FAILED | 개발자 요청 저장 실패 | @@ -108,7 +106,6 @@ export function buildPromptReferenceText(baseUrl: string): string { - 너무 넓은 축약어로만 매칭된 경우에는 확정 상품처럼 단정하지 말고, 추정 매칭임을 짧게 밝힌 뒤 재고를 안내합니다. - 위치/브랜드/상품명은 한 인자에 섞지 말고 가능한 한 분리해서 도구에 전달합니다. 8. **위치 기반 재고**: lat, lng 파라미터로 가까운 매장 우선 조회 -9. **롯데마트 상품 조회**: /api/lottemart/products는 keyword와 함께 storeCode 또는 storeName이 필요합니다. 10. **세븐일레븐 재고 조회**: /api/seveneleven/inventory에 keyword + storeKeyword를 함께 주면 매장별 수량을 바로 확인할 수 있습니다. 11. **이마트24 재고 조회**: /api/emart24/inventory는 pluCd + storeKeyword 조합도 지원하므로, 상품 선택 뒤 매장 코드를 다시 모으지 않아도 됩니다. 12. **올리브영 재고 해석**: inventory.products[].storeInventory.stores[]가 있으면 그 매장별 stockLabel과 remainQuantity를 우선 사용하고, inStock는 그 주변 매장 기준 결과로 해석합니다. @@ -129,8 +126,6 @@ MCP 연결 정보: ${baseUrl}/mcp - daiso_check_inventory: 재고 확인 - daiso_get_price_info: 가격 정보 조회 - daiso_get_display_location: 진열 위치 조회 -- lottemart_find_nearby_stores: 롯데마트 주변 매장 탐색 -- lottemart_search_products: 롯데마트 상품 검색 - gs25_find_nearby_stores: GS25 주변 매장 탐색 - gs25_search_products: GS25 상품 검색 - gs25_check_inventory: GS25 재고 조회 diff --git a/src/pages/promptRetailCinemaText.ts b/src/pages/promptRetailCinemaText.ts index a9d2a217..635acd2d 100644 --- a/src/pages/promptRetailCinemaText.ts +++ b/src/pages/promptRetailCinemaText.ts @@ -49,32 +49,6 @@ export function buildPromptRetailCinemaText(baseUrl: string): string { --- -### 6-2. 롯데마트 매장/상품 조회 - -**설명**: 롯데마트 계열 매장 검색과 특정 매장 기준 상품 가격/재고 조회를 제공합니다. - -**URL**: -- ${baseUrl}/api/lottemart/stores?keyword={키워드} -- ${baseUrl}/api/lottemart/products?keyword={검색어}&storeName={매장명} - -**선택 파라미터**: -- area: 지역 (예: 서울, 경기, 제주) -- brandVariant: lottemart, toysrus, max, bottlebunker, mealguru, grandgrocery -- lat: 위도 (선택) -- lng: 경도 (선택) -- limit: 최대 결과 수 (기본값: 20) -- storeCode 또는 storeName: 상품 검색 대상 매장 -- pageLimit: 추가 조회할 최대 페이지 수 (기본값: 3) -- source: 상품 검색 경로 (auto, legacy, zetta). 빠른 검색이나 헬스체크에는 zetta 권장 - -**예시**: -- ${baseUrl}/api/lottemart/stores?keyword=잠실&area=서울&limit=10 -- ${baseUrl}/api/lottemart/stores?area=경기&brandVariant=lottemart&limit=10 -- ${baseUrl}/api/lottemart/products?keyword=콜라&storeName=강변점&area=서울 -- ${baseUrl}/api/lottemart/products?keyword=우유&storeCode=2301&pageLimit=2&source=zetta - ---- - ### 6-3. GS25 매장/상품/재고 조회 **설명**: GS25 매장 탐색, 상품 키워드 검색, 재고 조회를 제공합니다. diff --git a/src/services/cgv/index.ts b/src/services/cgv/index.ts index 65fc0986..7b491263 100644 --- a/src/services/cgv/index.ts +++ b/src/services/cgv/index.ts @@ -1,3 +1,4 @@ +import type { GeocodeOptions } from '../../utils/geocode.js'; /** * CGV 서비스 프로바이더 */ @@ -16,20 +17,20 @@ const CGV_METADATA: ServiceMetadata = { }; class CgvService implements ServiceProvider { - constructor(private readonly options: { zyteApiKey?: string; googleMapsApiKey?: string } = {}) {} + constructor(private readonly options: GeocodeOptions & { zyteApiKey?: string } = {}) {} readonly metadata = CGV_METADATA; getTools(): ToolRegistration[] { return [ - createFindTheatersTool(this.options.zyteApiKey, this.options.googleMapsApiKey), - createSearchMoviesTool(this.options.zyteApiKey, this.options.googleMapsApiKey), - createGetTimetableTool(this.options.zyteApiKey, this.options.googleMapsApiKey), + createFindTheatersTool(this.options.zyteApiKey, this.options.googleMapsApiKey, this.options), + createSearchMoviesTool(this.options.zyteApiKey, this.options.googleMapsApiKey, this.options), + createGetTimetableTool(this.options.zyteApiKey, this.options.googleMapsApiKey, this.options), ]; } } -export function createCgvService(options: { zyteApiKey?: string; googleMapsApiKey?: string } = {}): ServiceProvider { +export function createCgvService(options: GeocodeOptions & { zyteApiKey?: string } = {}): ServiceProvider { return new CgvService(options); } diff --git a/src/services/cgv/location.ts b/src/services/cgv/location.ts index 3e4f6534..a29b64e3 100644 --- a/src/services/cgv/location.ts +++ b/src/services/cgv/location.ts @@ -2,13 +2,21 @@ * CGV 위치 해석 및 근처 극장 조회 보조 모듈 */ -import { fetchJson } from '../../utils/http.js'; +import { + geocodeLocation, + readGeocodeEnvironment, + searchKakaoPlaces, + validKoreanCoordinates, +} from '../../utils/geocode.js'; import { fetchCgvTheaters } from './client.js'; import type { CgvTheater } from './types.js'; interface RequestOptions { timeout?: number; googleMapsApiKey?: string; + kakaoRestApiKey?: string; + naverClientId?: string; + naverClientSecret?: string; zyteApiKey?: string; } @@ -20,19 +28,6 @@ interface ResolveCgvLocationParams { playDate?: string; } -interface GoogleGeocodeResponse { - status?: string; - results?: Array<{ - formatted_address?: string; - geometry?: { - location?: { - lat?: number; - lng?: number; - }; - }; - }>; -} - export interface CgvResolvedLocation { keyword: string | null; latitude: number | null; @@ -56,38 +51,10 @@ export interface CgvNearbyTheaterResult extends CgvResolvedLocation { } const DEFAULT_TIMEOUT_MS = 15000; -const USER_GEOCODE_CACHE_TTL_MS = 60 * 60 * 1000; -const THEATER_GEOCODE_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; -const MAX_CANDIDATE_THEATERS = 12; -const LOCATION_STOPWORDS = new Set([ - 'cgv', - '극장', - '영화', - '주변', - '근처', - '오늘', - '상영', - '시간표', - '좌석', - '잔여', - '찾아', - '주세요', - '주세요요', - '역', -]); const BRAND_PATTERN = /\b(?:cgv|씨지브이)\b/giu; const TRAILING_INTENT_PATTERN = /\s+(?:극장|영화관|영화|상영작|시간표|좌석|잔여좌석|남은좌석|목록|찾고|찾아|찾아서|알려|보여|추천|조회|확인|해주세요|해줘).*/u; -const userGeocodeCache = new Map< - string, - { expiresAt: number; value: { latitude: number; longitude: number; formattedAddress: string | null } | null } ->(); -const theaterGeocodeCache = new Map< - string, - { expiresAt: number; value: { latitude: number; longitude: number; formattedAddress: string | null } | null } ->(); - function calculateDistanceKm(lat1: number, lng1: number, lat2: number, lng2: number): number { const toRad = (deg: number) => (deg * Math.PI) / 180; const dLat = toRad(lat2 - lat1); @@ -129,85 +96,17 @@ function sanitizeLocationKeyword(keyword: string): string { return sanitized || trimmed; } -function extractLocationTokens(...values: Array): string[] { - const tokens = new Set(); - - for (const value of values) { - const matches = (value || '').match(/[0-9A-Za-z가-힣]+/g) || []; - for (const match of matches) { - const normalized = normalizeText(match); - if (normalized.length < 2 || LOCATION_STOPWORDS.has(normalized)) { - continue; - } - tokens.add(normalized); - } - } - - return [...tokens]; -} - -function scoreTheaterName(theaterName: string, tokens: string[]): number { - const normalized = normalizeText(theaterName); - return tokens.reduce((score, token) => (normalized.includes(token) ? score + 1 : score), 0); -} - -async function geocodeAddress( - query: string, - options: RequestOptions, - cacheKey: string, - ttlMs: number, - cache: Map< - string, - { expiresAt: number; value: { latitude: number; longitude: number; formattedAddress: string | null } | null } - >, -): Promise<{ latitude: number; longitude: number; formattedAddress: string | null } | null> { - const apiKey = (options.googleMapsApiKey || '').trim(); - const trimmedQuery = query.trim(); - if (apiKey.length === 0 || trimmedQuery.length === 0) { - return null; - } - - const cached = cache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return cached.value; - } - - const endpoint = new URL('https://maps.googleapis.com/maps/api/geocode/json'); - endpoint.searchParams.set('address', trimmedQuery); - endpoint.searchParams.set('key', apiKey); - - const body = await fetchJson(endpoint.toString(), { - method: 'GET', - timeout: options.timeout || DEFAULT_TIMEOUT_MS, - headers: { Accept: 'application/json' }, - }); - - const firstResult = body.status === 'OK' ? body.results?.[0] : null; - const latitude = firstResult?.geometry?.location?.lat; - const longitude = firstResult?.geometry?.location?.lng; - const value = - typeof latitude === 'number' && typeof longitude === 'number' - ? { - latitude, - longitude, - formattedAddress: firstResult?.formatted_address || null, - } - : null; - - cache.set(cacheKey, { - expiresAt: Date.now() + ttlMs, - value, - }); - - return value; -} - export async function resolveCgvLocation( params: ResolveCgvLocationParams, options: RequestOptions = {}, ): Promise { const keyword = sanitizeLocationKeyword((params.keyword || '').trim()); const hasCoordinates = isValidCoordinate(params.latitude) && isValidCoordinate(params.longitude); + if ( + hasCoordinates && + !validKoreanCoordinates(params.latitude as number, params.longitude as number) + ) + throw new Error('유효한 위도/경도를 입력해주세요.'); if (hasCoordinates) { return { @@ -229,13 +128,7 @@ export async function resolveCgvLocation( }; } - const geocoded = await geocodeAddress( - keyword, - options, - `user:${keyword}`, - USER_GEOCODE_CACHE_TTL_MS, - userGeocodeCache, - ); + const geocoded = await geocodeLocation(keyword, options); return { keyword, @@ -246,42 +139,6 @@ export async function resolveCgvLocation( }; } -async function geocodeCgvTheater( - theater: CgvTheater, - options: RequestOptions, -): Promise<{ latitude: number; longitude: number; address: string | null } | null> { - const cleanName = theater.theaterName.replace(/^CGV\s*/i, '').trim(); - const geocoded = await geocodeAddress( - `대한민국 CGV ${cleanName}`, - options, - `theater:${theater.theaterCode}:${cleanName}`, - THEATER_GEOCODE_CACHE_TTL_MS, - theaterGeocodeCache, - ); - - return geocoded - ? { - latitude: geocoded.latitude, - longitude: geocoded.longitude, - address: geocoded.formattedAddress, - } - : null; -} - -function pickCandidateTheaters(theaters: CgvTheater[], tokens: string[]): CgvTheater[] { - if (tokens.length === 0) { - return theaters.slice(0, MAX_CANDIDATE_THEATERS); - } - - const scored = theaters - .map((theater) => ({ theater, score: scoreTheaterName(theater.theaterName, tokens) })) - .filter((item) => item.score > 0) - .sort((left, right) => right.score - left.score || left.theater.theaterName.localeCompare(right.theater.theaterName)) - .map((item) => item.theater); - - return scored.slice(0, MAX_CANDIDATE_THEATERS); -} - export async function fetchCgvNearbyTheaters( params: ResolveCgvLocationParams & { playDate: string; limit?: number; timeout?: number }, options: RequestOptions = {}, @@ -295,42 +152,48 @@ export async function fetchCgvNearbyTheaters( zyteApiKey: options.zyteApiKey, }); const location = await resolveCgvLocation(params, options); - const tokens = extractLocationTokens(location.keyword, location.formattedAddress); - const hasGoogleKey = (options.googleMapsApiKey || '').trim().length > 0; - - if (tokens.length === 0 && !hasGoogleKey) { - return { - ...location, - playDate: params.playDate, - regionCode: params.regionCode || null, - count: 0, - theaters: [], - }; + if (location.keyword && location.latitude === null) + throw new Error(`위치를 좌표로 변환하지 못했습니다: ${location.keyword}`); + const geocodeOptions = { ...readGeocodeEnvironment(), ...options }; + let resolvedCandidates: CgvResolvedTheater[]; + if (location.latitude !== null && location.longitude !== null) { + // 주변 장소를 공식 CGV 목록에 연결하여 전국 앞부분만 검색하는 오류를 막습니다. + const places = await searchKakaoPlaces('CGV', geocodeOptions, { + latitude: location.latitude, + longitude: location.longitude, + }); + resolvedCandidates = places.flatMap((place) => { + const cleanName = normalizeText((place.place_name || '').replace(/^CGV\s*/iu, '')); + const theater = theaters.find( + (item) => normalizeText(item.theaterName.replace(/^CGV\s*/iu, '')) === cleanName, + ); + const latitude = Number(place.y); + const longitude = Number(place.x); + if (!theater || !validKoreanCoordinates(latitude, longitude)) return []; + return [ + { + ...theater, + latitude, + longitude, + address: place.road_address_name || place.address_name || null, + distanceKm: Number( + calculateDistanceKm( + location.latitude as number, + location.longitude as number, + latitude, + longitude, + ).toFixed(2), + ), + }, + ]; + }); + } else { + resolvedCandidates = []; } - const candidates = pickCandidateTheaters(theaters, tokens); - - const resolvedCandidates = await Promise.all( - candidates.map(async (theater) => { - const geocoded = await geocodeCgvTheater(theater, options); - const distanceKm = - geocoded && location.latitude !== null && location.longitude !== null - ? Number(calculateDistanceKm(location.latitude, location.longitude, geocoded.latitude, geocoded.longitude).toFixed(2)) - : null; - - return { - ...theater, - latitude: geocoded?.latitude ?? null, - longitude: geocoded?.longitude ?? null, - distanceKm, - address: geocoded?.address ?? null, - }; - }), - ); - const sorted = resolvedCandidates.sort((left, right) => { - const leftDistance = left.distanceKm ?? Number.POSITIVE_INFINITY; - const rightDistance = right.distanceKm ?? Number.POSITIVE_INFINITY; + const leftDistance = left.distanceKm as number; + const rightDistance = right.distanceKm as number; return leftDistance - rightDistance || left.theaterName.localeCompare(right.theaterName); }); @@ -369,6 +232,5 @@ export async function resolveCgvNearestTheater( } export function __testOnlyClearCgvLocationCaches(): void { - userGeocodeCache.clear(); - theaterGeocodeCache.clear(); + // 캐시를 유지하지 않으므로 초기화할 상태가 없습니다. } diff --git a/src/services/cgv/tools/findTheaters.ts b/src/services/cgv/tools/findTheaters.ts index 16f39bfa..4864be0a 100644 --- a/src/services/cgv/tools/findTheaters.ts +++ b/src/services/cgv/tools/findTheaters.ts @@ -1,3 +1,4 @@ +import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * CGV 극장 검색 도구 */ @@ -21,6 +22,7 @@ async function findTheaters( args: FindTheatersArgs, apiKey?: string, googleMapsApiKey?: string, + geocodeOptions: GeocodeOptions = {}, ): Promise { const { playDate = toYyyymmdd(), @@ -46,7 +48,8 @@ async function findTheaters( { timeout: timeoutMs, zyteApiKey: apiKey, - googleMapsApiKey: googleMapsApiKey || process.env.GOOGLE_MAPS_API_KEY, + ...geocodeOptions, + googleMapsApiKey, }, ); @@ -81,7 +84,7 @@ async function findTheaters( }; } -export function createFindTheatersTool(apiKey?: string, googleMapsApiKey?: string): ToolRegistration { +export function createFindTheatersTool(apiKey?: string, googleMapsApiKey?: string, geocodeOptions: GeocodeOptions = {}): ToolRegistration { return { name: 'cgv_find_theaters', metadata: { @@ -97,7 +100,7 @@ export function createFindTheatersTool(apiKey?: string, googleMapsApiKey?: strin timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: ((args) => findTheaters(args as FindTheatersArgs, apiKey, googleMapsApiKey)) as ( + handler: ((args) => findTheaters(args as FindTheatersArgs, apiKey, googleMapsApiKey, geocodeOptions)) as ( args: unknown, ) => Promise, }; diff --git a/src/services/cgv/tools/getTimetable.ts b/src/services/cgv/tools/getTimetable.ts index 4b91031a..7b03dc58 100644 --- a/src/services/cgv/tools/getTimetable.ts +++ b/src/services/cgv/tools/getTimetable.ts @@ -1,3 +1,4 @@ +import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * CGV 시간표 조회 도구 */ @@ -23,6 +24,7 @@ async function getTimetable( args: GetTimetableArgs, apiKey?: string, googleMapsApiKey?: string, + geocodeOptions: GeocodeOptions = {}, ): Promise { const { playDate = toYyyymmdd(), @@ -49,7 +51,8 @@ async function getTimetable( { timeout: timeoutMs, zyteApiKey: apiKey, - googleMapsApiKey: googleMapsApiKey || process.env.GOOGLE_MAPS_API_KEY, + ...geocodeOptions, + googleMapsApiKey, }, ); resolvedTheater = resolved.theater; @@ -90,7 +93,7 @@ async function getTimetable( }; } -export function createGetTimetableTool(apiKey?: string, googleMapsApiKey?: string): ToolRegistration { +export function createGetTimetableTool(apiKey?: string, googleMapsApiKey?: string, geocodeOptions: GeocodeOptions = {}): ToolRegistration { return { name: 'cgv_get_timetable', metadata: { @@ -107,7 +110,7 @@ export function createGetTimetableTool(apiKey?: string, googleMapsApiKey?: strin timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: ((args) => getTimetable(args as GetTimetableArgs, apiKey, googleMapsApiKey)) as ( + handler: ((args) => getTimetable(args as GetTimetableArgs, apiKey, googleMapsApiKey, geocodeOptions)) as ( args: unknown, ) => Promise, }; diff --git a/src/services/cgv/tools/searchMovies.ts b/src/services/cgv/tools/searchMovies.ts index 1f5e16a7..ce7e6ba3 100644 --- a/src/services/cgv/tools/searchMovies.ts +++ b/src/services/cgv/tools/searchMovies.ts @@ -1,3 +1,4 @@ +import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * CGV 영화 검색 도구 */ @@ -20,6 +21,7 @@ async function searchMovies( args: SearchMoviesArgs, apiKey?: string, googleMapsApiKey?: string, + geocodeOptions: GeocodeOptions = {}, ): Promise { const { playDate = toYyyymmdd(), theaterCode, keyword, latitude, longitude, timeoutMs = 15000 } = args; let resolvedTheater = null; @@ -37,7 +39,8 @@ async function searchMovies( { timeout: timeoutMs, zyteApiKey: apiKey, - googleMapsApiKey: googleMapsApiKey || process.env.GOOGLE_MAPS_API_KEY, + ...geocodeOptions, + googleMapsApiKey, }, ); resolvedTheater = resolved.theater; @@ -73,7 +76,7 @@ async function searchMovies( }; } -export function createSearchMoviesTool(apiKey?: string, googleMapsApiKey?: string): ToolRegistration { +export function createSearchMoviesTool(apiKey?: string, googleMapsApiKey?: string, geocodeOptions: GeocodeOptions = {}): ToolRegistration { return { name: 'cgv_search_movies', metadata: { @@ -88,7 +91,7 @@ export function createSearchMoviesTool(apiKey?: string, googleMapsApiKey?: strin timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: ((args) => searchMovies(args as SearchMoviesArgs, apiKey, googleMapsApiKey)) as ( + handler: ((args) => searchMovies(args as SearchMoviesArgs, apiKey, googleMapsApiKey, geocodeOptions)) as ( args: unknown, ) => Promise, }; diff --git a/src/services/cu/client.ts b/src/services/cu/client.ts index 8a893f68..e82cc34e 100644 --- a/src/services/cu/client.ts +++ b/src/services/cu/client.ts @@ -1,3 +1,4 @@ +import { geocodeLocation } from '../../utils/geocode.js'; /** * CU API 클라이언트 */ @@ -12,6 +13,9 @@ interface RequestOptions { timeout?: number; apiKey?: string; googleMapsApiKey?: string; + kakaoRestApiKey?: string; + naverClientId?: string; + naverClientSecret?: string; } interface FetchCuStoresParams { @@ -33,22 +37,9 @@ interface FetchCuStockParams { searchSort: string; } -interface Coordinate { - latitude: number; - longitude: number; -} -interface GoogleGeocodeResponse { - status?: string; - results?: Array<{ - geometry?: { - location?: { - lat?: number; - lng?: number; - }; - }; - }>; -} + + const CU_DEFAULT_HEADERS = { 'Content-Type': 'application/json', @@ -148,48 +139,9 @@ async function requestCuWebHtml( throw new Error(`API 요청 실패: ${response.status} ${response.statusText}`); } -export async function geocodeCuAddress(address: string, options: RequestOptions = {}): Promise { - const keyword = address.trim(); - if (keyword.length === 0) { - return null; - } - - const { timeout = 15000, googleMapsApiKey } = options; - const apiKey = (googleMapsApiKey || '').trim(); - if (apiKey.length === 0) { - return null; - } - - const endpoint = new URL('https://maps.googleapis.com/maps/api/geocode/json'); - endpoint.searchParams.set('address', keyword); - endpoint.searchParams.set('key', apiKey); - const body = await fetchJson(endpoint.toString(), { - method: 'GET', - timeout, - headers: { - Accept: 'application/json', - }, - }); - - if (body.status !== 'OK') { - return null; - } - - const firstLocation = body.results?.[0]?.geometry?.location; - if (!firstLocation) { - return null; - } - - const latitude = toNumber(firstLocation.lat); - const longitude = toNumber(firstLocation.lng); - if (latitude === 0 || longitude === 0) { - return null; - } - - return { - latitude, - longitude, - }; +export async function geocodeCuAddress(address: string, options: RequestOptions = {}) { + const location = await geocodeLocation(address, options); + return location ? { latitude: location.latitude, longitude: location.longitude } : null; } function decodeHtmlEntities(value: string): string { diff --git a/src/services/cu/index.ts b/src/services/cu/index.ts index fc74b4dd..f4a37367 100644 --- a/src/services/cu/index.ts +++ b/src/services/cu/index.ts @@ -17,6 +17,9 @@ const CU_METADATA: ServiceMetadata = { export interface CuServiceOptions { zyteApiKey?: string; googleMapsApiKey?: string; + kakaoRestApiKey?: string; + naverClientId?: string; + naverClientSecret?: string; } class CuService implements ServiceProvider { @@ -29,6 +32,7 @@ class CuService implements ServiceProvider { createFindNearbyStoresTool(this.options.zyteApiKey), createCheckInventoryTool({ zyteApiKey: this.options.zyteApiKey, + ...this.options, googleMapsApiKey: this.options.googleMapsApiKey, }), ]; diff --git a/src/services/cu/tools/checkInventory.ts b/src/services/cu/tools/checkInventory.ts index 9a052c6c..7a4063bc 100644 --- a/src/services/cu/tools/checkInventory.ts +++ b/src/services/cu/tools/checkInventory.ts @@ -18,11 +18,17 @@ interface CheckInventoryArgs { timeoutMs?: number; zyteApiKey?: string; googleMapsApiKey?: string; + kakaoRestApiKey?: string; + naverClientId?: string; + naverClientSecret?: string; } interface CuInventoryToolOptions { zyteApiKey?: string; googleMapsApiKey?: string; + kakaoRestApiKey?: string; + naverClientId?: string; + naverClientSecret?: string; } async function checkInventory(args: CheckInventoryArgs): Promise { @@ -82,7 +88,8 @@ async function checkInventory(args: CheckInventoryArgs): Promise 0) { const geocoded = await geocodeCuAddress(firstAddress, { timeout: timeoutMs, - googleMapsApiKey: googleMapsApiKey || process.env.GOOGLE_MAPS_API_KEY, + ...args, + googleMapsApiKey, }); if (geocoded) { const hasStockSeed = !!firstStockItem?.itemCode; @@ -194,6 +201,7 @@ export function createCheckInventoryTool(options: CuInventoryToolOptions = {}): handler: ((args: CheckInventoryArgs) => checkInventory({ ...args, + ...options, zyteApiKey: options.zyteApiKey ?? args.zyteApiKey, googleMapsApiKey: options.googleMapsApiKey ?? args.googleMapsApiKey, })) as (args: unknown) => Promise, diff --git a/src/services/gs25/client.ts b/src/services/gs25/client.ts index 1a0ce17e..885e0d83 100644 --- a/src/services/gs25/client.ts +++ b/src/services/gs25/client.ts @@ -1,3 +1,4 @@ +import { geocodeLocation } from '../../utils/geocode.js'; /** * GS25 API 클라이언트 */ @@ -26,6 +27,9 @@ export { interface RequestOptions { timeout?: number; googleMapsApiKey?: string; + kakaoRestApiKey?: string; + naverClientId?: string; + naverClientSecret?: string; zyteApiKey?: string; apiKey?: string; } @@ -51,17 +55,7 @@ interface Gs25WebLocationStore { posY?: string | number; } -interface GoogleGeocodeResponse { - status?: string; - results?: Array<{ - geometry?: { - location?: { - lat?: number; - lng?: number; - }; - }; - }>; -} + interface CacheEntry { expiresAt: number; @@ -126,46 +120,8 @@ function buildCacheKey( } export async function geocodeGs25Address(address: string, options: RequestOptions = {}) { - const keyword = address.trim(); - if (keyword.length === 0) { - return null; - } - - const { timeout = 15000, googleMapsApiKey } = options; - const apiKey = (googleMapsApiKey || '').trim(); - if (apiKey.length === 0) { - return null; - } - - const endpoint = new URL('https://maps.googleapis.com/maps/api/geocode/json'); - endpoint.searchParams.set('address', keyword); - endpoint.searchParams.set('key', apiKey); - - const body = await fetchJson(endpoint.toString(), { - ...GS25_DEFAULT_FETCH_OPTIONS, - method: 'GET', - timeout, - headers: { - Accept: 'application/json', - }, - }); - - if (body.status !== 'OK') { - return null; - } - - const location = body.results?.[0]?.geometry?.location; - if (!location) { - return null; - } - - const latitude = toNumber(location.lat); - const longitude = toNumber(location.lng); - if (latitude === 0 || longitude === 0) { - return null; - } - - return { latitude, longitude }; + const location = await geocodeLocation(address, options); + return location ? { latitude: location.latitude, longitude: location.longitude } : null; } export async function fetchGs25WebStores( diff --git a/src/services/gs25/index.ts b/src/services/gs25/index.ts index 2310f2b3..b9abd502 100644 --- a/src/services/gs25/index.ts +++ b/src/services/gs25/index.ts @@ -17,6 +17,9 @@ const GS25_METADATA: ServiceMetadata = { interface Gs25ServiceOptions { googleMapsApiKey?: string; + kakaoRestApiKey?: string; + naverClientId?: string; + naverClientSecret?: string; zyteApiKey?: string; apiKey?: string; } @@ -32,12 +35,14 @@ class Gs25Service implements ServiceProvider { this.options.googleMapsApiKey, this.options.zyteApiKey, this.options.apiKey, + this.options, ), createSearchProductsTool(this.options.zyteApiKey), createCheckInventoryTool( this.options.googleMapsApiKey, this.options.zyteApiKey, this.options.apiKey, + this.options, ), ]; } diff --git a/src/services/gs25/tools/checkInventory.ts b/src/services/gs25/tools/checkInventory.ts index f828e346..ecac2970 100644 --- a/src/services/gs25/tools/checkInventory.ts +++ b/src/services/gs25/tools/checkInventory.ts @@ -1,3 +1,4 @@ +import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * GS25 재고 확인 도구 */ @@ -29,6 +30,9 @@ interface CheckInventoryArgs { storeLimit?: number; timeoutMs?: number; googleMapsApiKey?: string; + kakaoRestApiKey?: string; + naverClientId?: string; + naverClientSecret?: string; zyteApiKey?: string; apiKey?: string; } @@ -81,7 +85,8 @@ async function checkInventory(args: CheckInventoryArgs): Promise 0) { const geocoded = await geocodeGs25Address(firstAddress, { timeout: timeoutMs, - googleMapsApiKey, + ...args, + googleMapsApiKey, }); if (geocoded) { resolvedLatitude = geocoded.latitude; @@ -240,6 +245,7 @@ export function createCheckInventoryTool( googleMapsApiKey?: string, zyteApiKey?: string, apiKey?: string, + geocodeOptions: GeocodeOptions = {}, ): ToolRegistration { return { name: 'gs25_check_inventory', @@ -278,6 +284,7 @@ export function createCheckInventoryTool( handler: ((args: CheckInventoryArgs) => checkInventory({ ...args, + ...geocodeOptions, googleMapsApiKey: args.googleMapsApiKey || googleMapsApiKey, zyteApiKey: args.zyteApiKey || zyteApiKey, apiKey: args.apiKey || apiKey, diff --git a/src/services/gs25/tools/findNearbyStores.ts b/src/services/gs25/tools/findNearbyStores.ts index 0c3a4169..6dafa2cd 100644 --- a/src/services/gs25/tools/findNearbyStores.ts +++ b/src/services/gs25/tools/findNearbyStores.ts @@ -1,3 +1,4 @@ +import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * GS25 주변 매장 탐색 도구 */ @@ -29,6 +30,9 @@ interface FindNearbyStoresArgs { limit?: number; timeoutMs?: number; googleMapsApiKey?: string; + kakaoRestApiKey?: string; + naverClientId?: string; + naverClientSecret?: string; zyteApiKey?: string; apiKey?: string; } @@ -56,6 +60,7 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise findNearbyStores({ ...args, + ...geocodeOptions, googleMapsApiKey: args.googleMapsApiKey || googleMapsApiKey, zyteApiKey: args.zyteApiKey || zyteApiKey, apiKey: args.apiKey || apiKey, diff --git a/src/services/lottecinema/index.ts b/src/services/lottecinema/index.ts index e0ef4a5a..c35f0c1a 100644 --- a/src/services/lottecinema/index.ts +++ b/src/services/lottecinema/index.ts @@ -1,3 +1,4 @@ +import type { GeocodeOptions } from '../../utils/geocode.js'; /** * 롯데시네마 서비스 프로바이더 */ @@ -16,20 +17,20 @@ const LOTTECINEMA_METADATA: ServiceMetadata = { }; class LotteCinemaService implements ServiceProvider { - constructor(private readonly options: { googleMapsApiKey?: string } = {}) {} + constructor(private readonly options: GeocodeOptions = {}) {} readonly metadata = LOTTECINEMA_METADATA; getTools(): ToolRegistration[] { return [ - createFindNearbyTheatersTool(this.options.googleMapsApiKey), - createListNowShowingTool(this.options.googleMapsApiKey), - createGetRemainingSeatsTool(this.options.googleMapsApiKey), + createFindNearbyTheatersTool(this.options.googleMapsApiKey, this.options), + createListNowShowingTool(this.options.googleMapsApiKey, this.options), + createGetRemainingSeatsTool(this.options.googleMapsApiKey, this.options), ]; } } -export function createLotteCinemaService(options: { googleMapsApiKey?: string } = {}): ServiceProvider { +export function createLotteCinemaService(options: GeocodeOptions = {}): ServiceProvider { return new LotteCinemaService(options); } diff --git a/src/services/lottecinema/location.ts b/src/services/lottecinema/location.ts index e2458c25..74b48dd1 100644 --- a/src/services/lottecinema/location.ts +++ b/src/services/lottecinema/location.ts @@ -2,13 +2,16 @@ * 롯데시네마 위치 해석 및 근처 극장 조회 보조 모듈 */ -import { fetchJson } from '../../utils/http.js'; +import { geocodeLocation, validKoreanCoordinates } from '../../utils/geocode.js'; import { fetchLotteCinemaTicketingPage } from './client.js'; import type { LotteCinemaTheater } from './types.js'; interface RequestOptions { timeout?: number; googleMapsApiKey?: string; + kakaoRestApiKey?: string; + naverClientId?: string; + naverClientSecret?: string; } interface ResolveLotteCinemaLocationParams { @@ -17,19 +20,6 @@ interface ResolveLotteCinemaLocationParams { longitude?: number; } -interface GoogleGeocodeResponse { - status?: string; - results?: Array<{ - formatted_address?: string; - geometry?: { - location?: { - lat?: number; - lng?: number; - }; - }; - }>; -} - export interface LotteCinemaResolvedLocation { keyword: string | null; latitude: number | null; @@ -49,28 +39,6 @@ export interface LotteCinemaNearbyTheaterResult extends LotteCinemaResolvedLocat } const DEFAULT_TIMEOUT_MS = 15000; -const GEOCODE_CACHE_TTL_MS = 60 * 60 * 1000; -const LOCATION_STOPWORDS = new Set([ - '롯데시네마', - '영화', - '극장', - '주변', - '근처', - '오늘', - '상영', - '영화목록', - '시간표', - '좌석', - '잔여', - '찾아', - '주세요', -]); - -const geocodeCache = new Map< - string, - { expiresAt: number; value: { latitude: number; longitude: number; formattedAddress: string | null } | null } ->(); - function calculateDistanceKm(lat1: number, lng1: number, lat2: number, lng2: number): number { const toRad = (deg: number) => (deg * Math.PI) / 180; const dLat = toRad(lat2 - lat1); @@ -86,87 +54,12 @@ function isValidCoordinate(value: number | undefined): value is number { return typeof value === 'number' && Number.isFinite(value); } -function normalizeText(text: string): string { - return text.replace(/\s+/g, '').toLowerCase(); -} - -function extractLocationTokens(...values: Array): string[] { - const tokens = new Set(); - - for (const value of values) { - const matches = (value || '').match(/[0-9A-Za-z가-힣]+/g) || []; - for (const match of matches) { - const normalized = normalizeText(match); - if (normalized.length < 2 || LOCATION_STOPWORDS.has(normalized)) { - continue; - } - tokens.add(normalized); - if (normalized.endsWith('역') && normalized.length > 2) { - tokens.add(normalized.slice(0, -1)); - } - } - } - - return [...tokens]; -} - -function scoreTheater(theater: LotteCinemaTheater, tokens: string[]): number { - const normalizedName = normalizeText(theater.theaterName); - const normalizedAddress = normalizeText(theater.address || ''); - - return tokens.reduce((score, token) => { - if (normalizedName.includes(token)) { - return score + 2; - } - if (normalizedAddress.includes(token)) { - return score + 1; - } - return score; - }, 0); -} - async function geocodeAddress( query: string, options: RequestOptions, ): Promise<{ latitude: number; longitude: number; formattedAddress: string | null } | null> { - const apiKey = (options.googleMapsApiKey || '').trim(); const trimmedQuery = query.trim(); - if (apiKey.length === 0 || trimmedQuery.length === 0) { - return null; - } - - const cacheKey = `keyword:${trimmedQuery}`; - const cached = geocodeCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return cached.value; - } - - const endpoint = new URL('https://maps.googleapis.com/maps/api/geocode/json'); - endpoint.searchParams.set('address', trimmedQuery); - endpoint.searchParams.set('key', apiKey); - - const body = await fetchJson(endpoint.toString(), { - method: 'GET', - timeout: options.timeout || DEFAULT_TIMEOUT_MS, - headers: { Accept: 'application/json' }, - }); - - const firstResult = body.status === 'OK' ? body.results?.[0] : null; - const latitude = firstResult?.geometry?.location?.lat; - const longitude = firstResult?.geometry?.location?.lng; - const value = - typeof latitude === 'number' && typeof longitude === 'number' - ? { - latitude, - longitude, - formattedAddress: firstResult?.formatted_address || null, - } - : null; - - geocodeCache.set(cacheKey, { - expiresAt: Date.now() + GEOCODE_CACHE_TTL_MS, - value, - }); + const value = await geocodeLocation(trimmedQuery, options); return value; } @@ -177,6 +70,11 @@ export async function resolveLotteCinemaLocation( ): Promise { const keyword = (params.keyword || '').trim(); const hasCoordinates = isValidCoordinate(params.latitude) && isValidCoordinate(params.longitude); + if ( + hasCoordinates && + !validKoreanCoordinates(params.latitude as number, params.longitude as number) + ) + throw new Error('유효한 위도/경도를 입력해주세요.'); if (hasCoordinates) { return { @@ -199,13 +97,14 @@ export async function resolveLotteCinemaLocation( } const geocoded = await geocodeAddress(keyword, options); + if (!geocoded) throw new Error(`위치를 좌표로 변환하지 못했습니다: ${keyword}`); return { keyword, - latitude: geocoded?.latitude ?? null, - longitude: geocoded?.longitude ?? null, - geocodeUsed: Boolean(geocoded), - formattedAddress: geocoded?.formattedAddress ?? null, + latitude: geocoded.latitude, + longitude: geocoded.longitude, + geocodeUsed: true, + formattedAddress: geocoded.formattedAddress, }; } @@ -234,22 +133,13 @@ export async function fetchLotteCinemaNearbyTheaters( ).toFixed(2), ), })) - .sort((left, right) => left.distanceKm - right.distanceKm || left.theaterName.localeCompare(right.theaterName)) + .sort( + (left, right) => + left.distanceKm - right.distanceKm || left.theaterName.localeCompare(right.theaterName), + ) .slice(0, limit); } else { - const tokens = extractLocationTokens(location.keyword, location.formattedAddress); - nearby = theaters - .map((theater) => ({ - theater, - score: scoreTheater(theater, tokens), - })) - .filter((item) => item.score > 0) - .sort((left, right) => right.score - left.score || left.theater.theaterName.localeCompare(right.theater.theaterName)) - .slice(0, limit) - .map((item) => ({ - ...item.theater, - distanceKm: null, - })); + nearby = []; } return { @@ -286,5 +176,5 @@ export async function resolveLotteCinemaNearestTheater( } export function __testOnlyClearLotteCinemaLocationCaches(): void { - geocodeCache.clear(); + // 캐시를 유지하지 않으므로 초기화할 상태가 없습니다. } diff --git a/src/services/lottecinema/tools/findNearbyTheaters.ts b/src/services/lottecinema/tools/findNearbyTheaters.ts index 838f691c..8a1ccf89 100644 --- a/src/services/lottecinema/tools/findNearbyTheaters.ts +++ b/src/services/lottecinema/tools/findNearbyTheaters.ts @@ -1,3 +1,4 @@ +import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * 롯데시네마 주변 지점 탐색 도구 */ @@ -19,6 +20,7 @@ interface FindNearbyTheatersArgs { async function findNearbyTheaters( args: FindNearbyTheatersArgs, googleMapsApiKey?: string, + geocodeOptions: GeocodeOptions = {}, ): Promise { const { keyword, latitude, longitude, playDate = toYyyymmdd(), limit = 10, timeoutMs = 15000 } = args; const hasCoordinates = typeof latitude === 'number' && typeof longitude === 'number'; @@ -33,7 +35,8 @@ async function findNearbyTheaters( }, { timeout: timeoutMs, - googleMapsApiKey: googleMapsApiKey || process.env.GOOGLE_MAPS_API_KEY, + ...geocodeOptions, + googleMapsApiKey, }, ); @@ -47,7 +50,7 @@ async function findNearbyTheaters( }; } -export function createFindNearbyTheatersTool(googleMapsApiKey?: string): ToolRegistration { +export function createFindNearbyTheatersTool(googleMapsApiKey?: string, geocodeOptions: GeocodeOptions = {}): ToolRegistration { return { name: 'lottecinema_find_nearby_theaters', metadata: { @@ -55,14 +58,14 @@ export function createFindNearbyTheatersTool(googleMapsApiKey?: string): ToolReg description: '위치 키워드 또는 좌표 기준으로 롯데시네마 지점을 거리순으로 조회합니다.', inputSchema: { keyword: z.string().optional().describe('위치 키워드 (예: 안산 중앙역, 잠실역)'), - latitude: z.number().optional().default(37.5665).describe('위도 (기본값: 서울 시청 37.5665)'), - longitude: z.number().optional().default(126.978).describe('경도 (기본값: 서울 시청 126.978)'), + latitude: z.number().optional().describe('위도 (좌표 또는 위치 키워드 입력)'), + longitude: z.number().optional().describe('경도 (좌표 또는 위치 키워드 입력)'), playDate: z.string().optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), limit: z.number().optional().default(10).describe('반환할 최대 지점 수 (기본값: 10)'), timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: ((args) => findNearbyTheaters(args as FindNearbyTheatersArgs, googleMapsApiKey)) as ( + handler: ((args) => findNearbyTheaters(args as FindNearbyTheatersArgs, googleMapsApiKey, geocodeOptions)) as ( args: unknown, ) => Promise, }; diff --git a/src/services/lottecinema/tools/getRemainingSeats.ts b/src/services/lottecinema/tools/getRemainingSeats.ts index 5cec6c16..3226c70c 100644 --- a/src/services/lottecinema/tools/getRemainingSeats.ts +++ b/src/services/lottecinema/tools/getRemainingSeats.ts @@ -1,3 +1,4 @@ +import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * 롯데시네마 잔여 좌석 조회 도구 */ @@ -21,6 +22,7 @@ interface GetRemainingSeatsArgs { async function getRemainingSeats( args: GetRemainingSeatsArgs, googleMapsApiKey?: string, + geocodeOptions: GeocodeOptions = {}, ): Promise { const { playDate = toYyyymmdd(), @@ -46,7 +48,8 @@ async function getRemainingSeats( }, { timeout: timeoutMs, - googleMapsApiKey: googleMapsApiKey || process.env.GOOGLE_MAPS_API_KEY, + ...geocodeOptions, + googleMapsApiKey, }, ); resolvedTheater = resolved.theater; @@ -102,7 +105,7 @@ async function getRemainingSeats( }; } -export function createGetRemainingSeatsTool(googleMapsApiKey?: string): ToolRegistration { +export function createGetRemainingSeatsTool(googleMapsApiKey?: string, geocodeOptions: GeocodeOptions = {}): ToolRegistration { return { name: 'lottecinema_get_remaining_seats', metadata: { @@ -119,7 +122,7 @@ export function createGetRemainingSeatsTool(googleMapsApiKey?: string): ToolRegi timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: ((args) => getRemainingSeats(args as GetRemainingSeatsArgs, googleMapsApiKey)) as ( + handler: ((args) => getRemainingSeats(args as GetRemainingSeatsArgs, googleMapsApiKey, geocodeOptions)) as ( args: unknown, ) => Promise, }; diff --git a/src/services/lottecinema/tools/listNowShowing.ts b/src/services/lottecinema/tools/listNowShowing.ts index 296a9659..1715634f 100644 --- a/src/services/lottecinema/tools/listNowShowing.ts +++ b/src/services/lottecinema/tools/listNowShowing.ts @@ -1,3 +1,4 @@ +import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * 롯데시네마 영화 목록 조회 도구 */ @@ -17,7 +18,7 @@ interface ListNowShowingArgs { timeoutMs?: number; } -async function listNowShowing(args: ListNowShowingArgs, googleMapsApiKey?: string): Promise { +async function listNowShowing(args: ListNowShowingArgs, googleMapsApiKey?: string, geocodeOptions: GeocodeOptions = {}): Promise { const { playDate = toYyyymmdd(), theaterId, movieId, keyword, latitude, longitude, timeoutMs = 15000 } = args; let resolvedTheater = null; let targetTheaterId = theaterId; @@ -33,7 +34,8 @@ async function listNowShowing(args: ListNowShowingArgs, googleMapsApiKey?: strin }, { timeout: timeoutMs, - googleMapsApiKey: googleMapsApiKey || process.env.GOOGLE_MAPS_API_KEY, + ...geocodeOptions, + googleMapsApiKey, }, ); resolvedTheater = resolved.theater; @@ -83,7 +85,7 @@ async function listNowShowing(args: ListNowShowingArgs, googleMapsApiKey?: strin }; } -export function createListNowShowingTool(googleMapsApiKey?: string): ToolRegistration { +export function createListNowShowingTool(googleMapsApiKey?: string, geocodeOptions: GeocodeOptions = {}): ToolRegistration { return { name: 'lottecinema_list_now_showing', metadata: { @@ -99,7 +101,7 @@ export function createListNowShowingTool(googleMapsApiKey?: string): ToolRegistr timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: ((args) => listNowShowing(args as ListNowShowingArgs, googleMapsApiKey)) as ( + handler: ((args) => listNowShowing(args as ListNowShowingArgs, googleMapsApiKey, geocodeOptions)) as ( args: unknown, ) => Promise, }; diff --git a/src/services/lottemart/client.ts b/src/services/lottemart/client.ts index 80305eab..221bba8a 100644 --- a/src/services/lottemart/client.ts +++ b/src/services/lottemart/client.ts @@ -167,26 +167,14 @@ export async function fetchLotteMartStores( geocodeUsed: boolean; }> { const { area, keyword = '', brandVariant = '', latitude, longitude, limit = 20 } = params; - const { timeout = DEFAULT_LOTTEMART_TIMEOUT_MS, googleMapsApiKey } = options; + const { timeout = DEFAULT_LOTTEMART_TIMEOUT_MS } = options; if (area && !normalizeArea(area)) { throw new Error(`지원하지 않는 지역입니다: ${area}`); } - let resolvedLatitude = latitude; - let resolvedLongitude = longitude; - let geocodeUsed = false; - - if ( - (typeof resolvedLatitude !== 'number' || typeof resolvedLongitude !== 'number') && - keyword.trim().length > 0 - ) { - const geocoded = await geocodeLotteMartAddress(keyword, { timeout, googleMapsApiKey }); - if (geocoded) { - resolvedLatitude = geocoded.latitude; - resolvedLongitude = geocoded.longitude; - geocodeUsed = true; - } - } + const resolvedLatitude = latitude; + const resolvedLongitude = longitude; + const geocodeUsed = false; const sessionCookie = await getLotteMartSessionCookie({ timeout }); @@ -219,33 +207,9 @@ export async function fetchLotteMartStores( resolvedLongitude, ); - const enriched = await Promise.all( - filtered.map(async (store) => { - if ( - typeof resolvedLatitude !== 'number' || - typeof resolvedLongitude !== 'number' || - store.latitude !== 0 || - store.longitude !== 0 - ) { - return store; - } - - const geocoded = await geocodeLotteMartAddress(store.address, { timeout, googleMapsApiKey }); - if (!geocoded) { - return store; - } - - return { - ...store, - latitude: geocoded.latitude, - longitude: geocoded.longitude, - distanceM: calculateDistanceM(resolvedLatitude, resolvedLongitude, geocoded.latitude, geocoded.longitude), - }; - }), - ); return { - stores: sortStores(enriched).slice(0, limit), + stores: sortStores(filtered).slice(0, limit), location: typeof resolvedLatitude === 'number' && typeof resolvedLongitude === 'number' ? { latitude: resolvedLatitude, longitude: resolvedLongitude } diff --git a/src/services/lottemart/geocode.ts b/src/services/lottemart/geocode.ts index 82f508f8..e3d478cb 100644 --- a/src/services/lottemart/geocode.ts +++ b/src/services/lottemart/geocode.ts @@ -1,75 +1,11 @@ -/** - * 롯데마트 매장 주소 지오코딩 - */ - -import { fetchJson } from '../../utils/http.js'; -import { DEFAULT_LOTTEMART_TIMEOUT_MS } from './config.js'; +/** 롯데마트 지원 종료 이후 기존 내부 함수도 외부 지도 API를 호출하지 않습니다. */ import type { RequestOptions } from './clientTypes.js'; - -interface GoogleGeocodeResponse { - status: string; - results?: Array<{ - geometry?: { - location?: { - lat?: number; - lng?: number; - }; - }; - }>; +export async function geocodeLotteMartAddress( + _address: string, + _options: RequestOptions = {}, +): Promise<{ latitude: number; longitude: number } | null> { + return null; } - -const GEOCODE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; -const geocodeCache = new Map(); - -export async function geocodeLotteMartAddress(address: string, options: RequestOptions = {}) { - const keyword = address.trim(); - if (keyword.length === 0) { - return null; - } - - const cached = geocodeCache.get(keyword); - if (cached && cached.expiresAt > Date.now()) { - return cached.value; - } - - const apiKey = (options.googleMapsApiKey || '').trim(); - if (apiKey.length === 0) { - return null; - } - - const endpoint = new URL('https://maps.googleapis.com/maps/api/geocode/json'); - endpoint.searchParams.set('address', keyword); - endpoint.searchParams.set('key', apiKey); - - const body = await fetchJson(endpoint.toString(), { - method: 'GET', - timeout: options.timeout || DEFAULT_LOTTEMART_TIMEOUT_MS, - headers: { - Accept: 'application/json', - }, - }); - - if (body.status !== 'OK') { - geocodeCache.set(keyword, { - expiresAt: Date.now() + GEOCODE_CACHE_TTL_MS, - value: null, - }); - return null; - } - - const latitude = body.results?.[0]?.geometry?.location?.lat; - const longitude = body.results?.[0]?.geometry?.location?.lng; - const value = - typeof latitude === 'number' && typeof longitude === 'number' ? { latitude, longitude } : null; - - geocodeCache.set(keyword, { - expiresAt: Date.now() + GEOCODE_CACHE_TTL_MS, - value, - }); - - return value; -} - export function __testOnlyClearLotteMartGeocodeCache(): void { - geocodeCache.clear(); + // 지원 종료 후에는 캐시나 외부 요청을 유지하지 않습니다. } diff --git a/src/services/megabox/index.ts b/src/services/megabox/index.ts index bce814d6..fcbe8b25 100644 --- a/src/services/megabox/index.ts +++ b/src/services/megabox/index.ts @@ -1,3 +1,4 @@ +import type { GeocodeOptions } from '../../utils/geocode.js'; /** * 메가박스 서비스 프로바이더 */ @@ -16,19 +17,20 @@ const MEGABOX_METADATA: ServiceMetadata = { }; class MegaboxService implements ServiceProvider { + constructor(private readonly options: GeocodeOptions = {}) {} readonly metadata = MEGABOX_METADATA; getTools(): ToolRegistration[] { return [ - createFindNearbyTheatersTool(), - createListNowShowingTool(), - createGetRemainingSeatsTool(), + createFindNearbyTheatersTool(this.options), + createListNowShowingTool(this.options), + createGetRemainingSeatsTool(this.options), ]; } } -export function createMegaboxService(): ServiceProvider { - return new MegaboxService(); +export function createMegaboxService(options: GeocodeOptions = {}): ServiceProvider { + return new MegaboxService(options); } export * from './types.js'; diff --git a/src/services/megabox/location.ts b/src/services/megabox/location.ts index f9a616f9..5720df7f 100644 --- a/src/services/megabox/location.ts +++ b/src/services/megabox/location.ts @@ -2,12 +2,15 @@ * 메가박스 위치 해석 및 근처 지점 조회 보조 모듈 */ -import { fetchJson } from '../../utils/http.js'; +import { geocodeLocation, validKoreanCoordinates } from '../../utils/geocode.js'; import { fetchMegaboxBookingList, fetchMegaboxTheaterInfo } from './client.js'; interface RequestOptions { timeout?: number; googleMapsApiKey?: string; + kakaoRestApiKey?: string; + naverClientId?: string; + naverClientSecret?: string; } interface ResolveMegaboxLocationParams { @@ -17,23 +20,6 @@ interface ResolveMegaboxLocationParams { areaCode?: string; } -interface GoogleGeocodeResponse { - status?: string; - results?: Array<{ - formatted_address?: string; - address_components?: Array<{ - long_name?: string; - short_name?: string; - }>; - geometry?: { - location?: { - lat?: number; - lng?: number; - }; - }; - }>; -} - export interface MegaboxNearbyTheater { theaterId: string; theaterName: string; @@ -61,12 +47,6 @@ const DEFAULT_LATITUDE = 37.5665; const DEFAULT_LONGITUDE = 126.978; const DEFAULT_AREA_CODE = '11'; const DEFAULT_TIMEOUT_MS = 15000; -const GEOCODE_CACHE_TTL_MS = 60 * 60 * 1000; - -const megaboxGeocodeCache = new Map< - string, - { expiresAt: number; value: { latitude: number; longitude: number; areaCode: string | null } | null } ->(); const MEGABOX_AREA_CODE_BY_REGION: Array<[string, string]> = [ ['서울특별시', '11'], @@ -133,82 +113,6 @@ function resolveAreaCodeFromText(text: string): string | null { return null; } -function resolveAreaCodeFromGeocodeResult(result: NonNullable[number]): string | null { - for (const component of result.address_components || []) { - const candidates = [component.long_name || '', component.short_name || '']; - for (const candidate of candidates) { - const areaCode = resolveAreaCodeFromText(candidate); - if (areaCode) { - return areaCode; - } - } - } - - return resolveAreaCodeFromText(result.formatted_address || ''); -} - -async function geocodeMegaboxLocation( - params: ResolveMegaboxLocationParams, - options: RequestOptions = {}, -): Promise<{ latitude: number; longitude: number; areaCode: string | null } | null> { - const apiKey = (options.googleMapsApiKey || '').trim(); - if (apiKey.length === 0) { - return null; - } - - const hasCoordinates = isValidCoordinate(params.latitude) && isValidCoordinate(params.longitude); - const keyword = (params.keyword || '').trim(); - if (!hasCoordinates && keyword.length === 0) { - return null; - } - - const cacheKey = hasCoordinates ? `coords:${params.latitude},${params.longitude}` : `keyword:${keyword}`; - const cached = megaboxGeocodeCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return cached.value; - } - - const endpoint = new URL('https://maps.googleapis.com/maps/api/geocode/json'); - if (hasCoordinates) { - endpoint.searchParams.set('latlng', `${params.latitude},${params.longitude}`); - } else { - endpoint.searchParams.set('address', keyword); - } - endpoint.searchParams.set('key', apiKey); - - const body = await fetchJson(endpoint.toString(), { - method: 'GET', - timeout: options.timeout || DEFAULT_TIMEOUT_MS, - headers: { - Accept: 'application/json', - }, - }); - - if (body.status !== 'OK') { - megaboxGeocodeCache.set(cacheKey, { - expiresAt: Date.now() + GEOCODE_CACHE_TTL_MS, - value: null, - }); - return null; - } - - const firstResult = body.results?.[0]; - const latitude = firstResult?.geometry?.location?.lat; - const longitude = firstResult?.geometry?.location?.lng; - const areaCode = firstResult ? resolveAreaCodeFromGeocodeResult(firstResult) : null; - const value = - typeof latitude === 'number' && typeof longitude === 'number' - ? { latitude, longitude, areaCode } - : null; - - megaboxGeocodeCache.set(cacheKey, { - expiresAt: Date.now() + GEOCODE_CACHE_TTL_MS, - value, - }); - - return value; -} - export async function resolveMegaboxLocation( params: ResolveMegaboxLocationParams, options: RequestOptions = {}, @@ -216,31 +120,32 @@ export async function resolveMegaboxLocation( const explicitAreaCode = (params.areaCode || '').trim(); const keyword = (params.keyword || '').trim(); const hasCoordinates = isValidCoordinate(params.latitude) && isValidCoordinate(params.longitude); - - let latitude: number = hasCoordinates ? (params.latitude as number) : DEFAULT_LATITUDE; - let longitude: number = hasCoordinates ? (params.longitude as number) : DEFAULT_LONGITUDE; - let areaCode = explicitAreaCode || ''; - let geocodeUsed = false; - - if (!explicitAreaCode || !hasCoordinates) { - const geocoded = await geocodeMegaboxLocation(params, options); - if (geocoded) { - if (!hasCoordinates) { - latitude = geocoded.latitude; - longitude = geocoded.longitude; - } - if (!explicitAreaCode && geocoded.areaCode) { - areaCode = geocoded.areaCode; - } - geocodeUsed = true; - } - } + if ( + hasCoordinates && + !validKoreanCoordinates(params.latitude as number, params.longitude as number) + ) + throw new Error('유효한 위도/경도를 입력해주세요.'); + + const geocoded = !hasCoordinates && keyword ? await geocodeLocation(keyword, options) : null; + if (keyword && !hasCoordinates && !geocoded) + throw new Error(`위치를 좌표로 변환하지 못했습니다: ${keyword}`); + const latitude = hasCoordinates + ? (params.latitude as number) + : (geocoded?.latitude ?? DEFAULT_LATITUDE); + const longitude = hasCoordinates + ? (params.longitude as number) + : (geocoded?.longitude ?? DEFAULT_LONGITUDE); + const areaCode = + explicitAreaCode || + resolveAreaCodeFromText(geocoded?.formattedAddress || keyword) || + (hasCoordinates ? '' : DEFAULT_AREA_CODE); + const geocodeUsed = Boolean(geocoded); return { keyword: keyword.length > 0 ? keyword : null, latitude, longitude, - areaCode: areaCode || DEFAULT_AREA_CODE, + areaCode, geocodeUsed, }; } @@ -281,7 +186,12 @@ export async function fetchMegaboxNearbyTheaters( latitude: info.latitude, longitude: info.longitude, distanceKm: Number( - calculateDistanceKm(resolved.latitude, resolved.longitude, info.latitude, info.longitude).toFixed(2), + calculateDistanceKm( + resolved.latitude, + resolved.longitude, + info.latitude, + info.longitude, + ).toFixed(2), ), }; }) @@ -324,5 +234,5 @@ export async function resolveMegaboxNearestTheater( } export function __testOnlyClearMegaboxLocationCaches(): void { - megaboxGeocodeCache.clear(); + // 캐시를 유지하지 않으므로 초기화할 상태가 없습니다. } diff --git a/src/services/megabox/tools/findNearbyTheaters.ts b/src/services/megabox/tools/findNearbyTheaters.ts index 0ab511f0..8b2aaf7d 100644 --- a/src/services/megabox/tools/findNearbyTheaters.ts +++ b/src/services/megabox/tools/findNearbyTheaters.ts @@ -1,3 +1,4 @@ +import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * 메가박스 주변 지점 탐색 도구 */ @@ -17,7 +18,7 @@ interface FindNearbyTheatersArgs { timeoutMs?: number; } -async function findNearbyTheaters(args: FindNearbyTheatersArgs): Promise { +async function findNearbyTheaters(args: FindNearbyTheatersArgs, geocodeOptions: GeocodeOptions = {}): Promise { const { keyword, latitude, @@ -39,7 +40,7 @@ async function findNearbyTheaters(args: FindNearbyTheatersArgs): Promise Promise, + handler: ((args: unknown) => findNearbyTheaters(args as FindNearbyTheatersArgs, geocodeOptions)), }; } diff --git a/src/services/megabox/tools/getRemainingSeats.ts b/src/services/megabox/tools/getRemainingSeats.ts index d4ad8d3c..422c93a5 100644 --- a/src/services/megabox/tools/getRemainingSeats.ts +++ b/src/services/megabox/tools/getRemainingSeats.ts @@ -1,3 +1,4 @@ +import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * 메가박스 잔여 좌석 조회 도구 */ @@ -19,7 +20,7 @@ interface GetRemainingSeatsArgs { timeoutMs?: number; } -async function getRemainingSeats(args: GetRemainingSeatsArgs): Promise { +async function getRemainingSeats(args: GetRemainingSeatsArgs, geocodeOptions: GeocodeOptions = {}): Promise { const { playDate = toYyyymmdd(), theaterId: inputTheaterId, @@ -46,7 +47,7 @@ async function getRemainingSeats(args: GetRemainingSeatsArgs): Promise Promise, + handler: ((args: unknown) => getRemainingSeats(args as GetRemainingSeatsArgs, geocodeOptions)), }; } diff --git a/src/services/megabox/tools/listNowShowing.ts b/src/services/megabox/tools/listNowShowing.ts index 87d8a144..8728bf92 100644 --- a/src/services/megabox/tools/listNowShowing.ts +++ b/src/services/megabox/tools/listNowShowing.ts @@ -1,3 +1,4 @@ +import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * 메가박스 영화 목록 조회 도구 */ @@ -18,7 +19,7 @@ interface ListNowShowingArgs { timeoutMs?: number; } -async function listNowShowing(args: ListNowShowingArgs): Promise { +async function listNowShowing(args: ListNowShowingArgs, geocodeOptions: GeocodeOptions = {}): Promise { const { playDate = toYyyymmdd(), theaterId: inputTheaterId, @@ -44,7 +45,7 @@ async function listNowShowing(args: ListNowShowingArgs): Promise Promise, + handler: ((args: unknown) => listNowShowing(args as ListNowShowingArgs, geocodeOptions)), }; } diff --git a/src/services/opinet/client.ts b/src/services/opinet/client.ts index 17ff9863..cbb82593 100644 --- a/src/services/opinet/client.ts +++ b/src/services/opinet/client.ts @@ -33,6 +33,9 @@ const BRAND_NAMES: Record = { interface OpinetClientOptions { apiKey?: string; googleMapsApiKey?: string; + kakaoRestApiKey?: string; + naverClientId?: string; + naverClientSecret?: string; timeoutMs?: number; fetchImpl?: typeof fetch; } @@ -252,7 +255,7 @@ export async function fetchOpinetStationsAround( options: OpinetClientOptions = {}, ): Promise { const resolvedLocation = await resolveOpinetLocation(params, { - googleMapsApiKey: options.googleMapsApiKey, + ...options, timeoutMs: options.timeoutMs, fetchImpl: options.fetchImpl, }); diff --git a/src/services/opinet/index.ts b/src/services/opinet/index.ts index fa526d1b..5981b6ed 100644 --- a/src/services/opinet/index.ts +++ b/src/services/opinet/index.ts @@ -17,6 +17,9 @@ const OPINET_METADATA: ServiceMetadata = { interface OpinetServiceOptions { apiKey?: string; googleMapsApiKey?: string; + kakaoRestApiKey?: string; + naverClientId?: string; + naverClientSecret?: string; } class OpinetService implements ServiceProvider { @@ -28,7 +31,7 @@ class OpinetService implements ServiceProvider { return [ createOpinetAveragePricesTool(this.options.apiKey), createOpinetLowestStationsTool(this.options.apiKey), - createOpinetStationsAroundTool(this.options.apiKey, this.options.googleMapsApiKey), + createOpinetStationsAroundTool(this.options.apiKey, this.options.googleMapsApiKey, this.options), createOpinetStationDetailTool(this.options.apiKey), ]; } diff --git a/src/services/opinet/location.ts b/src/services/opinet/location.ts index 38dc8a1d..18853b73 100644 --- a/src/services/opinet/location.ts +++ b/src/services/opinet/location.ts @@ -1,3 +1,7 @@ +import { + geocodeLocation as resolveFreeLocation, + validKoreanCoordinates, +} from '../../utils/geocode.js'; import proj4 from 'proj4'; const WGS84 = 'EPSG:4326'; @@ -7,20 +11,6 @@ const KATEC_PROJ4 = proj4.defs(KATEC, KATEC_PROJ4); -interface GoogleGeocodeResponse { - status: string; - results?: Array<{ - formatted_address?: string; - geometry?: { - location?: { - lat?: number; - lng?: number; - }; - }; - }>; - error_message?: string; -} - export interface OpinetLocationInput { x?: number; y?: number; @@ -31,6 +21,9 @@ export interface OpinetLocationInput { export interface OpinetLocationOptions { googleMapsApiKey?: string; + kakaoRestApiKey?: string; + naverClientId?: string; + naverClientSecret?: string; timeoutMs?: number; fetchImpl?: typeof fetch; } @@ -48,17 +41,6 @@ export interface ResolvedOpinetLocation { inputType: 'katec' | 'coordinates' | 'location'; } -const GEOCODE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; -const geocodeCache = new Map< - string, - { expiresAt: number; value: { latitude: number; longitude: number; formattedAddress: string | null } | null } ->(); - -function getProcessEnvValue(name: string): string | undefined { - /* c8 ignore next -- Worker 런타임은 bindings로 키를 주입합니다. */ - return typeof process !== 'undefined' ? process.env[name] : undefined; -} - function isFiniteNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value); } @@ -79,63 +61,7 @@ async function geocodeLocation( location: string, options: OpinetLocationOptions, ): Promise<{ latitude: number; longitude: number; formattedAddress: string | null } | null> { - const keyword = location.trim(); - const cached = geocodeCache.get(keyword); - if (cached && cached.expiresAt > Date.now()) { - return cached.value; - } - - const apiKey = options.googleMapsApiKey?.trim() || getProcessEnvValue('GOOGLE_MAPS_API_KEY')?.trim(); - if (!apiKey) { - throw new Error('location 검색에는 GOOGLE_MAPS_API_KEY가 필요합니다.'); - } - - const endpoint = new URL('https://maps.googleapis.com/maps/api/geocode/json'); - endpoint.searchParams.set('address', keyword); - endpoint.searchParams.set('key', apiKey); - - const response = await (options.fetchImpl ?? fetch)(endpoint.toString(), { - headers: { Accept: 'application/json' }, - signal: AbortSignal.timeout(options.timeoutMs ?? 10000), - }); - const bodyText = await response.text(); - let parsed: GoogleGeocodeResponse; - try { - parsed = JSON.parse(bodyText) as GoogleGeocodeResponse; - } catch { - parsed = { status: 'INVALID_JSON' }; - } - - if (!response.ok) { - throw new Error(`Google Geocoding 호출 실패: HTTP ${response.status} ${bodyText}`); - } - - if (parsed.status !== 'OK') { - geocodeCache.set(keyword, { - expiresAt: Date.now() + GEOCODE_CACHE_TTL_MS, - value: null, - }); - return null; - } - - const first = parsed.results?.[0]; - const latitude = first?.geometry?.location?.lat; - const longitude = first?.geometry?.location?.lng; - const value = - isFiniteNumber(latitude) && isFiniteNumber(longitude) - ? { - latitude, - longitude, - formattedAddress: first?.formatted_address ?? null, - } - : null; - - geocodeCache.set(keyword, { - expiresAt: Date.now() + GEOCODE_CACHE_TTL_MS, - value, - }); - - return value; + return resolveFreeLocation(location, options); } export async function resolveOpinetLocation( @@ -155,6 +81,8 @@ export async function resolveOpinetLocation( } if (isFiniteNumber(input.latitude) && isFiniteNumber(input.longitude)) { + if (!validKoreanCoordinates(input.latitude, input.longitude)) + throw new Error('유효한 위도/경도를 입력해주세요.'); return { katec: wgs84ToKatec(input.latitude, input.longitude), latitude: input.latitude, @@ -184,9 +112,11 @@ export async function resolveOpinetLocation( }; } - throw new Error('KATEC x/y, 위도/경도(latitude/longitude), 또는 location 중 하나를 입력해주세요.'); + throw new Error( + 'KATEC x/y, 위도/경도(latitude/longitude), 또는 location 중 하나를 입력해주세요.', + ); } export function __testOnlyClearOpinetGeocodeCache(): void { - geocodeCache.clear(); + // 캐시를 유지하지 않으므로 초기화할 상태가 없습니다. } diff --git a/src/services/opinet/tools.ts b/src/services/opinet/tools.ts index 4418c0f5..800ee8dd 100644 --- a/src/services/opinet/tools.ts +++ b/src/services/opinet/tools.ts @@ -1,3 +1,4 @@ +import type { GeocodeOptions } from '../../utils/geocode.js'; import * as z from 'zod'; import type { McpToolResponse, ToolRegistration } from '../../core/types.js'; import { @@ -55,7 +56,7 @@ export function createOpinetLowestStationsTool(apiKey?: string): ToolRegistratio }; } -export function createOpinetStationsAroundTool(apiKey?: string, googleMapsApiKey?: string): ToolRegistration { +export function createOpinetStationsAroundTool(apiKey?: string, googleMapsApiKey?: string, geocodeOptions: GeocodeOptions = {}): ToolRegistration { return { name: 'opinet_search_stations_around', metadata: { @@ -90,7 +91,7 @@ export function createOpinetStationsAroundTool(apiKey?: string, googleMapsApiKey timeoutMs?: number; }) => buildTextResponse( - await fetchOpinetStationsAround(args, { apiKey, googleMapsApiKey, timeoutMs: args.timeoutMs }), + await fetchOpinetStationsAround(args, { ...geocodeOptions, apiKey, googleMapsApiKey, timeoutMs: args.timeoutMs }), )) as (args: unknown) => Promise, }; } diff --git a/src/utils/geocode.ts b/src/utils/geocode.ts new file mode 100644 index 00000000..bbcffb60 --- /dev/null +++ b/src/utils/geocode.ts @@ -0,0 +1,200 @@ +import { fetchJson } from './http.js'; +import { searchNaverLocalPlaces } from '../services/places/client.js'; + +export interface GeocodeOptions { + kakaoRestApiKey?: string; + naverClientId?: string; + naverClientSecret?: string; + googleMapsApiKey?: string; + fetchImpl?: typeof fetch; + timeout?: number; + timeoutMs?: number; +} +export interface GeocodedLocation { + latitude: number; + longitude: number; + formattedAddress: string | null; +} +interface KakaoDocument { + x?: string; + y?: string; + place_name?: string; + address_name?: string; + road_address_name?: string; +} +function validKakaoDocument(value: unknown): value is KakaoDocument { + if (!value || typeof value !== 'object') return false; + const document = value as Record; + return ['place_name', 'address_name', 'road_address_name'].every( + (field) => document[field] === undefined || typeof document[field] === 'string', + ); +} +export function readGeocodeEnvironment(): GeocodeOptions { + const env = typeof process === 'undefined' ? {} : process.env; + return { + kakaoRestApiKey: env.KAKAO_REST_API_KEY, + naverClientId: env.NAVER_CLIENT_ID, + naverClientSecret: env.NAVER_CLIENT_SECRET, + }; +} +export function geocodeBindings(bindings?: { + KAKAO_REST_API_KEY?: string; + NAVER_CLIENT_ID?: string; + NAVER_CLIENT_SECRET?: string; +}): GeocodeOptions { + return { + kakaoRestApiKey: bindings?.KAKAO_REST_API_KEY, + naverClientId: bindings?.NAVER_CLIENT_ID, + naverClientSecret: bindings?.NAVER_CLIENT_SECRET, + }; +} +export function validKoreanCoordinates(latitude: number, longitude: number): boolean { + return ( + Number.isFinite(latitude) && + Number.isFinite(longitude) && + latitude >= 33 && + latitude <= 39 && + longitude >= 124 && + longitude <= 132 + ); +} +const normalize = (value: string) => + value + .replace(/특별자치시|특별자치도|특별시|광역시/gu, '') + .replace(/경기도/gu, '경기') + .replace(/충청북도/gu, '충북') + .replace(/충청남도/gu, '충남') + .replace(/전라북도|전북특별자치도/gu, '전북') + .replace(/전라남도/gu, '전남') + .replace(/경상북도/gu, '경북') + .replace(/경상남도/gu, '경남') + .replace(/강원도/gu, '강원') + .replace(/제주도/gu, '제주') + .replace(/[^0-9a-z가-힣]/giu, '') + .toLowerCase(); +function matches(query: string, value: string): boolean { + const tokens = + query + .replace(/^대한민국\s*/u, '') + .match(/[0-9]+(?:-[0-9]+)?|[a-z가-힣]+/giu) + ?.map((token) => (/^\d/u.test(token) ? token : normalize(token))) || []; + const normalized = normalize(value); + return ( + tokens.length > 0 && + tokens.every((token) => + /^\d+(?:-\d+)?$/u.test(token) + ? value.match(/\d+(?:-\d+)?/gu)?.includes(token) + : token.endsWith('역') + ? new RegExp(`${token}(?:[0-9]+호선|$|[^가-힣])`, 'u').test(value) + : normalized.includes(token), + ) + ); +} +function matchesAddress(query: string, address: string): boolean { + // 주소 검색은 숫자 존재뿐 아니라 도로 번호와 건물 번호의 순서도 검증합니다. + return ( + matches(query, address) && + query.match(/\d+(?:-\d+)?/gu)!.join(',') === address.match(/\d+(?:-\d+)?/gu)!.join(',') + ); +} +export async function searchKakaoPlaces( + query: string, + options: GeocodeOptions, + coordinates?: { latitude: number; longitude: number }, +): Promise { + const key = (options.kakaoRestApiKey ?? readGeocodeEnvironment().kakaoRestApiKey)?.trim(); + if (!key) return []; + const endpoint = new URL('https://dapi.kakao.com/v2/local/search/keyword.json'); + endpoint.searchParams.set('query', query); + endpoint.searchParams.set('size', '15'); + if (coordinates) { + endpoint.searchParams.set('x', String(coordinates.longitude)); + endpoint.searchParams.set('y', String(coordinates.latitude)); + endpoint.searchParams.set('radius', '20000'); + endpoint.searchParams.set('sort', 'distance'); + } + const body = await fetchJson<{ documents?: KakaoDocument[] }>(endpoint.toString(), { + fetchImpl: options.fetchImpl, + headers: { Authorization: `KakaoAK ${key}` }, + timeout: options.timeout ?? options.timeoutMs ?? 10000, + }); + return Array.isArray(body.documents) ? body.documents.filter(validKakaoDocument) : []; +} +export async function geocodeLocation( + query: string, + input: GeocodeOptions = {}, +): Promise { + const keyword = query.trim(); + if (!keyword) return null; + const env = readGeocodeEnvironment(); + const options = { + ...input, + kakaoRestApiKey: input.kakaoRestApiKey ?? env.kakaoRestApiKey, + naverClientId: input.naverClientId ?? env.naverClientId, + naverClientSecret: input.naverClientSecret ?? env.naverClientSecret, + }; + const isAddress = /(?:로|길|동|읍|면|리)\s*\d/u.test(keyword); + let documents: KakaoDocument[] = []; + if (options.kakaoRestApiKey?.trim()) { + try { + if (isAddress) { + const endpoint = new URL('https://dapi.kakao.com/v2/local/search/address.json'); + endpoint.searchParams.set('query', keyword); + const body = await fetchJson<{ documents?: KakaoDocument[] }>(endpoint.toString(), { + fetchImpl: options.fetchImpl, + headers: { Authorization: `KakaoAK ${options.kakaoRestApiKey.trim()}` }, + timeout: options.timeout ?? options.timeoutMs ?? 10000, + }); + documents = Array.isArray(body.documents) ? body.documents.filter(validKakaoDocument) : []; + } else documents = await searchKakaoPlaces(keyword, options); + } catch { + // 장소명은 네이버 지역 검색으로 보완하고 주소는 잘못된 장소로 대체하지 않습니다. + } + } + for (const document of documents) { + if (!isAddress && !document.place_name?.trim()) continue; + const latitude = Number(document.y); + const longitude = Number(document.x); + if ( + !validKoreanCoordinates(latitude, longitude) || + !(isAddress + ? matchesAddress(keyword, document.address_name || '') || + matchesAddress(keyword, document.road_address_name || '') + : matches( + keyword, + `${document.place_name} ${document.address_name || ''} ${document.road_address_name || ''}`, + )) + ) + continue; + return { + latitude, + longitude, + formattedAddress: document.road_address_name || document.address_name || null, + }; + } + if (!isAddress && options.naverClientId && options.naverClientSecret) { + try { + const result = await searchNaverLocalPlaces({ + ...options, + keyword, + timeoutMs: options.timeout ?? options.timeoutMs, + }); + const place = result.places.find( + (item) => + item.latitude !== null && + item.longitude !== null && + validKoreanCoordinates(item.latitude, item.longitude) && + matches(keyword, `${item.name} ${item.address} ${item.roadAddress}`), + ); + if (place) + return { + latitude: place.latitude as number, + longitude: place.longitude as number, + formattedAddress: place.roadAddress || place.address || null, + }; + } catch { + return null; + } + } + return null; +} diff --git a/src/utils/http.ts b/src/utils/http.ts index 715d6cf5..9029d867 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -5,6 +5,7 @@ import { diagnosticEvent } from './diagnostics.js'; */ export interface FetchOptions extends RequestInit { + fetchImpl?: typeof fetch; timeout?: number; /** fetchJson에서 허용할 정확한 HTTP 상태입니다. 생략하면 모든 2xx를 허용합니다. */ expectedStatus?: number; @@ -120,6 +121,7 @@ async function requestWithTimeout( ): Promise { const { timeout = 10000, + fetchImpl = fetch, retries = 0, retryDelayMs = 250, retryStatusCodes = DEFAULT_RETRY_STATUS_CODES, @@ -140,7 +142,7 @@ async function requestWithTimeout( let responseStatus: number | undefined; try { - const response = await fetch(url, { + const response = await fetchImpl(url, { ...restOptions, signal: controller.signal, }); diff --git a/tests/api/actionsProxy.test.ts b/tests/api/actionsProxy.test.ts index d6e9a898..6c8f29e6 100644 --- a/tests/api/actionsProxy.test.ts +++ b/tests/api/actionsProxy.test.ts @@ -12,16 +12,16 @@ import { describe('actionsProxy', () => { it('액션 정의를 조회할 수 있다', () => { - expect(getActionQueryDefinition('lottemartFindStores')?.targetPath).toBe('/api/lottemart/stores'); + expect(getActionQueryDefinition('gs25FindStores')?.targetPath).toBe('/api/gs25/stores'); expect(ACTION_QUERY_ACTIONS).toContain('daisoGetProduct'); }); it('일반 GET 액션을 대상 URL로 변환한다', () => { const target = buildActionQueryTargetUrl( - 'https://example.com/api/actions/query?action=lottemartFindStores&area=%EC%84%9C%EC%9A%B8&keyword=%EA%B0%95%EB%B3%80', + 'https://example.com/api/actions/query?action=gs25FindStores&area=%EC%84%9C%EC%9A%B8&keyword=%EA%B0%95%EB%B3%80', ); - expect(target.pathname).toBe('/api/lottemart/stores'); + expect(target.pathname).toBe('/api/gs25/stores'); expect(target.searchParams.get('action')).toBeNull(); expect(target.searchParams.get('area')).toBe('서울'); expect(target.searchParams.get('keyword')).toBe('강변'); @@ -29,10 +29,10 @@ describe('actionsProxy', () => { it('상대 경로 요청도 대상 URL로 변환한다', () => { const target = buildActionQueryTargetUrl( - '/api/actions/query?action=lottemartFindStores&area=%EC%84%9C%EC%9A%B8', + '/api/actions/query?action=gs25FindStores&area=%EC%84%9C%EC%9A%B8', ); - expect(target.pathname).toBe('/api/lottemart/stores'); + expect(target.pathname).toBe('/api/gs25/stores'); expect(target.searchParams.get('area')).toBe('서울'); }); @@ -67,7 +67,7 @@ describe('actionsProxy', () => { it('액션 설명 목록 문자열을 생성한다', () => { const descriptionList = createActionQueryDescriptionList(); - expect(descriptionList).toContain('- `lottemartFindStores`:'); + expect(descriptionList).toContain('- `gs25FindStores`:'); expect(descriptionList).toContain('- `daisoGetProduct`:'); }); }); diff --git a/tests/api/cgv-handlers.test.ts b/tests/api/cgv-handlers.test.ts index 98fb2be5..2b47efa2 100644 --- a/tests/api/cgv-handlers.test.ts +++ b/tests/api/cgv-handlers.test.ts @@ -17,6 +17,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -108,6 +109,7 @@ describe('handleCgvFindTheaters', () => { }); it('keyword가 있으면 가까운 극장을 우선 반환한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( @@ -126,38 +128,18 @@ describe('handleCgvFindTheaters', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: 'CGV 안산', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ); const ctx = createMockContext( { playDate: '20260315', keyword: '안산 중앙역' }, - { GOOGLE_MAPS_API_KEY: 'test-google-key' }, + { KAKAO_REST_API_KEY: 'test-google-key' }, ); await handleCgvFindTheaters(ctx); @@ -196,6 +178,7 @@ describe('handleCgvFindTheaters', () => { }); it('유효한 lat/lng 값은 숫자로 파싱한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( @@ -214,23 +197,13 @@ describe('handleCgvFindTheaters', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ); const ctx = createMockContext( { playDate: '20260315', lat: '37.3171', lng: '126.8389' }, - { GOOGLE_MAPS_API_KEY: 'test-google-key' }, + { KAKAO_REST_API_KEY: 'test-google-key' }, ); await handleCgvFindTheaters(ctx); @@ -350,6 +323,7 @@ describe('handleCgvSearchMovies', () => { }); it('keyword만 있어도 가까운 극장을 골라 영화를 조회한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( @@ -368,32 +342,12 @@ describe('handleCgvSearchMovies', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: 'CGV 안산', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ) .mockResolvedValueOnce( @@ -408,7 +362,7 @@ describe('handleCgvSearchMovies', () => { const ctx = createMockContext( { playDate: '20260315', keyword: '안산 중앙역' }, - { GOOGLE_MAPS_API_KEY: 'test-google-key' }, + { KAKAO_REST_API_KEY: 'test-google-key' }, ); await handleCgvSearchMovies(ctx); @@ -439,12 +393,7 @@ describe('handleCgvSearchMovies', () => { const ctx = createMockContext({ playDate: '20260315', keyword: '안산 중앙역' }); await handleCgvSearchMovies(ctx); - const payload = (ctx.json as ReturnType).mock.calls[0][0] as { - data: { filters: { theaterCode: string | null }; resolvedTheater: null; movies: [] }; - }; - expect(payload.data.filters.theaterCode).toBeNull(); - expect(payload.data.resolvedTheater).toBeNull(); - expect(payload.data.movies).toEqual([]); + expect(ctx.json).toHaveBeenCalledWith(expect.objectContaining({ success: false, error: expect.objectContaining({ message: expect.stringContaining('위치를 좌표로 변환하지 못했습니다') }) }), 500); }); it('CGV 영화 조회 중 비 Error 예외는 기본 메시지로 처리한다', async () => { @@ -724,6 +673,7 @@ describe('handleCgvGetTimetable', () => { }); it('keyword만 있어도 가까운 극장을 골라 시간표를 조회한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( @@ -742,32 +692,12 @@ describe('handleCgvGetTimetable', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: 'CGV 안산', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ) .mockResolvedValueOnce( @@ -795,7 +725,7 @@ describe('handleCgvGetTimetable', () => { const ctx = createMockContext( { playDate: '20260315', keyword: '안산 중앙역' }, - { GOOGLE_MAPS_API_KEY: 'test-google-key' }, + { KAKAO_REST_API_KEY: 'test-google-key' }, ); await handleCgvGetTimetable(ctx); @@ -826,12 +756,7 @@ describe('handleCgvGetTimetable', () => { const ctx = createMockContext({ playDate: '20260315', keyword: '안산 중앙역' }); await handleCgvGetTimetable(ctx); - const payload = (ctx.json as ReturnType).mock.calls[0][0] as { - data: { filters: { theaterCode: string | null }; resolvedTheater: null; timetable: [] }; - }; - expect(payload.data.filters.theaterCode).toBeNull(); - expect(payload.data.resolvedTheater).toBeNull(); - expect(payload.data.timetable).toEqual([]); + expect(ctx.json).toHaveBeenCalledWith(expect.objectContaining({ success: false, error: expect.objectContaining({ message: expect.stringContaining('위치를 좌표로 변환하지 못했습니다') }) }), 500); }); it('CGV 시간표 조회 중 비 Error 예외는 기본 메시지로 처리한다', async () => { @@ -870,3 +795,9 @@ describe('handleCgvGetTimetable', () => { ); }); }); +it.each([handleCgvSearchMovies, handleCgvGetTimetable])('위치가 유효해도 공식 극장이 없으면 목록을 비운다', async (handler) => { + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ statusCode: 0, data: [] }))); + const ctx = createMockContext({ lat: '35.115', lng: '129.041', playDate: '20261005' }); + await handler(ctx); + expect(ctx.json).toHaveBeenCalledWith(expect.objectContaining({ success: true, data: expect.objectContaining({ resolvedTheater: null }) })); +}); diff --git a/tests/api/cu-handlers.test.ts b/tests/api/cu-handlers.test.ts index 20521922..80b86ab0 100644 --- a/tests/api/cu-handlers.test.ts +++ b/tests/api/cu-handlers.test.ts @@ -13,6 +13,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -441,6 +442,7 @@ describe('handleCuCheckInventory', () => { }); it('storeKeyword 지오코딩 성공 시 좌표 기반으로 재조회한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] }))) .mockResolvedValueOnce( @@ -478,22 +480,19 @@ describe('handleCuCheckInventory', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.3172, lng: 126.8354 } } }], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '경기도 안산시 단원구 중앙대로 885', y: 37.3172, x: 126.8354 }] }), ), ) .mockResolvedValueOnce(new Response(JSON.stringify({ totalCnt: 0, storeList: [] }))); const ctx = createMockContextWithEnv( { keyword: '치킨', storeKeyword: '안산 중앙역' }, - { GOOGLE_MAPS_API_KEY: 'test-google-key' }, + { KAKAO_REST_API_KEY: 'test-google-key' }, ); await handleCuCheckInventory(ctx); const geocodeUrl = String(mockFetch.mock.calls[3][0]); - expect(geocodeUrl).toContain('maps.googleapis.com/maps/api/geocode/json'); + expect(geocodeUrl).toContain('dapi.kakao.com/v2/local/search/address.json'); const requestInit = mockFetch.mock.calls[4][1] as RequestInit; const body = JSON.parse(String(requestInit.body)); expect(body.latVal).toBe('37.3172'); @@ -501,6 +500,7 @@ describe('handleCuCheckInventory', () => { }); it('지오코딩 성공 + 재고 시드 없음이면 기본 페이지 타입으로 재조회한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] }))) .mockResolvedValueOnce( @@ -525,17 +525,14 @@ describe('handleCuCheckInventory', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.3172, lng: 126.8354 } } }], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '경기도 안산시 단원구 중앙대로 885', y: 37.3172, x: 126.8354 }] }), ), ) .mockResolvedValueOnce(new Response(JSON.stringify({ totalCnt: 0, storeList: [] }))); const ctx = createMockContextWithEnv( { keyword: '치킨', storeKeyword: '안산 중앙역' }, - { GOOGLE_MAPS_API_KEY: 'test-google-key' }, + { KAKAO_REST_API_KEY: 'test-google-key' }, ); await handleCuCheckInventory(ctx); diff --git a/tests/api/gs25-handlers.test.ts b/tests/api/gs25-handlers.test.ts index 998562ef..a64c7e95 100644 --- a/tests/api/gs25-handlers.test.ts +++ b/tests/api/gs25-handlers.test.ts @@ -19,6 +19,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -123,16 +124,14 @@ describe('handleGs25FindStores', () => { }); it('좌표가 없고 keyword가 있으면 지오코딩을 시도한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); const ctx = createMockContext({ keyword: '강남' }); - (ctx as { env: Record }).env = { GOOGLE_MAPS_API_KEY: 'test-google-key' }; + (ctx as { env: Record }).env = { KAKAO_REST_API_KEY: 'test-google-key' }; mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.5, lng: 127 } } }], - }), + JSON.stringify({ documents: [{ place_name: '강남역', address_name: '서울 강남구', y: 37.5, x: 127 }] }), ), ) .mockResolvedValueOnce( @@ -330,8 +329,9 @@ describe('handleGs25CheckInventory', () => { }); it('itemCode만으로도 재고 검색 결과를 반환한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); const ctx = createMockContext({ itemCode: '8801056038861', storeKeyword: '안산 중앙역' }); - (ctx as { env: Record }).env = { GOOGLE_MAPS_API_KEY: 'test-google-key' }; + (ctx as { env: Record }).env = { KAKAO_REST_API_KEY: 'test-google-key' }; mockFetch .mockResolvedValueOnce( @@ -349,10 +349,7 @@ describe('handleGs25CheckInventory', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.3187, lng: 126.8389 } } }], - }), + JSON.stringify({ documents: [{ place_name: '강남역', address_name: '서울 강남구', y: 37.3187, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( @@ -449,16 +446,14 @@ describe('handleGs25CheckInventory', () => { }); it('storeKeyword를 먼저 직접 지오코딩해 재고 조회 좌표로 사용한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); const ctx = createMockContext({ keyword: '오감자', storeKeyword: '강남' }); - (ctx as { env: Record }).env = { GOOGLE_MAPS_API_KEY: 'test-google-key' }; + (ctx as { env: Record }).env = { KAKAO_REST_API_KEY: 'test-google-key' }; mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.5, lng: 127 } } }], - }), + JSON.stringify({ documents: [{ place_name: '강남역', address_name: '서울 강남구', y: 37.5, x: 127 }] }), ), ) .mockResolvedValueOnce( @@ -494,14 +489,15 @@ describe('handleGs25CheckInventory', () => { }; expect(payload.data.geocodeUsed).toBe(true); expect(payload.data.location).toEqual({ latitude: 37.5, longitude: 127 }); - expect(String(mockFetch.mock.calls[0][0])).toContain('maps.googleapis.com'); + expect(String(mockFetch.mock.calls[0][0])).toContain('dapi.kakao.com'); expect(String(mockFetch.mock.calls[1][0])).toContain('/search/v3/totalSearch'); expect(String(mockFetch.mock.calls[2][0])).toContain('myPositionYCoordination=37.5'); }); it('storeKeyword가 있어도 지오코딩 실패 시 location은 null이다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); const ctx = createMockContext({ keyword: '오감자', storeKeyword: '강남' }); - (ctx as { env: Record }).env = { GOOGLE_MAPS_API_KEY: 'test-google-key' }; + (ctx as { env: Record }).env = { KAKAO_REST_API_KEY: 'test-google-key' }; mockFetch // 1. storeKeyword 기준 매장 조회 (지오코딩 주소 획득용) @@ -513,7 +509,7 @@ describe('handleGs25CheckInventory', () => { ), ) // 2. 지오코딩 실패 - .mockResolvedValueOnce(new Response(JSON.stringify({ status: 'ZERO_RESULTS', results: [] }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ documents: [] }))) // 3. totalSearch API (keyword → itemCode) .mockResolvedValueOnce( new Response( diff --git a/tests/api/health-checks.test.ts b/tests/api/health-checks.test.ts index fd592b5b..9cfa6094 100644 --- a/tests/api/health-checks.test.ts +++ b/tests/api/health-checks.test.ts @@ -112,7 +112,7 @@ describe('runHealthChecks', () => { expect.any(Object), ); expect(fetchImpl).toHaveBeenCalledWith( - expect.stringContaining('/api/lottemart/products?'), + expect.stringContaining('/api/gs25/products?'), expect.any(Object), ); const oliveyoungCall = fetchImpl.mock.calls.find((call) => diff --git a/tests/api/lottecinema-handlers.test.ts b/tests/api/lottecinema-handlers.test.ts index 9e1a1df4..30bece29 100644 --- a/tests/api/lottecinema-handlers.test.ts +++ b/tests/api/lottecinema-handlers.test.ts @@ -19,6 +19,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); __testOnlyClearLotteCinemaLocationCaches(); }); @@ -96,20 +97,11 @@ describe('handleLotteCinemaFindNearbyTheaters', () => { }); it('위치 키워드로 주변 지점을 반환한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ) .mockResolvedValueOnce( @@ -137,7 +129,7 @@ describe('handleLotteCinemaFindNearbyTheaters', () => { ); const ctx = createMockContext({ keyword: '안산 중앙역' }); - ctx.env = { GOOGLE_MAPS_API_KEY: 'test-google-key' }; + ctx.env = { KAKAO_REST_API_KEY: 'test-google-key' }; await handleLotteCinemaFindNearbyTheaters(ctx); expect(ctx.json).toHaveBeenCalledWith( @@ -194,20 +186,11 @@ describe('handleLotteCinemaListNowShowing', () => { }); it('위치 키워드로 최근접 극장을 찾아 영화/회차 목록을 반환한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ) .mockResolvedValueOnce( @@ -291,7 +274,7 @@ describe('handleLotteCinemaListNowShowing', () => { ); const ctx = createMockContext({ playDate: '20260310', keyword: '안산 중앙역', movieId: '23816' }); - ctx.env = { GOOGLE_MAPS_API_KEY: 'test-google-key' }; + ctx.env = { KAKAO_REST_API_KEY: 'test-google-key' }; await handleLotteCinemaListNowShowing(ctx); expect(ctx.json).toHaveBeenCalledWith( @@ -474,15 +457,6 @@ describe('handleLotteCinemaGetRemainingSeats', () => { const ctx = createMockContext({ playDate: '20260310', keyword: '제주공항', movieId: '23816' }); await handleLotteCinemaGetRemainingSeats(ctx); - expect(ctx.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: true, - data: expect.objectContaining({ - filters: expect.objectContaining({ theaterId: null, keyword: '제주공항' }), - resolvedTheater: null, - seats: [], - }), - }), - ); + expect(ctx.json).toHaveBeenCalledWith(expect.objectContaining({ success: false, error: expect.objectContaining({ message: expect.stringContaining('위치를 좌표로 변환하지 못했습니다') }) }), 500); }); }); diff --git a/tests/api/lottemart-handlers.test.ts b/tests/api/lottemart-handlers.test.ts index b653b769..9a46403c 100644 --- a/tests/api/lottemart-handlers.test.ts +++ b/tests/api/lottemart-handlers.test.ts @@ -1,487 +1,11 @@ -/** - * 롯데마트 API 핸들러 테스트 - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as lotteMartClient from '../../src/services/lottemart/client.js'; -import { __testOnlyClearLotteMartCaches } from '../../src/services/lottemart/client.js'; -import * as lotteMartDebug from '../../src/services/lottemart/debug.js'; -import { - handleLotteMartDebug, - handleLotteMartFindStores, - handleLotteMartSearchProducts, -} from '../../src/api/lottemartHandlers.js'; - -const mockFetch = vi.fn(); -const createSessionResponse = () => - new Response('', { headers: { 'set-cookie': 'ASPSESSIONID=TEST; path=/' } }); - -beforeEach(() => { - mockFetch.mockReset(); - __testOnlyClearLotteMartCaches(); - vi.stubGlobal('fetch', mockFetch); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -function createMockContext(query: Record = {}) { - return { - env: { - GOOGLE_MAPS_API_KEY: 'test-key', - }, - req: { - query: (key: string) => query[key], - param: () => undefined, - }, - json: vi.fn().mockImplementation((data, status) => ({ - data, - status: status || 200, - })), - } as unknown as Parameters[0]; -} - -describe('handleLotteMartFindStores', () => { - it('area와 brandVariant가 없으면 null로 응답한다', async () => { - mockFetch.mockImplementation(() => { - return Promise.resolve( - new Response(` -
-
    -
  • -
    공통점
    -
    -
      -
    • 주소 : 서울 중구 한강대로 405
    • -
    • 상담전화 : 02-3424-2502
    • -
    -
    - -
  • -
-
- `), - ); - }); - - const ctx = createMockContext({ limit: '1' }); - await handleLotteMartFindStores(ctx); - - expect(ctx.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: true, - data: expect.objectContaining({ - area: null, - brandVariant: null, - }), - }), - ); - }); - - it('매장 검색 결과를 반환한다', async () => { - mockFetch - .mockResolvedValueOnce(createSessionResponse()) - .mockResolvedValueOnce( - new Response(` -
-
    -
  • -
    강변점
    -
    -
      -
    • 주소 : 서울 광진구 광나루로 56길 85
    • -
    • 상담전화 : 02-3424-2502
    • -
    -
    - -
  • -
-
- `), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.5354, lng: 127.0958 } } }], - }), - ), - ); - - const ctx = createMockContext({ area: '서울', lat: '37.5354', lng: '127.0958' }); - await handleLotteMartFindStores(ctx); - - expect(ctx.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: true, - data: expect.objectContaining({ - stores: expect.any(Array), - }), - }), - ); - }); - - it('예외 발생 시 에러를 반환한다', async () => { - mockFetch.mockRejectedValue(new Error('store fail')); - - const ctx = createMockContext({ area: '잘못된지역' }); - await handleLotteMartFindStores(ctx); - - expect(ctx.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: false, - error: { - code: 'LOTTEMART_STORE_SEARCH_FAILED', - message: '지원하지 않는 지역입니다: 잘못된지역', - }, - }), - 500, - ); - }); - - it('알 수 없는 예외는 기본 메시지로 감싼다', async () => { - vi.spyOn(lotteMartClient, 'fetchLotteMartStores').mockRejectedValueOnce(undefined); - - const ctx = createMockContext({ area: '서울' }); - await handleLotteMartFindStores(ctx); - - expect(ctx.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: false, - error: { - code: 'LOTTEMART_STORE_SEARCH_FAILED', - message: '알 수 없는 오류가 발생했습니다.', - }, - }), - 500, - ); - }); -}); - -describe('handleLotteMartSearchProducts', () => { - it('keyword가 없으면 에러를 반환한다', async () => { - const ctx = createMockContext({}); - await handleLotteMartSearchProducts(ctx); - - expect(ctx.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: false, - error: { code: 'MISSING_QUERY', message: '검색어(keyword)를 입력해주세요.' }, - }), - 400, - ); - }); - - it('store 정보가 없으면 에러를 반환한다', async () => { - const ctx = createMockContext({ keyword: '콜라' }); - await handleLotteMartSearchProducts(ctx); - - expect(ctx.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: false, - error: { - code: 'MISSING_STORE', - message: 'storeCode 또는 storeName 중 하나를 입력해주세요.', - }, - }), - 400, - ); - }); - - it('상품 검색 결과를 반환한다', async () => { - mockFetch - .mockResolvedValueOnce(createSessionResponse()) - .mockResolvedValueOnce(new Response('')) - .mockResolvedValueOnce( - new Response(` - -
검색결과 : 1건
- -
    -
  • -
    -
    코카콜라
    -
    1.2L
    -
    -
    -
    -
    코카콜라
    - - - - -
    ㆍ제조사 :코카콜라음료 주식회사
    ㆍ가격 : 2,980 원
    ㆍ재고 : 20 개
    -
    닫기
    -
    -
    -
  • -
- `), - ); - - const ctx = createMockContext({ area: '서울', storeName: '강변점', keyword: '콜라' }); - await handleLotteMartSearchProducts(ctx); - - expect(ctx.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: true, - data: expect.objectContaining({ - storeName: '강변점', - products: expect.any(Array), - }), - }), - ); - }); - - it('timeoutMs와 source 쿼리를 상품 검색 요청에 전달한다', async () => { - const searchSpy = vi.spyOn(lotteMartClient, 'searchLotteMartProducts').mockResolvedValueOnce({ - area: '서울', - storeCode: '2301', - storeName: '강변점', - totalCount: 0, - totalPages: 1, - products: [], - }); - - const ctx = createMockContext({ - area: '서울', - storeCode: '2301', - keyword: '콜라', - timeoutMs: '1234', - source: 'zetta', - }); - await handleLotteMartSearchProducts(ctx); - - expect(searchSpy).toHaveBeenCalledWith( - expect.objectContaining({ - area: '서울', - storeCode: '2301', - keyword: '콜라', - source: 'zetta', - }), - expect.objectContaining({ - timeout: 1234, - }), - ); - }); - - it('지원하지 않는 source는 400을 반환한다', async () => { - const ctx = createMockContext({ - area: '서울', - storeCode: '2301', - keyword: '콜라', - source: 'bad', - }); - await handleLotteMartSearchProducts(ctx); - - expect(ctx.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: false, - error: { - code: 'INVALID_LOTTEMART_SOURCE', - message: 'source는 auto, legacy, zetta 중 하나여야 합니다.', - }, - }), - 400, - ); - }); - - it('timeoutMs 쿼리가 유효하지 않으면 기본 제한 시간을 사용한다', async () => { - const searchSpy = vi.spyOn(lotteMartClient, 'searchLotteMartProducts').mockResolvedValueOnce({ - area: '서울', - storeCode: '2301', - storeName: '강변점', - totalCount: 0, - totalPages: 1, - products: [], - }); - - const ctx = createMockContext({ - area: '서울', - storeCode: '2301', - keyword: '콜라', - timeoutMs: '0', - }); - await handleLotteMartSearchProducts(ctx); - - expect(searchSpy).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ - timeout: 15000, - }), - ); - }); - - it('알 수 없는 예외는 기본 메시지로 감싼다', async () => { - vi.spyOn(lotteMartClient, 'searchLotteMartProducts').mockRejectedValueOnce(undefined); - - const ctx = createMockContext({ area: '서울', storeCode: '2301', keyword: '콜라' }); - await handleLotteMartSearchProducts(ctx); - - expect(ctx.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: false, - error: { - code: 'LOTTEMART_PRODUCT_SEARCH_FAILED', - message: '알 수 없는 오류가 발생했습니다.', - }, - }), - 500, - ); - }); - - it('Error 예외는 메시지를 그대로 반환한다', async () => { - vi.spyOn(lotteMartClient, 'searchLotteMartProducts').mockRejectedValueOnce( - new Error('product fail'), - ); - - const ctx = createMockContext({ area: '서울', storeCode: '2301', keyword: '콜라' }); - await handleLotteMartSearchProducts(ctx); - - expect(ctx.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: false, - error: { - code: 'LOTTEMART_PRODUCT_SEARCH_FAILED', - message: 'product fail', - }, - }), - 500, - ); - }); -}); - -describe('handleLotteMartDebug', () => { - it('target이 잘못되면 에러를 반환한다', async () => { - const ctx = createMockContext({ target: 'invalid' }); - await handleLotteMartDebug(ctx); - - expect(ctx.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: false, - error: { - code: 'INVALID_TARGET', - message: 'target은 market-options, stores, products, product-page 중 하나여야 합니다.', - }, - }), - 400, - ); - }); - - it('target이 없으면 에러를 반환한다', async () => { - const ctx = createMockContext({}); - await handleLotteMartDebug(ctx); - - expect(ctx.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: false, - error: { - code: 'INVALID_TARGET', - message: 'target은 market-options, stores, products, product-page 중 하나여야 합니다.', - }, - }), - 400, - ); - }); - - it('진단 결과를 반환한다', async () => { - vi.spyOn(lotteMartDebug, 'probeLotteMartUpstream').mockResolvedValueOnce({ - request: { - target: 'stores', - method: 'POST', - url: 'https://company.lottemart.com/mobiledowa/market/search_shop.asp', - bodyText: 'm_area=4401', - timeout: 15000, - hasZyteApiKey: false, - }, - attempts: [ - { - used: 'direct', - success: false, - status: null, - statusText: null, - error: 'The operation was aborted', - bodyPreview: null, - sessionCookie: null, - }, - ], - }); - - const ctx = createMockContext({ target: 'stores', area: '경기' }); - await handleLotteMartDebug(ctx); - - expect(ctx.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: true, - data: expect.objectContaining({ - request: expect.objectContaining({ target: 'stores' }), - attempts: expect.any(Array), - }), - }), - ); - }); - - it('page와 timeoutMs를 숫자로 변환해 전달한다', async () => { - const probeSpy = vi.spyOn(lotteMartDebug, 'probeLotteMartUpstream').mockResolvedValueOnce({ - request: { - target: 'product-page', - method: 'GET', - url: 'https://company.lottemart.com/mobiledowa/inc/asp/search_product_list.asp', - bodyText: null, - timeout: 1234, - hasZyteApiKey: false, - }, - attempts: [], - }); - - const ctx = createMockContext({ target: 'product-page', page: '7', timeoutMs: '1234' }); - await handleLotteMartDebug(ctx); - - expect(probeSpy).toHaveBeenCalledWith( - expect.objectContaining({ - target: 'product-page', - page: 7, - timeout: 1234, - }), - ); - }); - - it('예외 발생 시 debug 에러를 반환한다', async () => { - vi.spyOn(lotteMartDebug, 'probeLotteMartUpstream').mockRejectedValueOnce( - new Error('debug fail'), - ); - - const ctx = createMockContext({ target: 'stores' }); - await handleLotteMartDebug(ctx); - - expect(ctx.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: false, - error: { - code: 'LOTTEMART_DEBUG_FAILED', - message: 'debug fail', - }, - }), - 500, - ); - }); - - it('알 수 없는 debug 예외는 기본 메시지로 감싼다', async () => { - vi.spyOn(lotteMartDebug, 'probeLotteMartUpstream').mockRejectedValueOnce(undefined); - - const ctx = createMockContext({ target: 'stores' }); - await handleLotteMartDebug(ctx); - - expect(ctx.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: false, - error: { - code: 'LOTTEMART_DEBUG_FAILED', - message: '알 수 없는 오류가 발생했습니다.', - }, - }), - 500, - ); - }); +/** 롯데마트 종료 핸들러 테스트 */ +import { afterEach, expect, it, vi } from 'vitest'; +import { handleLotteMartDebug, handleLotteMartFindStores, handleLotteMartSearchProducts } from '../../src/api/lottemartHandlers.js'; +afterEach(() => vi.unstubAllGlobals()); +it.each([handleLotteMartDebug, handleLotteMartFindStores, handleLotteMartSearchProducts])('옛 핸들러는 입력과 무관하게 업스트림 없이 410을 반환한다', async (handler) => { + const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); + const json = vi.fn(); + await handler({ json } as unknown as Parameters[0]); + expect(json).toHaveBeenCalledWith(expect.objectContaining({ success: false, error: { code: 'SERVICE_RETIRED', message: '롯데마트 서비스 지원이 종료되었습니다.' } }), 410); + expect(fetchMock).not.toHaveBeenCalled(); }); diff --git a/tests/api/megabox-handlers.test.ts b/tests/api/megabox-handlers.test.ts index fa8922d2..3cac2135 100644 --- a/tests/api/megabox-handlers.test.ts +++ b/tests/api/megabox-handlers.test.ts @@ -19,12 +19,13 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); function createMockContext(query: Record = {}) { return { - env: { GOOGLE_MAPS_API_KEY: 'test-google-key' }, + env: { KAKAO_REST_API_KEY: 'test-google-key' }, req: { query: (key: string) => query[key], param: () => undefined, @@ -155,20 +156,11 @@ describe('handleMegaboxFindNearbyTheaters', () => { }); it('keyword로 지오코드한 위치를 기준으로 안산 지점을 찾는다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( @@ -290,20 +282,11 @@ describe('handleMegaboxListNowShowing', () => { }); it('theaterId가 없어도 keyword 기준 가까운 지점을 자동 선택한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( @@ -513,20 +496,11 @@ describe('handleMegaboxGetRemainingSeats', () => { }); it('theaterId가 없어도 keyword 기준 가까운 지점 좌석을 조회한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( diff --git a/tests/app/app-api-actions.test.ts b/tests/app/app-api-actions.test.ts index c7a24c2a..26e1a25f 100644 --- a/tests/app/app-api-actions.test.ts +++ b/tests/app/app-api-actions.test.ts @@ -16,6 +16,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); __testOnlyClearLotteCinemaLocationCaches(); }); @@ -66,6 +67,7 @@ describe('GET /api/actions/query', () => { }); it('GS25 2단계 재고 조회를 action facade로 위임한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( @@ -105,10 +107,7 @@ describe('GET /api/actions/query', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.3187, lng: 126.8389 } } }], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '', y: 37.3187, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( @@ -137,7 +136,7 @@ describe('GET /api/actions/query', () => { const inventoryRes = await app.request( '/api/actions/query?action=gs25CheckInventory&itemCode=8801056038861&storeKeyword=%EC%95%88%EC%82%B0%20%EC%A4%91%EC%95%99%EC%97%AD&storeLimit=10', undefined, - { GOOGLE_MAPS_API_KEY: 'test-google-key' }, + { KAKAO_REST_API_KEY: 'test-google-key' }, ); expect(inventoryRes.status).toBe(200); @@ -287,6 +286,7 @@ describe('GET /api/actions/query', () => { }); it('CGV keyword 조회를 action facade로 위임한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( @@ -305,32 +305,12 @@ describe('GET /api/actions/query', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: 'CGV 안산', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ) .mockResolvedValueOnce( @@ -347,7 +327,7 @@ describe('GET /api/actions/query', () => { '/api/actions/query?action=cgvSearchMovies&playDate=20260315&keyword=안산%20중앙역', undefined, { - GOOGLE_MAPS_API_KEY: 'test-google-key', + KAKAO_REST_API_KEY: 'test-google-key', }, ); expect(res.status).toBe(200); @@ -358,20 +338,11 @@ describe('GET /api/actions/query', () => { }); it('롯데시네마 keyword 조회를 action facade로 위임한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ) .mockResolvedValueOnce( @@ -458,7 +429,7 @@ describe('GET /api/actions/query', () => { '/api/actions/query?action=lottecinemaListMovies&playDate=20260315&keyword=안산%20중앙역&movieId=23816', undefined, { - GOOGLE_MAPS_API_KEY: 'test-google-key', + KAKAO_REST_API_KEY: 'test-google-key', }, ); expect(res.status).toBe(200); diff --git a/tests/app/app-api-cgv.keyword.test.ts b/tests/app/app-api-cgv.keyword.test.ts index bf8d1de1..8b3d4ce0 100644 --- a/tests/app/app-api-cgv.keyword.test.ts +++ b/tests/app/app-api-cgv.keyword.test.ts @@ -16,6 +16,7 @@ beforeEach(() => { describe('GET /api/cgv/movies keyword normalization', () => { it('복합 요청 문장에서도 안산 극장을 선택한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( @@ -37,32 +38,12 @@ describe('GET /api/cgv/movies keyword normalization', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구 고잔동', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: 'CGV 안산', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ) .mockResolvedValueOnce( @@ -79,7 +60,7 @@ describe('GET /api/cgv/movies keyword normalization', () => { 'CGV 안산 중앙역 근처 극장 찾고 오늘 하는 영화 목록 찾고 남은 좌석수 찾아주세요', ); const res = await app.request(`/api/cgv/movies?playDate=20260315&keyword=${keyword}`, undefined, { - GOOGLE_MAPS_API_KEY: 'test-google-key', + KAKAO_REST_API_KEY: 'test-google-key', }); expect(res.status).toBe(200); diff --git a/tests/app/app-api-cgv.test.ts b/tests/app/app-api-cgv.test.ts index 86b03278..c62909b7 100644 --- a/tests/app/app-api-cgv.test.ts +++ b/tests/app/app-api-cgv.test.ts @@ -41,6 +41,7 @@ describe('GET /api/cgv/theaters', () => { }); it('keyword로 가까운 CGV 극장을 찾는다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( @@ -59,37 +60,17 @@ describe('GET /api/cgv/theaters', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: 'CGV 안산', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ); const res = await app.request('/api/cgv/theaters?playDate=20260315&keyword=안산%20중앙역', undefined, { - GOOGLE_MAPS_API_KEY: 'test-google-key', + KAKAO_REST_API_KEY: 'test-google-key', }); expect(res.status).toBe(200); @@ -120,6 +101,7 @@ describe('GET /api/cgv/movies', () => { }); it('keyword만으로 가까운 극장의 영화 목록을 조회한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( @@ -138,32 +120,12 @@ describe('GET /api/cgv/movies', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: 'CGV 안산', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ) .mockResolvedValueOnce( @@ -177,7 +139,7 @@ describe('GET /api/cgv/movies', () => { ); const res = await app.request('/api/cgv/movies?playDate=20260315&keyword=안산%20중앙역', undefined, { - GOOGLE_MAPS_API_KEY: 'test-google-key', + KAKAO_REST_API_KEY: 'test-google-key', }); expect(res.status).toBe(200); @@ -221,6 +183,7 @@ describe('GET /api/cgv/timetable', () => { }); it('keyword만으로 가까운 극장의 시간표를 조회한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( @@ -239,32 +202,12 @@ describe('GET /api/cgv/timetable', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: 'CGV 안산', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ) .mockResolvedValueOnce( @@ -291,7 +234,7 @@ describe('GET /api/cgv/timetable', () => { ); const res = await app.request('/api/cgv/timetable?playDate=20260315&keyword=안산%20중앙역', undefined, { - GOOGLE_MAPS_API_KEY: 'test-google-key', + KAKAO_REST_API_KEY: 'test-google-key', }); expect(res.status).toBe(200); diff --git a/tests/app/app-api-gs25.test.ts b/tests/app/app-api-gs25.test.ts index d1a8bde8..85e99d18 100644 --- a/tests/app/app-api-gs25.test.ts +++ b/tests/app/app-api-gs25.test.ts @@ -114,6 +114,7 @@ describe('GET /api/gs25/inventory', () => { }); it('itemCode만으로도 GS25 재고 검색 결과를 반환한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( @@ -130,10 +131,7 @@ describe('GET /api/gs25/inventory', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.3187, lng: 126.8389 } } }], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '', y: 37.3187, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( @@ -154,7 +152,7 @@ describe('GET /api/gs25/inventory', () => { const res = await app.request( '/api/gs25/inventory?itemCode=8801056038861&storeKeyword=%EC%95%88%EC%82%B0%20%EC%A4%91%EC%95%99%EC%97%AD', undefined, - { GOOGLE_MAPS_API_KEY: 'test-google-key' }, + { KAKAO_REST_API_KEY: 'test-google-key' }, ); expect(res.status).toBe(200); diff --git a/tests/app/app-api-lottecinema.test.ts b/tests/app/app-api-lottecinema.test.ts index 63748f05..243e94ed 100644 --- a/tests/app/app-api-lottecinema.test.ts +++ b/tests/app/app-api-lottecinema.test.ts @@ -15,6 +15,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); __testOnlyClearLotteCinemaLocationCaches(); }); @@ -53,20 +54,11 @@ describe('GET /api/lottecinema/theaters', () => { }); it('위치 키워드로도 주변 지점을 반환한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ) .mockResolvedValueOnce( @@ -94,7 +86,7 @@ describe('GET /api/lottecinema/theaters', () => { ); const res = await app.request('/api/lottecinema/theaters?keyword=안산%20중앙역', undefined, { - GOOGLE_MAPS_API_KEY: 'test-google-key', + KAKAO_REST_API_KEY: 'test-google-key', }); expect(res.status).toBe(200); @@ -170,20 +162,11 @@ describe('GET /api/lottecinema/movies', () => { }); it('위치 키워드로 최근접 극장을 찾아 영화/회차를 반환한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ) .mockResolvedValueOnce( @@ -267,7 +250,7 @@ describe('GET /api/lottecinema/movies', () => { ); const res = await app.request('/api/lottecinema/movies?playDate=20260310&keyword=안산%20중앙역&movieId=23816', undefined, { - GOOGLE_MAPS_API_KEY: 'test-google-key', + KAKAO_REST_API_KEY: 'test-google-key', }); expect(res.status).toBe(200); diff --git a/tests/app/app-api-lottemart.test.ts b/tests/app/app-api-lottemart.test.ts index 09bf62fe..2b058fab 100644 --- a/tests/app/app-api-lottemart.test.ts +++ b/tests/app/app-api-lottemart.test.ts @@ -1,121 +1,8 @@ -/** - * 앱 통합 테스트 - 롯데마트 API - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest'; +/** 롯데마트 종료 경로 테스트 */ +import { expect, it } from 'vitest'; import app from '../../src/index.js'; -import { __testOnlyClearLotteMartCaches } from '../../src/services/lottemart/client.js'; -import { setupFetchMock } from './testHelpers.js'; - -const mockFetch = vi.fn(); -const createSessionResponse = () => new Response('', { headers: { 'set-cookie': 'ASPSESSIONID=TEST; path=/' } }); -setupFetchMock(mockFetch); - -beforeEach(() => { - __testOnlyClearLotteMartCaches(); -}); - -describe('GET /api/lottemart/stores', () => { - it('롯데마트 매장 검색 결과를 반환한다', async () => { - mockFetch - .mockResolvedValueOnce(createSessionResponse()) - .mockResolvedValueOnce( - new Response(` -
-
    -
  • -
    강변점
    -
    -
      -
    • 주소 : 서울 광진구 광나루로 56길 85
    • -
    • 상담전화 : 02-3424-2502
    • -
    -
    - -
  • -
-
- `), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.5354, lng: 127.0958 } } }], - }), - ), - ); - - const res = await app.request('/api/lottemart/stores?area=서울&lat=37.5354&lng=127.0958', undefined, { - GOOGLE_MAPS_API_KEY: 'test-key', - }); - expect(res.status).toBe(200); - - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.data.stores).toHaveLength(1); - }); -}); - -describe('GET /api/lottemart/products', () => { - it('롯데마트 상품 검색 결과를 반환한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('')) - .mockResolvedValueOnce( - new Response(` - -
검색결과 : 1건
- -
    -
  • -
    -
    코카콜라
    -
    1.2L
    -
    -
    -
    -
    코카콜라
    - - - - -
    ㆍ제조사 :코카콜라음료 주식회사
    ㆍ가격 : 2,980 원
    ㆍ재고 : 20 개
    -
    닫기
    -
    -
    -
  • -
- `), - ); - - const res = await app.request('/api/lottemart/products?area=서울&storeName=강변점&keyword=콜라'); - expect(res.status).toBe(200); - - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.data.products).toHaveLength(1); - }); - - it('매장 정보가 없으면 에러를 반환한다', async () => { - const res = await app.request('/api/lottemart/products?keyword=콜라'); - expect(res.status).toBe(400); - - const data = await res.json(); - expect(data.success).toBe(false); - expect(data.error.code).toBe('MISSING_STORE'); - }); -}); - -describe('GET /api/lottemart/debug', () => { - it('롯데마트 단독 진단 결과를 반환한다', async () => { - mockFetch.mockResolvedValue(new Response('')); - - const res = await app.request('/api/lottemart/debug?target=market-options&area=%EA%B2%BD%EA%B8%B0&type=1'); - expect(res.status).toBe(200); - - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.data.request.target).toBe('market-options'); - expect(data.data.attempts[0].used).toBe('direct'); - }); +it.each(['stores', 'products', 'debug'])('옛 경로 %s는 지원 종료를 반환한다', async (endpoint) => { + const response = await app.request(`/api/lottemart/${endpoint}?keyword=콜라&storeCode=123`); + expect(response.status).toBe(410); + expect(await response.json()).toMatchObject({ error: { code: 'SERVICE_RETIRED' } }); }); diff --git a/tests/app/app-api-megabox.test.ts b/tests/app/app-api-megabox.test.ts index 51725d5f..f7ac6d77 100644 --- a/tests/app/app-api-megabox.test.ts +++ b/tests/app/app-api-megabox.test.ts @@ -33,20 +33,11 @@ describe('GET /api/megabox/theaters', () => { }); it('keyword로 지오코드한 위치를 기준으로 메가박스 지점을 반환한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( @@ -57,7 +48,7 @@ describe('GET /api/megabox/theaters', () => { ); const res = await app.request('/api/megabox/theaters?keyword=안산%20중앙역', undefined, { - GOOGLE_MAPS_API_KEY: 'test-google-key', + KAKAO_REST_API_KEY: 'test-google-key', }); expect(res.status).toBe(200); @@ -102,20 +93,11 @@ describe('GET /api/megabox/movies', () => { }); it('keyword만 있어도 가까운 극장을 자동 선택한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( @@ -147,7 +129,7 @@ describe('GET /api/megabox/movies', () => { ); const res = await app.request('/api/megabox/movies?playDate=20260315&keyword=안산%20중앙역', undefined, { - GOOGLE_MAPS_API_KEY: 'test-google-key', + KAKAO_REST_API_KEY: 'test-google-key', }); expect(res.status).toBe(200); @@ -189,20 +171,11 @@ describe('GET /api/megabox/seats', () => { }); it('keyword만 있어도 가까운 극장 좌석을 조회한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( @@ -232,7 +205,7 @@ describe('GET /api/megabox/seats', () => { ); const res = await app.request('/api/megabox/seats?playDate=20260315&keyword=안산%20중앙역', undefined, { - GOOGLE_MAPS_API_KEY: 'test-google-key', + KAKAO_REST_API_KEY: 'test-google-key', }); expect(res.status).toBe(200); diff --git a/tests/app/app-api-opinet.test.ts b/tests/app/app-api-opinet.test.ts index c2292dd2..d2d4f74b 100644 --- a/tests/app/app-api-opinet.test.ts +++ b/tests/app/app-api-opinet.test.ts @@ -56,14 +56,12 @@ describe('GET /api/opinet', () => { }); it('반경 내 주유소 API는 위경도와 location을 지원한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce(new Response(JSON.stringify({ RESULT: { OIL: [] } }))) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [{ formatted_address: '서울 강남역', geometry: { location: { lat: 37.4979, lng: 127.0276 } } }], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '서울 강남역', y: 37.4979, x: 127.0276 }] }), ), ) .mockResolvedValueOnce(new Response(JSON.stringify({ RESULT: { OIL: [] } }))); @@ -73,7 +71,7 @@ describe('GET /api/opinet', () => { }); const byLocation = await app.request('/api/opinet/stations/around?location=%EA%B0%95%EB%82%A8%EC%97%AD', undefined, { OPINET_API_KEY: 'key', - GOOGLE_MAPS_API_KEY: 'google-key', + KAKAO_REST_API_KEY: 'google-key', }); expect((await byCoordinates.json()).data.location.inputType).toBe('coordinates'); diff --git a/tests/app/app-daily-rate-limit.test.ts b/tests/app/app-daily-rate-limit.test.ts index 29f71803..0dc92c1b 100644 --- a/tests/app/app-daily-rate-limit.test.ts +++ b/tests/app/app-daily-rate-limit.test.ts @@ -172,7 +172,6 @@ describe('일일 호출 제한 통합', () => { ['cgv', '/api/cgv/timetable'], ['cu', '/api/cu/stores'], ['gs25', '/api/gs25/products'], - ['lottemart', '/api/lottemart/products'], ] as const)('%s 한도 초과 429마다 정확히 한 건을 원장에 먼저 기록한다', async (service, path) => { const denied = createResult({ allowed: false, count: 3000, remaining: 0 }); const fixture = createRateLimitEnv([denied]); @@ -217,6 +216,19 @@ describe('일일 호출 제한 통합', () => { }); }); + it.each(['stores', 'products', 'debug'])('종료된 롯데마트 %s는 소진된 쿼터를 조회하거나 기록하지 않고 410을 반환한다', async (endpoint) => { + const fixture = createRateLimitEnv([createResult({ allowed: false, count: 3000, remaining: 0 })]); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const response = await app.request(`/api/lottemart/${endpoint}`, { + headers: { 'CF-Connecting-IP': '203.0.113.10' }, + }, fixture.env); + expect(response.status).toBe(410); + expect((await response.json()).error.code).toBe('SERVICE_RETIRED'); + expect(response.headers.get('X-RateLimit-Remaining')).toBeNull(); + expect(fixture.calls).toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it('교차 존 요청의 원장에는 원본·정규화 주체·단순 해시 대신 DO ID만 기록한다', async () => { const denied = createResult({ allowed: false, count: 3000, remaining: 0 }); const fixture = createRateLimitEnv([denied]); diff --git a/tests/app/app-health-checks.test.ts b/tests/app/app-health-checks.test.ts index 8f4e0d45..ac1abe1f 100644 --- a/tests/app/app-health-checks.test.ts +++ b/tests/app/app-health-checks.test.ts @@ -57,14 +57,14 @@ describe('GET /api/health/checks', () => { jsonResponse({ success: true, data: { - products: [{ productName: '코카콜라 (215ML*6입)' }], + products: [{ itemName: '코카콜라 (215ML*6입)' }], }, meta: { total: 1 }, }), ); const res = await app.request( - '/api/health/checks?check=lottemart.products&includeSamples=true&timeoutMs=1234&fresh=true&transport=network', + '/api/health/checks?check=gs25.products&includeSamples=true&timeoutMs=1234&fresh=true&transport=network', { headers: { Authorization: 'Bearer test-secret' }, }, @@ -79,14 +79,14 @@ describe('GET /api/health/checks', () => { expect(data.checks).toHaveLength(1); expect(data.checks[0]).toEqual( expect.objectContaining({ - id: 'lottemart.products', - service: 'lottemart', + id: 'gs25.products', + service: 'gs25', target: 'products', status: 'ok', sample: { first: '코카콜라 (215ML*6입)' }, }), ); - expect(String(mockFetch.mock.calls[0][0])).toContain('/api/lottemart/products'); + expect(String(mockFetch.mock.calls[0][0])).toContain('/api/gs25/products'); expect(String(mockFetch.mock.calls[0][0])).toContain('timeoutMs=1234'); }); @@ -102,7 +102,7 @@ describe('GET /api/health/checks', () => { ); const res = await app.request( - 'https://mcp.aka.page/api/health/checks?check=lottemart.products&fresh=true', + 'https://mcp.aka.page/api/health/checks?check=gs25.products&fresh=true', { headers: { Authorization: 'Bearer test-secret' }, }, @@ -115,7 +115,7 @@ describe('GET /api/health/checks', () => { expect(res.status).toBe(200); expect(String(mockFetch.mock.calls[0][0])).toMatch( - /^https:\/\/daiso-mcp\.example\.workers\.dev\/api\/lottemart\/products/, + /^https:\/\/daiso-mcp\.example\.workers\.dev\/api\/gs25\/products/, ); }); diff --git a/tests/app/app-info-pages.test.ts b/tests/app/app-info-pages.test.ts index dfbea5fb..996ad82b 100644 --- a/tests/app/app-info-pages.test.ts +++ b/tests/app/app-info-pages.test.ts @@ -97,13 +97,12 @@ describe('GET /', () => { expect(emart24Service.name).toBe('이마트24'); }); - it('롯데마트 서비스가 등록되어 있다', async () => { + it('종료된 롯데마트 서비스는 등록하지 않는다', async () => { const res = await app.request('/'); const data = await res.json(); const lotteMartService = data.services.find((s: { id: string }) => s.id === 'lottemart'); - expect(lotteMartService).toBeDefined(); - expect(lotteMartService.name).toBe('롯데마트'); + expect(lotteMartService).toBeUndefined(); }); it('메가박스 서비스가 등록되어 있다', async () => { @@ -204,12 +203,12 @@ describe('GET /', () => { expect(data.tools).toContain('emart24_check_inventory'); }); - it('롯데마트 도구들이 포함되어 있다', async () => { + it('종료된 롯데마트 도구들은 포함하지 않는다', async () => { const res = await app.request('/'); const data = await res.json(); - expect(data.tools).toContain('lottemart_find_nearby_stores'); - expect(data.tools).toContain('lottemart_search_products'); + expect(data.tools).not.toContain('lottemart_find_nearby_stores'); + expect(data.tools).not.toContain('lottemart_search_products'); }); it('메가박스 도구들이 포함되어 있다', async () => { @@ -284,6 +283,7 @@ describe('기본 페이지', () => { it('GET /health는 헬스 체크 응답을 반환한다', async () => { const res = await app.request('/health', undefined, { GOOGLE_MAPS_API_KEY: 'test-google', + KAKAO_REST_API_KEY: 'test-kakao', ZYTE_API_KEY: '', HEALTH_CHECK_SECRET: 'test-secret', }); @@ -315,13 +315,14 @@ describe('기본 페이지', () => { accessPairValid: true, usedBy: ['dtryx'], }, - googleMapsApiKey: { configured: true, usedBy: expect.arrayContaining(['gs25', 'cgv']) }, + googleMapsApiKey: { configured: true, enabled: false, usedBy: [] }, + kakaoRestApiKey: { configured: true, usedBy: ['gs25', 'cu', 'opinet', 'megabox', 'lottecinema', 'cgv'] }, zyteApiKey: { configured: false, enabled: false, usedBy: [], }, - naverLocalSearch: { configured: false, usedBy: ['places'] }, + naverLocalSearch: { configured: false, usedBy: ['places', 'gs25', 'cu', 'opinet', 'megabox', 'lottecinema', 'cgv'] }, opinetApiKey: { configured: false, usedBy: ['opinet'] }, supabaseFeedback: { configured: false, usedBy: ['feedback'] }, healthCheckSecret: { configured: true, usedBy: ['health-checks'] }, @@ -338,7 +339,7 @@ describe('기본 페이지', () => { const data = await res.json(); expect(data.config.naverLocalSearch).toEqual({ configured: true, - usedBy: ['places'], + usedBy: ['places', 'gs25', 'cu', 'opinet', 'megabox', 'lottecinema', 'cgv'], }); }); diff --git a/tests/app/cli.test.ts b/tests/app/cli.test.ts index c2dbcd54..d59bb663 100644 --- a/tests/app/cli.test.ts +++ b/tests/app/cli.test.ts @@ -877,52 +877,22 @@ describe('CLI', () => { expect(errors[0]).toContain('pluCd와 --bizNoArr가 필요합니다'); }); - it('lottemart-stores 명령은 롯데마트 매장 API를 호출한다', async () => { - const { deps } = createDeps(); - const fetchImpl = vi.fn().mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue({ success: true }), - } as unknown as Response); - deps.fetchImpl = fetchImpl; - - const exitCode = await runCli( - ['lottemart-stores', '잠실', '--area', '서울', '--limit', '5'], - deps, - ); - - expect(exitCode).toBe(0); - expect(fetchImpl).toHaveBeenCalledWith( - 'https://mcp.aka.page/api/lottemart/stores?area=%EC%84%9C%EC%9A%B8&limit=5&keyword=%EC%9E%A0%EC%8B%A4', - ); - }); - - it('lottemart-products 명령은 롯데마트 상품 API를 호출한다', async () => { - const { deps } = createDeps(); - const fetchImpl = vi.fn().mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue({ success: true }), - } as unknown as Response); - deps.fetchImpl = fetchImpl; - - const exitCode = await runCli( - ['lottemart-products', '콜라', '--storeName', '강변점', '--area', '서울'], - deps, - ); - - expect(exitCode).toBe(0); - expect(fetchImpl).toHaveBeenCalledWith( - 'https://mcp.aka.page/api/lottemart/products?keyword=%EC%BD%9C%EB%9D%BC&storeName=%EA%B0%95%EB%B3%80%EC%A0%90&area=%EC%84%9C%EC%9A%B8', - ); - }); - - it('lottemart-products 명령은 store 정보가 없으면 실패한다', async () => { - const { errors, deps } = createDeps(); - - const exitCode = await runCli(['lottemart-products', '콜라'], deps); - - expect(exitCode).toBe(1); - expect(errors[0]).toContain('--storeCode 또는 --storeName이 필요합니다'); - expect(errors[0]).toContain('매장을 모르면 먼저 daiso lottemart-stores 잠실 --area 서울'); + it.each([ + ['lottemart-stores'], + ['lottemart-stores', '잠실', '--area', '서울', '--limit', '5'], + ['lottemart-stores', '--help'], + ['lottemart-stores', '--foo', 'bar'], + ['lottemart-products'], + ['lottemart-products', '콜라', '--storeName', '강변점', '--area', '서울'], + ['lottemart-products', '콜라'], + ['lottemart-products', '--help'], + ['lottemart-products', '--foo', 'bar'], + ])('롯데마트 명령 %j는 입력과 관계없이 외부 요청 없이 지원 종료를 알린다', async (...args) => { + const { errors, output, deps } = createDeps(); + expect(await runCli(args, deps)).toBe(1); + expect(errors.join('\n')).toContain('롯데마트 서비스 지원이 종료되었습니다.'); + expect(output).toEqual([]); + expect(deps.fetchImpl).not.toHaveBeenCalled(); }); it('gs25-stores 명령은 GS25 매장 API를 호출한다', async () => { @@ -1234,8 +1204,6 @@ describe('CLI', () => { ['emart24-stores', ['emart24-stores', '강남']], ['emart24-products', ['emart24-products', '두바이']], ['emart24-inventory', ['emart24-inventory', '8800244010504', '--bizNoArr', '28339']], - ['lottemart-stores', ['lottemart-stores', '잠실']], - ['lottemart-products', ['lottemart-products', '콜라', '--storeName', '강변점']], ['gs25-stores', ['gs25-stores', '강남']], ['gs25-products', ['gs25-products', '오감자']], ['gs25-inventory', ['gs25-inventory', '오감자']], diff --git a/tests/freeCgvCandidates.test.ts b/tests/freeCgvCandidates.test.ts new file mode 100644 index 00000000..9c7b73a2 --- /dev/null +++ b/tests/freeCgvCandidates.test.ts @@ -0,0 +1,67 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { fetchCgvNearbyTheaters } from '../src/services/cgv/location.js'; +vi.mock('../src/services/cgv/client.js', () => ({ + fetchCgvTheaters: vi + .fn() + .mockResolvedValue([ + ...Array.from({ length: 12 }, (_, i) => ({ + theaterCode: String(i), + theaterName: `서울${i}`, + regionCode: '01', + })), + { theaterCode: 'busan', theaterName: '서면', regionCode: '05' }, + ]), +})); +afterEach(() => vi.unstubAllGlobals()); +it('부산 좌표는 전국 첫 12개 대신 주변 CGV 장소를 공식 목록과 연결한다', async () => { + const mockFetch = vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ + documents: [ + { place_name: 'CGV 서면', address_name: '부산 부산진구', x: '129.06', y: '35.15' }, + ], + }), + ), + ); + vi.stubGlobal('fetch', mockFetch); + const result = await fetchCgvNearbyTheaters( + { latitude: 35.115, longitude: 129.041, playDate: '20261005' }, + { kakaoRestApiKey: 'free' }, + ); + expect(result.theaters).toHaveLength(1); + expect(result.theaters[0]).toMatchObject({ theaterCode: 'busan', latitude: 35.15 }); + expect(result.theaters[0].distanceKm).toBeGreaterThan(0); + expect(mockFetch).toHaveBeenCalledTimes(1); +}); +it('키워드 좌표 해석 실패를 null거리의 근처 극장 성공으로 반환하지 않는다', async () => { + vi.stubGlobal('fetch', vi.fn()); + await expect(fetchCgvNearbyTheaters({ keyword: '부산역', playDate: '20261005' })).rejects.toThrow( + '위치를 좌표로 변환하지 못했습니다', + ); +}); +it('잘못된 장소 이름을 건너뛰고 공식 극장과 연결한다', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ documents: [{ place_name: 123, x: '129', y: '35' }, { place_name: 'CGV 서면', x: '129.06', y: '35.15' }] })))); + expect((await fetchCgvNearbyTheaters({ latitude: 35.115, longitude: 129.041, playDate: '20261005' }, { kakaoRestApiKey: 'free' })).theaters).toHaveLength(1); +}); +it('입력이 없으면 근처 극장을 만들지 않고 좌표가 잘못되면 거부한다', async () => { + expect((await fetchCgvNearbyTheaters({ playDate: '20261005' })).theaters).toEqual([]); + await expect(fetchCgvNearbyTheaters({ latitude: 0, longitude: 0, playDate: '20261005' })).rejects.toThrow('유효한 위도'); +}); +it('공식 이름과 일치하지 않는 장소나 잘못된 좌표는 결과에서 제외한다', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ documents: [{ place_name: 'CGV 가짜', x: '129', y: '35' }, { place_name: 'CGV 서면', x: '0', y: '0' }, { x: '129', y: '35' }] })))); + expect((await fetchCgvNearbyTheaters({ latitude: 35.115, longitude: 129.041, playDate: '20261005' }, { kakaoRestApiKey: 'free' })).theaters).toEqual([]); +}); +it('같은 거리의 극장은 이름순으로 안정적으로 정렬한다', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ documents: [{ place_name: 'CGV 서울1', x: '129.06', y: '35.15' }, { place_name: 'CGV 서면', x: '129.06', y: '35.15' }, { place_name: 'CGV 서울2', x: '129.08', y: '35.17' }] })))); + const result = await fetchCgvNearbyTheaters({ latitude: 35.115, longitude: 129.041, playDate: '20261005' }, { kakaoRestApiKey: 'free' }); + expect(result.theaters.map((theater) => theater.theaterName)).toEqual(['서면', '서울1', '서울2']); +}); +it.each(['searchMovies', 'getTimetable'])('위치가 해석돼도 주변 공식 극장이 없으면 %s 결과는 비운다', async (toolName) => { + const tool = toolName === 'searchMovies' + ? (await import('../src/services/cgv/tools/searchMovies.js')).createSearchMoviesTool() + : (await import('../src/services/cgv/tools/getTimetable.js')).createGetTimetableTool(); + const result = await tool.handler({ latitude: 35.115, longitude: 129.041, playDate: '20261005' }); + expect(JSON.parse(result.content[0].text).resolvedTheater).toBeNull(); +}); diff --git a/tests/freeConfigStatus.test.ts b/tests/freeConfigStatus.test.ts new file mode 100644 index 00000000..21db0a8f --- /dev/null +++ b/tests/freeConfigStatus.test.ts @@ -0,0 +1,9 @@ +import { expect, it } from 'vitest'; +import { buildConfigStatus } from '../src/api/configStatus.js'; +it('무료 지도 공급자의 구성을 알리고 유료 공급자는 비활성으로 표시한다', () => { + const status = buildConfigStatus({ KAKAO_REST_API_KEY: 'test', GOOGLE_MAPS_API_KEY: 'disabled' }); + expect(status).toMatchObject({ + kakaoRestApiKey: { configured: true }, + googleMapsApiKey: { enabled: false, usedBy: [] }, + }); +}); diff --git a/tests/freeGeocode.test.ts b/tests/freeGeocode.test.ts new file mode 100644 index 00000000..b5736d78 --- /dev/null +++ b/tests/freeGeocode.test.ts @@ -0,0 +1,336 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { geocodeLocation } from '../src/utils/geocode.js'; + +describe('무료 위치 검색', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + it('주소는 Kakao 주소 검색으로 좌표를 검증한다', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + documents: [{ address_name: '부산 동구 중앙대로 206', x: '129.041', y: '35.115' }], + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + expect( + await geocodeLocation('부산 동구 중앙대로 206', { kakaoRestApiKey: 'test' }), + ).toMatchObject({ latitude: 35.115, longitude: 129.041 }); + expect(fetchMock.mock.calls[0][0]).toContain('/search/address.json'); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('KakaoAK test'); + }); + it('무관한 첫 장소 대신 이름이 일치하는 장소를 고른다', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + documents: [ + { place_name: '서울역', address_name: '서울 중구', x: '126.9', y: '37.5' }, + { place_name: '부산역', address_name: '부산 동구', x: '129.041', y: '35.115' }, + ], + }), + ), + ), + ); + expect(await geocodeLocation('부산역', { kakaoRestApiKey: 'test' })).toMatchObject({ + latitude: 35.115, + }); + }); + it('Google 키만 제공하면 요청하지 않는다', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + expect(await geocodeLocation('부산역', { googleMapsApiKey: 'disabled' })).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); +it('정식 시도명과 축약 주소를 같은 지역으로 해석한다', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + documents: [{ address_name: '부산 동구 중앙대로 206', x: '129.045', y: '35.117' }], + }), + ), + ), + ); + expect( + await geocodeLocation('부산광역시 동구 중앙대로 206', { kakaoRestApiKey: 'free' }), + ).toMatchObject({ latitude: 35.117 }); + vi.unstubAllGlobals(); +}); +it('역 이름과 주소 지역을 함께 검증한다', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + documents: [ + { + place_name: '강남역 2호선', + address_name: '서울 강남구', + x: '127.027', + y: '37.497', + }, + ], + }), + ), + ), + ); + expect(await geocodeLocation('서울 강남역', { kakaoRestApiKey: 'free' })).toMatchObject({ + latitude: 37.497, + }); + vi.unstubAllGlobals(); +}); +it.each([ + ['부산 동구 중앙대로 20', '부산 동구 중앙대로 206'], + ['서울 강남역', '서울 강남역점'], +])('유사 문자열을 실제 위치로 오인하지 않는다: %s', async (query, name) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + documents: [{ place_name: name, address_name: name, x: '127', y: '37' }], + }), + ), + ), + ); + expect(await geocodeLocation(query, { kakaoRestApiKey: 'free' })).toBeNull(); + vi.unstubAllGlobals(); +}); +it('주소 검색은 Naver Local로 우회하지 않는다', async () => { + const mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + expect( + await geocodeLocation('부산 동구 중앙대로 206', { + naverClientId: 'id', + naverClientSecret: 'secret', + }), + ).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); +}); +it('Kakao 오류는 이름이 일치하는 Naver 장소로 보완한다', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(new Response('unauthorized', { status: 401 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { title: '부산역', address: '부산 동구', mapx: '1290410000', mapy: '351150000' }, + ], + }), + ), + ); + vi.stubGlobal('fetch', mockFetch); + expect( + await geocodeLocation('부산역', { + kakaoRestApiKey: 'free', + naverClientId: 'id', + naverClientSecret: 'secret', + }), + ).toMatchObject({ latitude: 35.115 }); + vi.unstubAllGlobals(); +}); +it('네이버 장애도 다른 도시로 대체하지 않는다', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('unauthorized', { status: 401 }))); + expect( + await geocodeLocation('부산역', { naverClientId: 'id', naverClientSecret: 'secret' }), + ).toBeNull(); + vi.unstubAllGlobals(); +}); +it.each([{}, [null]])( + '잘못된 Kakao documents는 네이버 장소 검색으로 보완한다: %j', + async (documents) => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ documents }))) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ items: [{ title: '부산역', mapx: '1290410000', mapy: '351150000' }] }), + ), + ); + vi.stubGlobal('fetch', mockFetch); + expect( + await geocodeLocation('부산역', { + kakaoRestApiKey: 'free', + naverClientId: 'id', + naverClientSecret: 'secret', + }), + ).toMatchObject({ latitude: 35.115 }); + vi.unstubAllGlobals(); + }, +); +it('지번 주소의 본번과 부번을 보존한다', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + documents: [{ address_name: '서울 강남구 역삼동 123-4', x: '127', y: '37.5' }], + }), + ), + ), + ); + expect( + await geocodeLocation('서울 강남구 역삼동 123-4', { kakaoRestApiKey: 'free' }), + ).toMatchObject({ latitude: 37.5 }); + vi.unstubAllGlobals(); +}); +it.each([ + { address_name: 206, x: '129', y: '35' }, + { place_name: 123, x: '129', y: '35' }, +])('문자열이 아닌 장소 필드는 건너뛰고 다음 유효한 결과를 해석한다', async (document) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + documents: [ + document, + { place_name: '부산역', address_name: '부산 동구', x: '129.041', y: '35.115' }, + ], + }), + ), + ), + ); + expect(await geocodeLocation('부산역', { kakaoRestApiKey: 'free' })).toMatchObject({ + latitude: 35.115, + }); + vi.unstubAllGlobals(); +}); +it('주소 검색의 문자열이 아닌 주소 필드는 건너뛴다', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + documents: [ + { address_name: 206, x: '129', y: '35' }, + { address_name: '부산 동구 중앙대로 206', x: '129.041', y: '35.115' }, + ], + }), + ), + ), + ); + expect( + await geocodeLocation('부산 동구 중앙대로 206', { kakaoRestApiKey: 'free' }), + ).toMatchObject({ latitude: 35.115 }); + vi.unstubAllGlobals(); +}); +it('Worker에는 process가 없어도 환경 읽기가 안전하다', async () => { + const { readGeocodeEnvironment } = await import('../src/utils/geocode.js'); + vi.stubGlobal('process', undefined); + const result = readGeocodeEnvironment(); + vi.unstubAllGlobals(); + expect(result).toEqual({ + kakaoRestApiKey: undefined, + naverClientId: undefined, + naverClientSecret: undefined, + }); +}); +it('장소 토큰이 없거나 네이버 결과가 무관하면 선택하지 않는다', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ documents: [{ place_name: '부산역', y: '35', x: '129' }] }), + ), + ), + ), + ); + expect(await geocodeLocation('!!!', { kakaoRestApiKey: 'free' })).toBeNull(); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ items: [{ title: '서울역', mapx: '1260000000', mapy: '370000000' }] }), + ), + ), + ); + expect( + await geocodeLocation('부산역', { naverClientId: 'id', naverClientSecret: 'secret' }), + ).toBeNull(); + vi.unstubAllGlobals(); +}); +it('주소 응답이 누락되었거나 지번과 도로명 번호가 섞이면 선택하지 않는다', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ documents: {} }))) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + documents: [ + { + address_name: '서울 강남구 역삼동 123', + road_address_name: '서울 강남구 테헤란로 4', + y: '37.5', + x: '127', + }, + ], + }), + ), + ), + ); + expect(await geocodeLocation('서울 강남구 역삼동 123', { kakaoRestApiKey: 'free' })).toBeNull(); + expect(await geocodeLocation('서울 강남구 역삼동 4', { kakaoRestApiKey: 'free' })).toBeNull(); + vi.unstubAllGlobals(); +}); +it('장소명 응답에 이름이 없으면 주소에 역명이 있어도 선택하지 않는다', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ documents: [{ address_name: '부산역', x: '129', y: '35' }] }), + ), + ), + ); + expect(await geocodeLocation('부산역', { kakaoRestApiKey: 'free' })).toBeNull(); + vi.unstubAllGlobals(); +}); +it('지번 주소가 없는 응답도 도로명 주소가 일치하면 해석한다', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + documents: [{ road_address_name: '부산 동구 중앙대로 206', x: '129', y: '35' }], + }), + ), + ), + ); + expect( + await geocodeLocation('부산 동구 중앙대로 206', { kakaoRestApiKey: 'free' }), + ).toMatchObject({ formattedAddress: '부산 동구 중앙대로 206' }); + vi.unstubAllGlobals(); +}); +it('도로와 건물의 숫자 순서가 뒤바뀐 주소를 선택하지 않는다', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + documents: [{ address_name: '서울 강남구 테헤란로30길 5', x: '127', y: '37.5' }], + }), + ), + ), + ); + expect( + await geocodeLocation('서울 강남구 테헤란로5길 30', { kakaoRestApiKey: 'free' }), + ).toBeNull(); + vi.unstubAllGlobals(); +}); diff --git a/tests/freeLocationRegression.test.ts b/tests/freeLocationRegression.test.ts new file mode 100644 index 00000000..aca700c6 --- /dev/null +++ b/tests/freeLocationRegression.test.ts @@ -0,0 +1,44 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { geocodeCuAddress } from '../src/services/cu/client.js'; +import { resolveMegaboxLocation } from '../src/services/megabox/location.js'; +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); +it('CU 주소 해석은 무료 키로 주소 검색한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'free'); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ + documents: [{ address_name: '부산 동구 중앙대로 206', x: '129.041', y: '35.115' }], + }), + ), + ), + ); + expect(await geocodeCuAddress('부산 동구 중앙대로 206')).toMatchObject({ latitude: 35.115 }); +}); +it('메가박스 부산역 위치 실패는 서울로 성공하지 않는다', async () => { + await expect(resolveMegaboxLocation({ keyword: '부산역' })).rejects.toThrow('위치'); +}); +it('메가박스는 한국 범위 밖 좌표를 가까운 극장 검색에 사용하지 않는다', async () => { + await expect(resolveMegaboxLocation({ latitude: 0, longitude: 0 })).rejects.toThrow( + '유효한 위도', + ); +}); +it('롯데시네마 위치 실패를 거리 없는 성공으로 반환하지 않는다', async () => { + const { resolveLotteCinemaLocation } = await import('../src/services/lottecinema/location.js'); + await expect(resolveLotteCinemaLocation({ keyword: '부산역' })).rejects.toThrow( + '위치를 좌표로 변환하지 못했습니다', + ); +}); +it('롯데시네마 MCP 위치 입력에 서울 좌표 기본값을 넣지 않는다', async () => { + const { createFindNearbyTheatersTool } = await import('../src/services/lottecinema/tools/findNearbyTheaters.js'); + const schema = createFindNearbyTheatersTool().metadata.inputSchema; + expect(schema.latitude.parse(undefined)).toBeUndefined(); + expect(schema.longitude.parse(undefined)).toBeUndefined(); +}); diff --git a/tests/middleware/dailyRateLimit.test.ts b/tests/middleware/dailyRateLimit.test.ts index 65f14399..3b3754bf 100644 --- a/tests/middleware/dailyRateLimit.test.ts +++ b/tests/middleware/dailyRateLimit.test.ts @@ -72,7 +72,6 @@ describe('isDailyRateLimitedRequest', () => { '/api/cgv/timetable', '/api/cu/stores', '/api/gs25/products', - '/api/lottemart/products', ])('%s GET을 보호한다', (path) => { expect(isDailyRateLimitedRequest(new Request(`https://example.com${path}`))).toBe(true); }); @@ -87,6 +86,9 @@ describe('isDailyRateLimitedRequest', () => { '/api/cgvish/timetable', '/api/cuish/stores', '/api/gs250/products', + '/api/lottemart/stores', + '/api/lottemart/products', + '/api/lottemart/debug', '/api/lottemartish/products', '/api/cgv', ])('%s 경로를 제외한다', (path) => { diff --git a/tests/pages/openapi.test.ts b/tests/pages/openapi.test.ts index 4765931d..f2e90364 100644 --- a/tests/pages/openapi.test.ts +++ b/tests/pages/openapi.test.ts @@ -73,7 +73,7 @@ describe('OpenAPI 페이지', () => { spec.paths['/api/actions/query'].get.parameters.find( (parameter) => parameter.name === 'storeName', )?.description, - ).toContain('모르면 먼저 lottemartFindNearbyStores'); + ).not.toContain('lottemart'); expect( spec.paths['/api/actions/query'].get.parameters.some( (parameter) => parameter.name === 'location', @@ -150,15 +150,8 @@ describe('OpenAPI 페이지', () => { expect(spec.paths['/api/emart24/stores']).toBeDefined(); expect(spec.paths['/api/emart24/products']).toBeDefined(); expect(spec.paths['/api/emart24/inventory']).toBeDefined(); - expect(spec.paths['/api/lottemart/stores']).toBeDefined(); - expect(spec.paths['/api/lottemart/products']).toBeDefined(); - expect( - ( - spec.paths['/api/lottemart/products'] as { - get: { parameters: Array<{ name: string }> }; - } - ).get.parameters.map((parameter) => parameter.name), - ).toEqual(expect.arrayContaining(['source', 'timeoutMs'])); + expect(spec.paths['/api/lottemart/stores']).toBeUndefined(); + expect(spec.paths['/api/lottemart/products']).toBeUndefined(); expect(spec.paths['/api/gs25/stores']).toBeDefined(); expect(spec.paths['/api/gs25/products']).toBeDefined(); expect(spec.paths['/api/gs25/inventory']).toBeDefined(); diff --git a/tests/pages/prompt.test.ts b/tests/pages/prompt.test.ts index bf2c7bfd..a5e1ab3b 100644 --- a/tests/pages/prompt.test.ts +++ b/tests/pages/prompt.test.ts @@ -35,8 +35,8 @@ describe('generatePromptText', () => { expect(text).toContain('/api/oliveyoung/products'); expect(text).toContain('products[].imageUrl'); expect(text).toContain('/api/oliveyoung/inventory'); - expect(text).toContain('/api/lottemart/stores'); - expect(text).toContain('/api/lottemart/products'); + expect(text).not.toContain('/api/lottemart/stores'); + expect(text).not.toContain('/api/lottemart/products'); expect(text).toContain('/api/gs25/stores'); expect(text).toContain('/api/gs25/products'); expect(text).toContain('/api/gs25/inventory'); @@ -102,8 +102,8 @@ describe('generatePromptText', () => { expect(text).toContain('GS25_STORE_SEARCH_FAILED'); expect(text).toContain('GS25_PRODUCT_SEARCH_FAILED'); expect(text).toContain('GS25_INVENTORY_CHECK_FAILED'); - expect(text).toContain('LOTTEMART_STORE_SEARCH_FAILED'); - expect(text).toContain('LOTTEMART_PRODUCT_SEARCH_FAILED'); + expect(text).not.toContain('LOTTEMART_STORE_SEARCH_FAILED'); + expect(text).not.toContain('LOTTEMART_PRODUCT_SEARCH_FAILED'); expect(text).toContain('SEVENELEVEN_INVENTORY_CHECK_FAILED'); }); @@ -116,8 +116,8 @@ describe('generatePromptText', () => { expect(text).toContain('daiso_check_inventory'); expect(text).toContain('daiso_get_price_info'); expect(text).toContain('daiso_get_display_location'); - expect(text).toContain('lottemart_find_nearby_stores'); - expect(text).toContain('lottemart_search_products'); + expect(text).not.toContain('lottemart_find_nearby_stores'); + expect(text).not.toContain('lottemart_search_products'); expect(text).toContain('gs25_find_nearby_stores'); expect(text).toContain('gs25_search_products'); expect(text).toContain('gs25_check_inventory'); diff --git a/tests/retiredCli.test.ts b/tests/retiredCli.test.ts new file mode 100644 index 00000000..12203664 --- /dev/null +++ b/tests/retiredCli.test.ts @@ -0,0 +1,12 @@ +import { expect, it, vi } from 'vitest'; +import { runCli } from '../src/cli.js'; +it.each(['lottemart-stores', 'lottemart-products'])( + '종료된 명령 %s는 원격 조회 없이 종료를 알린다', + async (command) => { + const fetchImpl = vi.fn(); + const writeErr = vi.fn(); + expect(await runCli([command, '콜라'], { fetchImpl, writeErr, writeOut: vi.fn() })).toBe(1); + expect(writeErr).toHaveBeenCalledWith(expect.stringContaining('지원이 종료')); + expect(fetchImpl).not.toHaveBeenCalled(); + }, +); diff --git a/tests/retirement.test.ts b/tests/retirement.test.ts new file mode 100644 index 00000000..4ed69ea7 --- /dev/null +++ b/tests/retirement.test.ts @@ -0,0 +1,37 @@ +import { expect, it, vi } from 'vitest'; +import app from '../src/index.js'; +it('롯데마트 경로는 업스트림 호출 없이 종료를 알린다', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + try { + for (const endpoint of ['stores', 'products', 'debug']) { + const response = await app.request(`/api/lottemart/${endpoint}`); + expect(response.status).toBe(410); + expect(await response.json()).toMatchObject({ + success: false, + error: { code: 'SERVICE_RETIRED' }, + }); + } + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } +}); +it('롯데마트는 지원 서비스와 MCP 도구에 없다', async () => { + const body = await (await app.request('/')).json(); + expect(body.tools.some((name: string) => name.startsWith('lottemart_'))).toBe(false); +}); +it.each(['lottemartFindStores', 'lottemartSearchProducts'])('기존 action %s도 업스트림 없이 종료를 알린다', async (action) => { + const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); + try { + const response = await app.request(`/api/actions/query?action=${action}`); + expect(response.status).toBe(410); + expect(await response.json()).toMatchObject({ error: { code: 'SERVICE_RETIRED' } }); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { vi.unstubAllGlobals(); } +}); +it('action이 없는 요청은 지원 종료 응답과 구분한다', async () => { + const response = await app.request('/api/actions/query'); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code: 'INVALID_ACTION_QUERY' } }); +}); diff --git a/tests/scripts/cli-smoke.test.ts b/tests/scripts/cli-smoke.test.ts index 8c405fc2..86bb0e45 100644 --- a/tests/scripts/cli-smoke.test.ts +++ b/tests/scripts/cli-smoke.test.ts @@ -25,7 +25,6 @@ describe('runCliSmoke', () => { 'gs25', 'seveneleven', 'emart24', - 'lottemart', 'oliveyoung', 'megabox', 'lottecinema', @@ -35,11 +34,22 @@ describe('runCliSmoke', () => { ); }); + it('지원 종료 서비스는 활성 smoke 목록에 포함하지 않는다', () => { + expect(CLI_SMOKE_COMMANDS.map((command) => command.service)).not.toContain('lottemart'); + expect(CLI_SMOKE_COMMANDS.some((command) => command.args[0].startsWith('lottemart-'))).toBe( + false, + ); + }); + it('필수 CLI 명령이 모두 성공하면 0을 반환한다', async () => { const runCommand = vi.fn((_command: string, args: string[]) => { const command = args[1]; if (command === 'health') { - return Promise.resolve({ exitCode: 0, stdout: JSON.stringify({ status: 'ok' }), stderr: '' }); + return Promise.resolve({ + exitCode: 0, + stdout: JSON.stringify({ status: 'ok' }), + stderr: '', + }); } const stdoutByCommand: Record = { @@ -49,7 +59,6 @@ describe('runCliSmoke', () => { 'gs25-stores': { success: true, data: { keyword: '강남' } }, 'seveneleven-products': { success: true, data: { query: '커피' } }, 'emart24-products': { success: true, data: { keyword: '커피' } }, - 'lottemart-products': { success: true, data: { keyword: '콜라' } }, 'megabox-theaters': { success: true, data: { keyword: '강남' } }, 'lottecinema-theaters': { success: true, data: { keyword: '잠실' } }, }; @@ -58,14 +67,16 @@ describe('runCliSmoke', () => { path === '/api/dtryx/movies' ? { success: true, data: { movies: [{ movieCode: '1', movieName: '영화' }] } } : path === '/api/oliveyoung/products' - ? { success: true, data: { keyword: '선크림' } } - : path === '/api/megabox/theaters' || path === '/api/cgv/theaters' - ? { success: true, data: { keyword: '강남' } } - : path === '/api/opinet/average' - ? { success: true, data: { provider: 'opinet' } } - : undefined; + ? { success: true, data: { keyword: '선크림' } } + : path === '/api/megabox/theaters' || path === '/api/cgv/theaters' + ? { success: true, data: { keyword: '강남' } } + : path === '/api/opinet/average' + ? { success: true, data: { provider: 'opinet' } } + : undefined; const payload = stdoutByCommand[command] || getPayload || { success: true, data: {} }; - const stderr = args.includes('--store') ? '알 수 없는 옵션: --store\n매장명은 --keyword로 전달하세요' : ''; + const stderr = args.includes('--store') + ? '알 수 없는 옵션: --store\n매장명은 --keyword로 전달하세요' + : ''; return Promise.resolve({ exitCode: args.includes('--store') ? 1 : 0, stdout: args.includes('--store') ? '' : JSON.stringify(payload), @@ -85,27 +96,30 @@ describe('runCliSmoke', () => { expect(exitCode).toBe(0); expect(runCommand).toHaveBeenCalledWith('node', ['dist/bin.js', 'health']); - expect(runCommand).toHaveBeenCalledWith('node', ['dist/bin.js', 'products', '수납박스', '--pageSize', '1', '--json']); - expect(runCommand).toHaveBeenCalledWith('node', ['dist/bin.js', 'stores', '안산 중앙역', '--limit', '1', '--json']); expect(runCommand).toHaveBeenCalledWith('node', [ 'dist/bin.js', - 'inventory', - '1034604', - '--store', - '강남역점', + 'products', + '수납박스', + '--pageSize', + '1', + '--json', ]); expect(runCommand).toHaveBeenCalledWith('node', [ 'dist/bin.js', - 'lottemart-products', - '콜라', - '--storeCode', - '2301', - '--area', - '서울', - '--pageLimit', + 'stores', + '안산 중앙역', + '--limit', '1', '--json', ]); + expect(runCommand).toHaveBeenCalledWith('node', [ + 'dist/bin.js', + 'inventory', + '1034604', + '--store', + '강남역점', + ]); + expect(runCommand.mock.calls.some(([, args]) => args[1].startsWith('lottemart-'))).toBe(false); expect(writeErr).not.toHaveBeenCalled(); }); @@ -135,7 +149,10 @@ describe('runCliSmoke', () => { expect(exitCode).toBe(0); expect(runCommand).toHaveBeenCalledTimes(2); - expect(runCommand.mock.calls.map(([, args]) => args[1])).toEqual(['gs25-products', 'gs25-stores']); + expect(runCommand.mock.calls.map(([, args]) => args[1])).toEqual([ + 'gs25-products', + 'gs25-stores', + ]); }); it('known upstream 403이면 degraded로 기록하고 통과한다', async () => { @@ -222,7 +239,6 @@ describe('runCliSmoke', () => { 'gs25-stores': { success: true, data: { keyword: '강남' } }, 'seveneleven-products': { success: true, data: { query: '커피' } }, 'emart24-products': { success: true, data: { keyword: '커피' } }, - 'lottemart-products': { success: true, data: { keyword: '콜라' } }, 'megabox-theaters': { success: true, data: { keyword: '강남' } }, 'lottecinema-theaters': { success: true, data: { keyword: null } }, }; @@ -231,12 +247,12 @@ describe('runCliSmoke', () => { path === '/api/dtryx/movies' ? { success: true, data: { movies: [{ movieCode: '1', movieName: '영화' }] } } : path === '/api/oliveyoung/products' - ? { success: true, data: { keyword: '선크림' } } - : path === '/api/megabox/theaters' || path === '/api/cgv/theaters' - ? { success: true, data: { keyword: '강남' } } - : path === '/api/opinet/average' - ? { success: true, data: { provider: 'opinet' } } - : undefined; + ? { success: true, data: { keyword: '선크림' } } + : path === '/api/megabox/theaters' || path === '/api/cgv/theaters' + ? { success: true, data: { keyword: '강남' } } + : path === '/api/opinet/average' + ? { success: true, data: { provider: 'opinet' } } + : undefined; const payload = payloadByCommand[command] || getPayload || { success: true, data: {} }; return Promise.resolve({ diff --git a/tests/scripts/workers-chart-helpers.test.ts b/tests/scripts/workers-chart-helpers.test.ts index 7bb5e372..2bbca621 100644 --- a/tests/scripts/workers-chart-helpers.test.ts +++ b/tests/scripts/workers-chart-helpers.test.ts @@ -22,6 +22,8 @@ describe('buildReadmeSection', () => { }); expect(section).toContain('> [!IMPORTANT]'); + expect(section).not.toContain('롯데마트'); + expect(section).toContain('올리브영·CGV·CU·GS25'); expect(section).toContain('2026년 7월 18일부터'); expect(section).toContain('IP당 하루 합산 3,000회(KST 기준)'); expect(section.indexOf('workers-invocations.png')).toBeLessThan( diff --git a/tests/services/cgv/location.keyword.test.ts b/tests/services/cgv/location.keyword.test.ts index c2e570e0..c33f0dba 100644 --- a/tests/services/cgv/location.keyword.test.ts +++ b/tests/services/cgv/location.keyword.test.ts @@ -18,24 +18,16 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); describe('CGV 위치 키워드 정규화', () => { it('복합 요청 문장에서도 위치 구간만 추출해 지오코드한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch.mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구 고잔동', y: 37.3171, x: 126.8389 }] }), ), ); @@ -43,12 +35,12 @@ describe('CGV 위치 키워드 정규화', () => { { keyword: 'CGV 안산 중앙역 근처 극장 찾고 오늘 하는 영화 목록 찾고 남은 좌석수 찾아주세요', }, - { googleMapsApiKey: 'test-google-key' }, + { kakaoRestApiKey: 'test-google-key' }, ); const requestUrl = new URL(mockFetch.mock.calls[0]?.[0] as string); - expect(requestUrl.searchParams.get('address')).toBe('안산 중앙역'); + expect(requestUrl.searchParams.get('query')).toBe('안산 중앙역'); expect(result.keyword).toBe('안산 중앙역'); expect(result.latitude).toBe(37.3171); expect(result.longitude).toBe(126.8389); @@ -68,6 +60,7 @@ describe('CGV 위치 키워드 정규화', () => { }); it('정규화된 위치로 가까운 극장을 고른다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( @@ -89,32 +82,12 @@ describe('CGV 위치 키워드 정규화', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구 고잔동', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: 'CGV 안산', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ); @@ -123,7 +96,7 @@ describe('CGV 위치 키워드 정규화', () => { playDate: '20260315', keyword: 'CGV 안산 중앙역 근처 극장 찾고 오늘 하는 영화 목록 찾고 남은 좌석수 찾아주세요', }, - { googleMapsApiKey: 'test-google-key' }, + { kakaoRestApiKey: 'test-google-key' }, ); expect(result.keyword).toBe('안산 중앙역'); diff --git a/tests/services/cgv/location.test.ts b/tests/services/cgv/location.test.ts index c2f29abf..98bfec5f 100644 --- a/tests/services/cgv/location.test.ts +++ b/tests/services/cgv/location.test.ts @@ -19,6 +19,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -41,25 +42,16 @@ describe('resolveCgvLocation', () => { }); it('키워드를 구글 지오코드로 보강한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch.mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구 고잔동', y: 37.3171, x: 126.8389 }] }), ), ); const result = await resolveCgvLocation( { keyword: '안산 중앙역' }, - { googleMapsApiKey: 'test-google-key' }, + { kakaoRestApiKey: 'test-google-key' }, ); expect(result).toEqual({ @@ -72,24 +64,16 @@ describe('resolveCgvLocation', () => { }); it('formatted_address가 없어도 좌표는 사용한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch.mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '', y: 37.3171, x: 126.8389 }] }), ), ); const result = await resolveCgvLocation( { keyword: '안산 중앙역' }, - { googleMapsApiKey: 'test-google-key' }, + { kakaoRestApiKey: 'test-google-key' }, ); expect(result.latitude).toBe(37.3171); @@ -98,18 +82,16 @@ describe('resolveCgvLocation', () => { }); it('지오코드 결과가 없으면 빈 좌표를 반환한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch.mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'ZERO_RESULTS', - results: [], - }), + JSON.stringify({ documents: [] }), ), ); const result = await resolveCgvLocation( { keyword: '없는 위치' }, - { googleMapsApiKey: 'test-google-key' }, + { kakaoRestApiKey: 'test-google-key' }, ); expect(result.latitude).toBeNull(); @@ -129,33 +111,11 @@ describe('resolveCgvLocation', () => { formattedAddress: null, }); }); - - it('같은 키워드 지오코드 결과를 캐시에서 재사용한다', async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), - ), - ); - - await resolveCgvLocation({ keyword: '안산 중앙역' }, { googleMapsApiKey: 'test-google-key' }); - await resolveCgvLocation({ keyword: '안산 중앙역' }, { googleMapsApiKey: 'test-google-key' }); - - expect(mockFetch).toHaveBeenCalledTimes(1); - }); }); describe('fetchCgvNearbyTheaters', () => { it('키워드와 극장명 지오코드를 사용해 가까운 극장을 정렬한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( @@ -177,32 +137,12 @@ describe('fetchCgvNearbyTheaters', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구 고잔동', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: 'CGV 안산', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ); @@ -212,7 +152,7 @@ describe('fetchCgvNearbyTheaters', () => { keyword: '안산 중앙역', }, { - googleMapsApiKey: 'test-google-key', + kakaoRestApiKey: 'test-google-key', }, ); @@ -222,64 +162,6 @@ describe('fetchCgvNearbyTheaters', () => { expect(result.theaters[0].address).toContain('안산시'); }); - it('구글 키가 없어도 극장명 토큰으로 후보를 찾는다', async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 0, - statusMessage: '조회 되었습니다.', - data: [ - { - regnGrpCd: '02', - regnGrpNm: '경기', - siteList: [ - { siteNo: '0212', siteNm: '안산중앙' }, - { siteNo: '0211', siteNm: '안산' }, - { siteNo: '0056', siteNm: '강남' }, - ], - }, - ], - }), - ), - ); - - const result = await fetchCgvNearbyTheaters({ - playDate: '20260315', - keyword: '안산 중앙역', - }); - - expect(result.theaters).toHaveLength(2); - expect(result.theaters[0].theaterCode).toBe('0211'); - expect(result.theaters[1].theaterCode).toBe('0212'); - expect(result.theaters[0].distanceKm).toBeNull(); - }); - - it('키워드와 매칭되는 극장이 없으면 빈 결과를 반환한다', async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 0, - statusMessage: '조회 되었습니다.', - data: [ - { - regnGrpCd: '01', - regnGrpNm: '서울', - siteList: [{ siteNo: '0056', siteNm: '강남' }], - }, - ], - }), - ), - ); - - const result = await fetchCgvNearbyTheaters({ - playDate: '20260315', - keyword: '안산 중앙역', - }); - - expect(result.count).toBe(0); - expect(result.theaters).toEqual([]); - }); - it('좌표만 있고 구글 키가 없으면 임의 근처 극장을 만들지 않는다', async () => { mockFetch.mockResolvedValueOnce( new Response( @@ -306,100 +188,11 @@ describe('fetchCgvNearbyTheaters', () => { expect(result.count).toBe(0); expect(result.theaters).toEqual([]); }); - - it('좌표와 구글 키가 있으면 토큰이 없어도 후보 극장을 거리순으로 계산한다', async () => { - mockFetch - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 0, - statusMessage: '조회 되었습니다.', - data: [ - { - regnGrpCd: '02', - regnGrpNm: '경기', - siteList: [ - { siteNo: '0212', siteNm: '야탑' }, - { siteNo: '0211', siteNm: '안산' }, - ], - }, - ], - }), - ), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 성남시 분당구', - geometry: { - location: { lat: 37.411, lng: 127.128 }, - }, - }, - ], - }), - ), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), - ), - ); - - const result = await fetchCgvNearbyTheaters( - { - playDate: '20260315', - latitude: 37.3171, - longitude: 126.8389, - }, - { googleMapsApiKey: 'test-google-key' }, - ); - - expect(result.theaters[0].theaterCode).toBe('0211'); - expect(result.theaters[0].distanceKm).toBeCloseTo(0.01, 2); - }); - - it('stopword만 있는 키워드는 토큰으로 사용하지 않는다', async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 0, - statusMessage: '조회 되었습니다.', - data: [ - { - regnGrpCd: '01', - regnGrpNm: '서울', - siteList: [{ siteNo: '0056', siteNm: '강남' }], - }, - ], - }), - ), - ); - - const result = await fetchCgvNearbyTheaters({ - playDate: '20260315', - keyword: 'CGV 역', - }); - - expect(result.count).toBe(0); - expect(result.theaters).toEqual([]); - }); }); describe('resolveCgvNearestTheater', () => { it('가장 가까운 극장을 선택한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( @@ -418,56 +211,21 @@ describe('resolveCgvNearestTheater', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구 고잔동', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: 'CGV 안산', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ); const result = await resolveCgvNearestTheater( { playDate: '20260315', keyword: '안산 중앙역' }, - { googleMapsApiKey: 'test-google-key' }, + { kakaoRestApiKey: 'test-google-key' }, ); expect(result.location.keyword).toBe('안산 중앙역'); expect(result.theater?.theaterCode).toBe('0211'); }); - - it('후보가 없으면 null을 반환한다', async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 0, - statusMessage: '조회 되었습니다.', - data: [], - }), - ), - ); - - const result = await resolveCgvNearestTheater({ playDate: '20260315', keyword: '안산 중앙역' }); - expect(result.theater).toBeNull(); - }); }); diff --git a/tests/services/cgv/tools/findTheaters.test.ts b/tests/services/cgv/tools/findTheaters.test.ts index e6b4cc2b..7ed637b0 100644 --- a/tests/services/cgv/tools/findTheaters.test.ts +++ b/tests/services/cgv/tools/findTheaters.test.ts @@ -13,7 +13,8 @@ beforeEach(() => { }); afterEach(() => { - delete process.env.GOOGLE_MAPS_API_KEY; + vi.unstubAllEnvs(); + delete process.env.KAKAO_REST_API_KEY; vi.restoreAllMocks(); }); @@ -83,6 +84,7 @@ describe('createFindTheatersTool', () => { }); it('keyword가 있으면 가까운 극장 후보를 우선 반환한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( @@ -101,32 +103,12 @@ describe('createFindTheatersTool', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: 'CGV 안산', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ); @@ -139,7 +121,8 @@ describe('createFindTheatersTool', () => { }); it('환경 변수 구글 키도 사용한다', async () => { - process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); + process.env.KAKAO_REST_API_KEY = 'test-google-key'; mockFetch .mockResolvedValueOnce( new Response( @@ -158,32 +141,12 @@ describe('createFindTheatersTool', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: 'CGV 안산', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ); diff --git a/tests/services/cgv/tools/getTimetable.test.ts b/tests/services/cgv/tools/getTimetable.test.ts index 7cc9c909..7706d4b9 100644 --- a/tests/services/cgv/tools/getTimetable.test.ts +++ b/tests/services/cgv/tools/getTimetable.test.ts @@ -13,7 +13,8 @@ beforeEach(() => { }); afterEach(() => { - delete process.env.GOOGLE_MAPS_API_KEY; + vi.unstubAllEnvs(); + delete process.env.KAKAO_REST_API_KEY; vi.restoreAllMocks(); }); @@ -189,6 +190,7 @@ describe('createGetTimetableTool', () => { }); it('keyword만 있어도 가까운 극장을 골라 시간표를 조회한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( @@ -207,32 +209,12 @@ describe('createGetTimetableTool', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: 'CGV 안산', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ) .mockResolvedValueOnce( @@ -344,11 +326,6 @@ describe('createGetTimetableTool', () => { ); const tool = createGetTimetableTool(); - const result = await tool.handler({ playDate: '20260315', keyword: '안산 중앙역' }); - - const parsed = JSON.parse(result.content[0].text); - expect(parsed.filters.theaterCode).toBeNull(); - expect(parsed.resolvedTheater).toBeNull(); - expect(parsed.timetable).toEqual([]); + await expect(tool.handler({ playDate: '20260315', keyword: '안산 중앙역' })).rejects.toThrow('위치를 좌표로 변환하지 못했습니다'); }); }); diff --git a/tests/services/cgv/tools/searchMovies.test.ts b/tests/services/cgv/tools/searchMovies.test.ts index 2272505e..51e3097f 100644 --- a/tests/services/cgv/tools/searchMovies.test.ts +++ b/tests/services/cgv/tools/searchMovies.test.ts @@ -13,7 +13,8 @@ beforeEach(() => { }); afterEach(() => { - delete process.env.GOOGLE_MAPS_API_KEY; + vi.unstubAllEnvs(); + delete process.env.KAKAO_REST_API_KEY; vi.restoreAllMocks(); }); @@ -82,6 +83,7 @@ describe('createSearchMoviesTool', () => { }); it('keyword만 있어도 가까운 극장을 골라 영화 목록을 조회한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( @@ -100,32 +102,12 @@ describe('createSearchMoviesTool', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: 'CGV 안산', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ) .mockResolvedValueOnce( @@ -165,11 +147,6 @@ describe('createSearchMoviesTool', () => { ); const tool = createSearchMoviesTool(); - const result = await tool.handler({ playDate: '20260315', keyword: '안산 중앙역' }); - - const parsed = JSON.parse(result.content[0].text); - expect(parsed.filters.theaterCode).toBeNull(); - expect(parsed.resolvedTheater).toBeNull(); - expect(parsed.movies).toEqual([]); + await expect(tool.handler({ playDate: '20260315', keyword: '안산 중앙역' })).rejects.toThrow('위치를 좌표로 변환하지 못했습니다'); }); }); diff --git a/tests/services/cu/client.test.ts b/tests/services/cu/client.test.ts index afc76eec..c0e3f971 100644 --- a/tests/services/cu/client.test.ts +++ b/tests/services/cu/client.test.ts @@ -39,6 +39,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -463,26 +464,15 @@ describe('fetchCuStock', () => { describe('geocodeCuAddress', () => { it('Google Geocoding 성공 시 좌표를 반환한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch.mockResolvedValue( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - geometry: { - location: { - lat: 37.3172188, - lng: 126.8354271, - }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '서울시 강남구', address_name: '경기도 안산시 단원구 중앙대로 885', y: 37.3172188, x: 126.8354271 }] }), ), ); const result = await geocodeCuAddress('경기도 안산시 단원구 중앙대로 885', { - googleMapsApiKey: 'test-google-key', + kakaoRestApiKey: 'test-google-key', }); expect(result).toEqual({ latitude: 37.3172188, longitude: 126.8354271 }); @@ -495,55 +485,49 @@ describe('geocodeCuAddress', () => { it('빈 주소면 null을 반환한다', async () => { const result = await geocodeCuAddress(' ', { - googleMapsApiKey: 'test-google-key', + kakaoRestApiKey: 'test-google-key', }); expect(result).toBeNull(); }); it('status가 OK가 아니면 null을 반환한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch.mockResolvedValue( new Response( - JSON.stringify({ - status: 'ZERO_RESULTS', - results: [], - }), + JSON.stringify({ documents: [] }), ), ); const result = await geocodeCuAddress('없는주소', { - googleMapsApiKey: 'test-google-key', + kakaoRestApiKey: 'test-google-key', }); expect(result).toBeNull(); }); it('좌표 결과가 없으면 null을 반환한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch.mockResolvedValue( new Response( - JSON.stringify({ - status: 'OK', - results: [], - }), + JSON.stringify({ documents: [] }), ), ); const result = await geocodeCuAddress('경기도 안산시 단원구 중앙대로 885', { - googleMapsApiKey: 'test-google-key', + kakaoRestApiKey: 'test-google-key', }); expect(result).toBeNull(); }); it('좌표값이 0이면 null을 반환한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch.mockResolvedValue( new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 0, lng: 127.0 } } }], - }), + JSON.stringify({ documents: [{ place_name: '서울시 강남구', address_name: '경기도 안산시 단원구 중앙대로 885', y: 0, x: 127.0 }] }), ), ); const result = await geocodeCuAddress('경기도 안산시 단원구 중앙대로 885', { - googleMapsApiKey: 'test-google-key', + kakaoRestApiKey: 'test-google-key', }); expect(result).toBeNull(); }); diff --git a/tests/services/cu/tools/checkInventory.test.ts b/tests/services/cu/tools/checkInventory.test.ts index f74f0ff2..7574f960 100644 --- a/tests/services/cu/tools/checkInventory.test.ts +++ b/tests/services/cu/tools/checkInventory.test.ts @@ -13,6 +13,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -227,7 +228,7 @@ describe('createCheckInventoryTool', () => { }); it('주소가 있어도 Google 키가 없으면 키워드 매장 결과를 그대로 사용한다', async () => { - delete process.env.GOOGLE_MAPS_API_KEY; + delete process.env.KAKAO_REST_API_KEY; mockFetch .mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] }))) @@ -287,8 +288,9 @@ describe('createCheckInventoryTool', () => { }); it('storeKeyword 기반 지오코딩이 성공하면 좌표 기반 재조회한다', async () => { - const prevGoogleKey = process.env.GOOGLE_MAPS_API_KEY; - process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); + const prevGoogleKey = process.env.KAKAO_REST_API_KEY; + process.env.KAKAO_REST_API_KEY = 'test-google-key'; mockFetch .mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] }))) @@ -337,10 +339,7 @@ describe('createCheckInventoryTool', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.3172, lng: 126.8354 } } }], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '경기도 안산시 단원구 중앙대로 885', y: 37.3172, x: 126.8354 }] }), ), ) .mockResolvedValueOnce(new Response(JSON.stringify({ totalCnt: 0, storeList: [] }))); @@ -349,19 +348,20 @@ describe('createCheckInventoryTool', () => { await tool.handler({ keyword: '치킨', storeKeyword: '안산 중앙역' }); const geocodeUrl = String(mockFetch.mock.calls[3][0]); - expect(geocodeUrl).toContain('maps.googleapis.com/maps/api/geocode/json'); + expect(geocodeUrl).toContain('dapi.kakao.com/v2/local/search/address.json'); const requestInit = mockFetch.mock.calls[4][1] as RequestInit; const body = JSON.parse(String(requestInit.body)); expect(body.latVal).toBe('37.3172'); expect(body.longVal).toBe('126.8354'); expect(body.itemCd).toBe('2202000140047'); - process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; + process.env.KAKAO_REST_API_KEY = prevGoogleKey; }); it('지오코딩 성공 + 재고 시드 없음이면 기본 페이지 타입으로 재조회한다', async () => { - const prevGoogleKey = process.env.GOOGLE_MAPS_API_KEY; - process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); + const prevGoogleKey = process.env.KAKAO_REST_API_KEY; + process.env.KAKAO_REST_API_KEY = 'test-google-key'; mockFetch .mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] }))) @@ -387,10 +387,7 @@ describe('createCheckInventoryTool', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.3172, lng: 126.8354 } } }], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '경기도 안산시 단원구 중앙대로 885', y: 37.3172, x: 126.8354 }] }), ), ) .mockResolvedValueOnce(new Response(JSON.stringify({ totalCnt: 0, storeList: [] }))); @@ -404,6 +401,6 @@ describe('createCheckInventoryTool', () => { expect(body.recommendId).toBe(''); expect(body.pageType).toBe('search_improve'); - process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; + process.env.KAKAO_REST_API_KEY = prevGoogleKey; }); }); diff --git a/tests/services/gs25/client.test.ts b/tests/services/gs25/client.test.ts index dc75a1a8..036a8778 100644 --- a/tests/services/gs25/client.test.ts +++ b/tests/services/gs25/client.test.ts @@ -26,6 +26,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -560,17 +561,15 @@ describe('유틸 함수', () => { describe('geocodeGs25Address', () => { it('Google Geocoding 응답을 좌표로 변환한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch.mockResolvedValue( new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.3172, lng: 126.8354 } } }], - }), + JSON.stringify({ documents: [{ place_name: '서울시 강남구', address_name: '경기도 안산시 단원구 중앙대로 885', y: 37.3172, x: 126.8354 }] }), ), ); const result = await geocodeGs25Address('경기도 안산시 단원구 중앙대로 885', { - googleMapsApiKey: 'test-key', + kakaoRestApiKey: 'test-key', }); expect(result).toEqual({ latitude: 37.3172, longitude: 126.8354 }); @@ -583,43 +582,43 @@ describe('geocodeGs25Address', () => { it('주소가 비어 있으면 null을 반환한다', async () => { const result = await geocodeGs25Address(' ', { - googleMapsApiKey: 'test-key', + kakaoRestApiKey: 'test-key', }); expect(result).toBeNull(); }); it('status가 OK가 아니면 null을 반환한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch.mockResolvedValue( - new Response(JSON.stringify({ status: 'ZERO_RESULTS', results: [] })), + new Response(JSON.stringify({ documents: [] })), ); const result = await geocodeGs25Address('없는 주소', { - googleMapsApiKey: 'test-key', + kakaoRestApiKey: 'test-key', }); expect(result).toBeNull(); }); it('결과 location이 없으면 null을 반환한다', async () => { - mockFetch.mockResolvedValue(new Response(JSON.stringify({ status: 'OK', results: [{}] }))); + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ documents: [{ place_name: '서울시 강남구', address_name: '경기도 안산시 단원구 중앙대로 885', y: undefined, x: undefined }] }))); const result = await geocodeGs25Address('서울 강남구', { - googleMapsApiKey: 'test-key', + kakaoRestApiKey: 'test-key', }); expect(result).toBeNull(); }); it('좌표가 0이면 null을 반환한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch.mockResolvedValue( new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 0, lng: 0 } } }], - }), + JSON.stringify({ documents: [{ place_name: '서울시 강남구', address_name: '경기도 안산시 단원구 중앙대로 885', y: 0, x: 0 }] }), ), ); const result = await geocodeGs25Address('서울 강남구', { - googleMapsApiKey: 'test-key', + kakaoRestApiKey: 'test-key', }); expect(result).toBeNull(); }); diff --git a/tests/services/gs25/tools/checkInventory.test.ts b/tests/services/gs25/tools/checkInventory.test.ts index 6a1aa10b..ead61559 100644 --- a/tests/services/gs25/tools/checkInventory.test.ts +++ b/tests/services/gs25/tools/checkInventory.test.ts @@ -15,6 +15,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -105,8 +106,9 @@ describe('createCheckInventoryTool', () => { }); it('storeKeyword 기반 지오코딩이 성공하면 좌표를 반영한다', async () => { - const prevGoogleKey = process.env.GOOGLE_MAPS_API_KEY; - process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); + const prevGoogleKey = process.env.KAKAO_REST_API_KEY; + process.env.KAKAO_REST_API_KEY = 'test-google-key'; // 1. 기본 매장 목록 조회 (지오코딩용) mockFetch.mockResolvedValueOnce( @@ -116,10 +118,7 @@ describe('createCheckInventoryTool', () => { // 2. Google Geocoding API 응답 mockFetch.mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.5, lng: 127 } } }], - }), + JSON.stringify({ documents: [{ place_name: '강남역', address_name: '서울 강남구 강남대로 1', y: 37.5, x: 127 }] }), ), ); @@ -148,7 +147,7 @@ describe('createCheckInventoryTool', () => { expect(parsed.geocodeUsed).toBe(true); expect(parsed.location).toEqual({ latitude: 37.5, longitude: 127 }); - process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; + process.env.KAKAO_REST_API_KEY = prevGoogleKey; }); it('상품 검색 결과가 없으면 keyword fallback을 사용한다', async () => { @@ -182,8 +181,9 @@ describe('createCheckInventoryTool', () => { }); it('위치 기반 재고 조회 결과가 있으면 storeKeyword 문자열 필터가 비어도 가까운 재고 매장을 유지한다', async () => { - const prevGoogleKey = process.env.GOOGLE_MAPS_API_KEY; - process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); + const prevGoogleKey = process.env.KAKAO_REST_API_KEY; + process.env.KAKAO_REST_API_KEY = 'test-google-key'; mockFetch.mockResolvedValueOnce( createStoreStockResponse([ @@ -193,10 +193,7 @@ describe('createCheckInventoryTool', () => { mockFetch.mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.4979, lng: 127.0276 } } }], - }), + JSON.stringify({ documents: [{ place_name: '강남역', address_name: '서울 강남구 강남대로 1', y: 37.4979, x: 127.0276 }] }), ), ); @@ -226,6 +223,6 @@ describe('createCheckInventoryTool', () => { expect(parsed.inventory.stores[0].storeCode).toBe('near'); expect(parsed.inventory.inStockStoreCount).toBe(1); - process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; + process.env.KAKAO_REST_API_KEY = prevGoogleKey; }); }); diff --git a/tests/services/gs25/tools/findNearbyStores.test.ts b/tests/services/gs25/tools/findNearbyStores.test.ts index ee253eb6..4250701f 100644 --- a/tests/services/gs25/tools/findNearbyStores.test.ts +++ b/tests/services/gs25/tools/findNearbyStores.test.ts @@ -15,6 +15,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -103,16 +104,14 @@ describe('createFindNearbyStoresTool', () => { }); it('좌표가 없고 keyword가 있으면 지오코딩을 시도한다', async () => { - const prevGoogleKey = process.env.GOOGLE_MAPS_API_KEY; - process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); + const prevGoogleKey = process.env.KAKAO_REST_API_KEY; + process.env.KAKAO_REST_API_KEY = 'test-google-key'; mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.5, lng: 127 } } }], - }), + JSON.stringify({ documents: [{ place_name: '강남역', address_name: '서울 강남구', y: 37.5, x: 127 }] }), ), ) .mockResolvedValueOnce( @@ -130,20 +129,18 @@ describe('createFindNearbyStoresTool', () => { expect(parsed.geocodeUsed).toBe(true); expect(parsed.location).toEqual({ latitude: 37.5, longitude: 127 }); - process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; + process.env.KAKAO_REST_API_KEY = prevGoogleKey; }); it('지오코딩이 성공하면 좌표 기반 매장 조회를 사용하고 키워드 필터가 비어도 가까운 매장을 반환한다', async () => { - const prevGoogleKey = process.env.GOOGLE_MAPS_API_KEY; - process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); + const prevGoogleKey = process.env.KAKAO_REST_API_KEY; + process.env.KAKAO_REST_API_KEY = 'test-google-key'; mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.4979, lng: 127.0276 } } }], - }), + JSON.stringify({ documents: [{ place_name: '강남역', address_name: '서울 강남구', y: 37.4979, x: 127.0276 }] }), ), ) .mockResolvedValueOnce( @@ -181,20 +178,18 @@ describe('createFindNearbyStoresTool', () => { expect(parsed.count).toBe(2); expect(parsed.stores[0].storeCode).toBe('near'); - process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; + process.env.KAKAO_REST_API_KEY = prevGoogleKey; }); it('좌표 기반 매장 조회가 비면 상품 재고 조회를 이용해 가까운 GS25 매장으로 대체한다', async () => { - const prevGoogleKey = process.env.GOOGLE_MAPS_API_KEY; - process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); + const prevGoogleKey = process.env.KAKAO_REST_API_KEY; + process.env.KAKAO_REST_API_KEY = 'test-google-key'; mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.4979, lng: 127.0276 } } }], - }), + JSON.stringify({ documents: [{ place_name: '강남역', address_name: '서울 강남구', y: 37.4979, x: 127.0276 }] }), ), ) .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))) @@ -222,20 +217,18 @@ describe('createFindNearbyStoresTool', () => { expect(parsed.count).toBe(1); expect(parsed.stores[0].storeCode).toBe('near'); - process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; + process.env.KAKAO_REST_API_KEY = prevGoogleKey; }); it('좌표 기반 매장 조회가 비고 fallback 재고 조회가 실패해도 빈 매장 결과를 반환한다', async () => { - const prevGoogleKey = process.env.GOOGLE_MAPS_API_KEY; - process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); + const prevGoogleKey = process.env.KAKAO_REST_API_KEY; + process.env.KAKAO_REST_API_KEY = 'test-google-key'; mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.4979, lng: 127.0276 } } }], - }), + JSON.stringify({ documents: [{ place_name: '강남역', address_name: '서울 강남구', y: 37.4979, x: 127.0276 }] }), ), ) .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))) @@ -250,20 +243,18 @@ describe('createFindNearbyStoresTool', () => { expect(parsed.fallbackUsed).toBe(true); expect(parsed.count).toBe(0); - process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; + process.env.KAKAO_REST_API_KEY = prevGoogleKey; }); it('fallback 상품 재고 조회도 비면 빈 매장 결과를 반환한다', async () => { - const prevGoogleKey = process.env.GOOGLE_MAPS_API_KEY; - process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); + const prevGoogleKey = process.env.KAKAO_REST_API_KEY; + process.env.KAKAO_REST_API_KEY = 'test-google-key'; mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.4979, lng: 127.0276 } } }], - }), + JSON.stringify({ documents: [{ place_name: '강남역', address_name: '서울 강남구', y: 37.4979, x: 127.0276 }] }), ), ) .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))) @@ -277,6 +268,6 @@ describe('createFindNearbyStoresTool', () => { expect(parsed.fallbackUsed).toBe(true); expect(parsed.count).toBe(0); - process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; + process.env.KAKAO_REST_API_KEY = prevGoogleKey; }); }); diff --git a/tests/services/lottecinema/location.test.ts b/tests/services/lottecinema/location.test.ts index 2cbf6314..24c47474 100644 --- a/tests/services/lottecinema/location.test.ts +++ b/tests/services/lottecinema/location.test.ts @@ -7,7 +7,6 @@ import { __testOnlyClearLotteCinemaLocationCaches, fetchLotteCinemaNearbyTheaters, resolveLotteCinemaLocation, - resolveLotteCinemaNearestTheater, } from '../../../src/services/lottecinema/location.js'; const mockFetch = vi.fn(); @@ -61,6 +60,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); __testOnlyClearLotteCinemaLocationCaches(); }); @@ -79,81 +79,17 @@ describe('resolveLotteCinemaLocation', () => { expect(mockFetch).not.toHaveBeenCalled(); }); - it('구글 지오코드 결과를 캐시한다', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), - ), - ); - - const first = await resolveLotteCinemaLocation( - { keyword: '안산 중앙역' }, - { googleMapsApiKey: 'test-google-key' }, - ); - const second = await resolveLotteCinemaLocation( - { keyword: '안산 중앙역' }, - { googleMapsApiKey: 'test-google-key' }, - ); - - expect(first.geocodeUsed).toBe(true); - expect(first.formattedAddress).toContain('안산시'); - expect(second.latitude).toBe(37.3172); - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - - it('지오코드 실패 결과도 캐시한다', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - status: 'ZERO_RESULTS', - results: [], - }), - ), - ); - - const first = await resolveLotteCinemaLocation( - { keyword: '존재하지않는역' }, - { googleMapsApiKey: 'test-google-key' }, - ); - const second = await resolveLotteCinemaLocation( - { keyword: '존재하지않는역' }, - { googleMapsApiKey: 'test-google-key' }, - ); - - expect(first.latitude).toBeNull(); - expect(second.longitude).toBeNull(); - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - it('지오코드 주소가 없으면 formattedAddress를 null로 둔다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch.mockResolvedValue( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '', y: 37.3172, x: 126.839 }] }), ), ); const resolved = await resolveLotteCinemaLocation( { keyword: '안산 중앙역' }, - { googleMapsApiKey: 'test-google-key' }, + { kakaoRestApiKey: 'test-google-key' }, ); expect(resolved.geocodeUsed).toBe(true); @@ -171,27 +107,18 @@ describe('resolveLotteCinemaLocation', () => { describe('fetchLotteCinemaNearbyTheaters', () => { it('좌표 기준으로 가까운 극장을 거리순 정렬한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ) .mockResolvedValueOnce(createTicketingResponse()); const result = await fetchLotteCinemaNearbyTheaters( { keyword: '안산 중앙역', playDate: '20260315', limit: 2 }, - { googleMapsApiKey: 'test-google-key' }, + { kakaoRestApiKey: 'test-google-key' }, ); expect(result.geocodeUsed).toBe(true); @@ -199,81 +126,6 @@ describe('fetchLotteCinemaNearbyTheaters', () => { expect(result.theaters[0].distanceKm).toBeLessThan(result.theaters[1].distanceKm as number); }); - it('지오코드 없이도 키워드 매칭으로 극장을 찾는다', async () => { - mockFetch.mockResolvedValue(createTicketingResponse()); - - const result = await fetchLotteCinemaNearbyTheaters({ - keyword: '안산 중앙역', - playDate: '20260315', - limit: 2, - }); - - expect(result.count).toBe(2); - expect(result.theaters[0].theaterName).toBe('안산중앙'); - expect(result.theaters[0].distanceKm).toBeNull(); - }); - - it('극장명이 아니라 주소로도 키워드 매칭을 수행한다', async () => { - mockFetch.mockResolvedValue(createTicketingResponse()); - - const result = await fetchLotteCinemaNearbyTheaters({ - keyword: '고잔동', - playDate: '20260315', - limit: 2, - }); - - expect(result.count).toBe(2); - expect(result.theaters[0].address).toContain('고잔동'); - }); - - it('주소가 비어 있어도 극장명으로 키워드 매칭을 수행한다', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - IsOK: true, - Cinemas: { - Cinemas: { - Items: [ - { - CinemaID: '1016', - CinemaNameKR: '월드타워', - DivisionCode: '1', - DetailDivisionCode: '0001', - Latitude: '37.5132941', - Longitude: '127.104215', - CinemaAddrSummary: '', - }, - ], - }, - }, - Movies: { Movies: { Items: [] } }, - }), - ), - ); - - const result = await fetchLotteCinemaNearbyTheaters({ - keyword: '월드타워', - playDate: '20260315', - limit: 2, - }); - - expect(result.count).toBe(1); - expect(result.theaters[0].theaterName).toBe('월드타워'); - }); - - it('불용어만 있으면 매칭 결과를 비운다', async () => { - mockFetch.mockResolvedValue(createTicketingResponse()); - - const result = await fetchLotteCinemaNearbyTheaters({ - keyword: '영화', - playDate: '20260315', - limit: 2, - }); - - expect(result.count).toBe(0); - expect(result.theaters).toEqual([]); - }); - it('limit이 음수여도 최소 1개는 반환한다', async () => { mockFetch.mockResolvedValue(createTicketingResponse()); @@ -347,16 +199,18 @@ describe('fetchLotteCinemaNearbyTheaters', () => { }); }); -describe('resolveLotteCinemaNearestTheater', () => { - it('매칭되는 극장이 없으면 null을 반환한다', async () => { - mockFetch.mockResolvedValue(createTicketingResponse()); - const result = await resolveLotteCinemaNearestTheater({ - keyword: '제주공항', - playDate: '20260315', - }); - expect(result.location.keyword).toBe('제주공항'); - expect(result.theater).toBeNull(); - }); +it.each(['안산 중앙역', '고잔동', '안산중앙', '극장', '없는곳'])('무료 위치 해석에 실패한 %s는 거리 없는 성공으로 대체하지 않는다', async (keyword) => { + await expect(fetchLotteCinemaNearbyTheaters({ keyword, playDate: '20261005' })).rejects.toThrow('위치를 좌표로 변환하지 못했습니다'); + expect(mockFetch).not.toHaveBeenCalled(); +}); +it('한국 범위 밖 좌표는 조회에 사용하지 않는다', async () => { + await expect(resolveLotteCinemaLocation({ latitude: 0, longitude: 0 })).rejects.toThrow('유효한 위도'); +}); +it('위치 입력이나 극장 좌표가 없으면 가까운 극장을 만들지 않는다', async () => { + mockFetch.mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ IsOK: true, Cinemas: { Cinemas: { Items: [] } }, Movies: { Movies: { Items: [] } } })))); + const { resolveLotteCinemaNearestTheater } = await import('../../../src/services/lottecinema/location.js'); + expect((await fetchLotteCinemaNearbyTheaters({ playDate: '20261005' })).theaters).toEqual([]); + expect((await resolveLotteCinemaNearestTheater({ latitude: 35.115, longitude: 129.041, playDate: '20261005' })).theater).toBeNull(); }); diff --git a/tests/services/lottecinema/tools/findNearbyTheaters.test.ts b/tests/services/lottecinema/tools/findNearbyTheaters.test.ts index ea4f0a92..6d52541f 100644 --- a/tests/services/lottecinema/tools/findNearbyTheaters.test.ts +++ b/tests/services/lottecinema/tools/findNearbyTheaters.test.ts @@ -15,6 +15,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); __testOnlyClearLotteCinemaLocationCaches(); }); @@ -64,20 +65,11 @@ describe('createFindNearbyTheatersTool', () => { }); it('위치 키워드로도 주변 지점을 찾는다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ) .mockResolvedValueOnce( diff --git a/tests/services/lottecinema/tools/getRemainingSeats.test.ts b/tests/services/lottecinema/tools/getRemainingSeats.test.ts index 5ccc0323..707fdef4 100644 --- a/tests/services/lottecinema/tools/getRemainingSeats.test.ts +++ b/tests/services/lottecinema/tools/getRemainingSeats.test.ts @@ -15,6 +15,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); __testOnlyClearLotteCinemaLocationCaches(); }); @@ -202,20 +203,11 @@ describe('createGetRemainingSeatsTool', () => { }); it('위치 키워드로 최근접 극장을 찾아 좌석을 조회한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ) .mockResolvedValueOnce( diff --git a/tests/services/lottecinema/tools/listNowShowing.test.ts b/tests/services/lottecinema/tools/listNowShowing.test.ts index 23fe4f70..342d9afa 100644 --- a/tests/services/lottecinema/tools/listNowShowing.test.ts +++ b/tests/services/lottecinema/tools/listNowShowing.test.ts @@ -7,7 +7,7 @@ import { __testOnlyClearLotteCinemaLocationCaches } from '../../../../src/servic import { createListNowShowingTool } from '../../../../src/services/lottecinema/tools/listNowShowing.js'; const mockFetch = vi.fn(); -const originalGoogleMapsApiKey = process.env.GOOGLE_MAPS_API_KEY; +const originalGoogleMapsApiKey = process.env.KAKAO_REST_API_KEY; beforeEach(() => { mockFetch.mockReset(); @@ -16,9 +16,10 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); __testOnlyClearLotteCinemaLocationCaches(); - process.env.GOOGLE_MAPS_API_KEY = originalGoogleMapsApiKey; + process.env.KAKAO_REST_API_KEY = originalGoogleMapsApiKey; }); describe('createListNowShowingTool', () => { @@ -66,20 +67,11 @@ describe('createListNowShowingTool', () => { }); it('위치 키워드로 최근접 극장을 찾아 영화/회차를 조회한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동 535', - geometry: { - location: { lat: 37.3172, lng: 126.839 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), ), ) .mockResolvedValueOnce( @@ -215,13 +207,14 @@ describe('createListNowShowingTool', () => { ), ); - process.env.GOOGLE_MAPS_API_KEY = ''; + process.env.KAKAO_REST_API_KEY = ''; const tool = createListNowShowingTool(); - const result = await tool.handler({ playDate: '20260310', keyword: '제주공항' }); - const parsed = JSON.parse(result.content[0].text); - - expect(parsed.filters.theaterId).toBeNull(); - expect(parsed.resolvedTheater).toBeNull(); - expect(parsed.counts.showtimes).toBe(0); + await expect(tool.handler({ playDate: '20260310', keyword: '제주공항' })).rejects.toThrow('위치를 좌표로 변환하지 못했습니다'); + expect(mockFetch).not.toHaveBeenCalled(); }); }); +it('좌표가 유효해도 주변 극장이 없으면 목록을 비운다', async () => { + mockFetch.mockResolvedValue(new Response(JSON.stringify({ IsOK: true, Cinemas: { Cinemas: { Items: [] } }, Movies: { Movies: { Items: [] } } }))); + const result = await createListNowShowingTool().handler({ latitude: 35.115, longitude: 129.041, playDate: '20261005' }); + expect(JSON.parse(result.content[0].text).resolvedTheater).toBeNull(); +}); diff --git a/tests/services/lottemart/client.edge.test.ts b/tests/services/lottemart/client.edge.test.ts index 12b3e50d..ab2bf58d 100644 --- a/tests/services/lottemart/client.edge.test.ts +++ b/tests/services/lottemart/client.edge.test.ts @@ -24,6 +24,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -135,21 +136,13 @@ describe('geocodeLotteMartAddress 예외 분기', () => { expect(first).toBeNull(); expect(second).toBeNull(); - expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).not.toHaveBeenCalled(); }); }); describe('fetchLotteMartStores 보조 분기', () => { it('키워드 지오코딩에 실패하면 좌표 없이 조회를 계속한다', async () => { mockFetch - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - status: 'ZERO_RESULTS', - results: [], - }), - ), - ) .mockResolvedValueOnce(createSessionResponse()) .mockResolvedValueOnce( new Response(` @@ -394,3 +387,7 @@ describe('searchLotteMartProducts 예외 처리', () => { expect(mockFetch).toHaveBeenCalledTimes(4); }); }); +it('역사적 내부 매장 함수도 유효하지 않은 지역은 거부한다', async () => { + await expect(fetchLotteMartStores({ area: '없는지역' })).rejects.toThrow('지원하지 않는 지역'); + expect(mockFetch).not.toHaveBeenCalled(); +}); diff --git a/tests/services/lottemart/client.test.ts b/tests/services/lottemart/client.test.ts index c2dc38ed..bc94e9d2 100644 --- a/tests/services/lottemart/client.test.ts +++ b/tests/services/lottemart/client.test.ts @@ -23,6 +23,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -119,7 +120,8 @@ describe('geocodeLotteMartAddress', () => { googleMapsApiKey: 'test-key', }); - expect(result).toEqual({ latitude: 37.5, longitude: 127.0 }); + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); }); }); @@ -187,19 +189,11 @@ describe('fetchLotteMartStores', () => { expect(result.location).toEqual({ latitude: 37.5354, longitude: 127.0958 }); expect(result.stores).toHaveLength(1); expect(result.stores[0].storeCode).toBe('2301'); - expect(result.stores[0].distanceM).toBe(0); + expect(result.stores[0].distanceM).toBeNull(); }); it('키워드 지오코딩을 사용한 뒤 키워드로 매장을 필터링한다', async () => { mockFetch - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - status: 'OK', - results: [{ geometry: { location: { lat: 37.5, lng: 127.0 } } }], - }), - ), - ) .mockResolvedValueOnce(createSessionResponse()) .mockResolvedValueOnce( new Response(` @@ -238,7 +232,7 @@ describe('fetchLotteMartStores', () => { }, ); - expect(result.geocodeUsed).toBe(true); + expect(result.geocodeUsed).toBe(false); expect(result.stores[0].storeName).toBe('강변점'); }); @@ -339,11 +333,11 @@ describe('fetchLotteMartStores', () => { }, ); - expect(result.geocodeUsed).toBe(true); + expect(result.geocodeUsed).toBe(false); expect(result.stores.map((store) => store.storeCode)).toEqual(['2415']); - expect(mockFetch).toHaveBeenCalledTimes(4); - expect(String(mockFetch.mock.calls[1]?.[1]?.body || '')).toContain('m_area=%EC%84%9C%EC%9A%B8'); - expect(String(mockFetch.mock.calls[2]?.[1]?.body || '')).toContain('m_area=%EA%B2%BD%EA%B8%B0'); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(String(mockFetch.mock.calls[0]?.[1]?.body || '')).toContain('m_area=%EC%84%9C%EC%9A%B8'); + expect(String(mockFetch.mock.calls[1]?.[1]?.body || '')).toContain('m_area=%EA%B2%BD%EA%B8%B0'); }); }); diff --git a/tests/services/lottemart/tools/findNearbyStores.test.ts b/tests/services/lottemart/tools/findNearbyStores.test.ts index 39f7b6bb..540b1dd4 100644 --- a/tests/services/lottemart/tools/findNearbyStores.test.ts +++ b/tests/services/lottemart/tools/findNearbyStores.test.ts @@ -17,6 +17,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -68,7 +69,7 @@ describe('createFindNearbyStoresTool', () => { const parsed = JSON.parse(result.content[0].text); expect(parsed.location).toEqual({ latitude: 37.5354, longitude: 127.0958 }); - expect(parsed.stores[0].distanceM).toBe(0); + expect(parsed.stores[0].distanceM).toBeNull(); }); it('지역이 없으면 전체 지역 기준 응답을 구성하고 전달된 API 키를 우선 사용한다', async () => { diff --git a/tests/services/megabox/location.test.ts b/tests/services/megabox/location.test.ts index b6e05791..3fd27afe 100644 --- a/tests/services/megabox/location.test.ts +++ b/tests/services/megabox/location.test.ts @@ -19,6 +19,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -41,25 +42,16 @@ describe('resolveMegaboxLocation', () => { }); it('키워드를 구글 지오코드로 보강해 좌표와 지역 코드를 찾는다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch.mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구 고잔동', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구 고잔동', y: 37.3171, x: 126.8389 }] }), ), ); const result = await resolveMegaboxLocation( { keyword: '안산 중앙역' }, - { googleMapsApiKey: 'test-google-key' }, + { kakaoRestApiKey: 'test-google-key' }, ); expect(result).toEqual({ @@ -71,122 +63,17 @@ describe('resolveMegaboxLocation', () => { }); }); - it('지오코드 address_components의 short_name으로도 지역 코드를 찾는다', async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - address_components: [{ long_name: '', short_name: '경기도' }], - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), - ), - ); - - const result = await resolveMegaboxLocation( - { keyword: '안산 중앙역' }, - { googleMapsApiKey: 'test-google-key' }, - ); - - expect(result.areaCode).toBe('41'); - expect(result.latitude).toBe(37.3171); - expect(result.longitude).toBe(126.8389); - expect(result.geocodeUsed).toBe(true); - }); - - it('좌표만 있어도 역지오코드로 지역 코드를 보강한다', async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - address_components: [{ long_name: '경기도' }], - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), - ), - ); - - const result = await resolveMegaboxLocation( - { latitude: 37.3171, longitude: 126.8389 }, - { googleMapsApiKey: 'test-google-key' }, - ); - - expect(result.areaCode).toBe('41'); - expect(result.geocodeUsed).toBe(true); - }); - - it('지오코드 실패 시 기본 서울 좌표와 지역 코드를 사용한다', async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - status: 'ZERO_RESULTS', - results: [], - }), - ), - ); - - const result = await resolveMegaboxLocation( - { keyword: '없는 위치' }, - { googleMapsApiKey: 'test-google-key' }, - ); - - expect(result.areaCode).toBe('11'); - expect(result.latitude).toBe(37.5665); - expect(result.longitude).toBe(126.978); - expect(result.geocodeUsed).toBe(false); - }); - - it('지오코드가 OK여도 결과가 비면 기본 서울 좌표와 지역 코드를 사용한다', async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - status: 'OK', - results: [], - }), - ), - ); - - const result = await resolveMegaboxLocation( - { keyword: '결과없음' }, - { googleMapsApiKey: 'test-google-key' }, - ); - - expect(result.areaCode).toBe('11'); - expect(result.latitude).toBe(37.5665); - expect(result.longitude).toBe(126.978); - expect(result.geocodeUsed).toBe(false); - }); - it('지역 코드를 찾지 못해도 좌표는 사용하고 지역 코드는 기본값으로 둔다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch.mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 어딘가', - geometry: { - location: { lat: 35.1234, lng: 128.1234 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 어딘가', y: 35.1234, x: 128.1234 }] }), ), ); const result = await resolveMegaboxLocation( { keyword: '어딘가' }, - { googleMapsApiKey: 'test-google-key' }, + { kakaoRestApiKey: 'test-google-key' }, ); expect(result.latitude).toBe(35.1234); @@ -194,76 +81,15 @@ describe('resolveMegaboxLocation', () => { expect(result.areaCode).toBe('11'); expect(result.geocodeUsed).toBe(true); }); - - it('같은 키워드 지오코드 결과를 캐시에서 재사용한다', async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), - ), - ); - - const first = await resolveMegaboxLocation( - { keyword: '안산 중앙역' }, - { googleMapsApiKey: 'test-google-key' }, - ); - const second = await resolveMegaboxLocation( - { keyword: '안산 중앙역' }, - { googleMapsApiKey: 'test-google-key' }, - ); - - expect(first.areaCode).toBe('41'); - expect(second.areaCode).toBe('41'); - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - - it('좌표 없는 지오코드 결과는 null로 처리한다', async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - status: 'OK', - results: [{}], - }), - ), - ); - - const result = await resolveMegaboxLocation( - { keyword: '좌표없는결과' }, - { googleMapsApiKey: 'test-google-key' }, - ); - - expect(result.areaCode).toBe('11'); - expect(result.latitude).toBe(37.5665); - expect(result.longitude).toBe(126.978); - expect(result.geocodeUsed).toBe(false); - }); }); describe('fetchMegaboxNearbyTheaters', () => { it('지오코드된 위치 기준으로 근처 극장을 거리순 정렬한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( @@ -285,7 +111,7 @@ describe('fetchMegaboxNearbyTheaters', () => { const result = await fetchMegaboxNearbyTheaters( { keyword: '안산 중앙역', playDate: '20260315', limit: 2 }, - { googleMapsApiKey: 'test-google-key' }, + { kakaoRestApiKey: 'test-google-key' }, ); expect(result.areaCode).toBe('41'); @@ -295,20 +121,11 @@ describe('fetchMegaboxNearbyTheaters', () => { }); it('nearest theater를 자동으로 선택한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( @@ -324,7 +141,7 @@ describe('fetchMegaboxNearbyTheaters', () => { const result = await resolveMegaboxNearestTheater( { keyword: '안산 중앙역', playDate: '20260315' }, - { googleMapsApiKey: 'test-google-key' }, + { kakaoRestApiKey: 'test-google-key' }, ); expect(result.location.areaCode).toBe('41'); @@ -352,7 +169,7 @@ describe('fetchMegaboxNearbyTheaters', () => { playDate: '20260315', limit: 0, }, - { googleMapsApiKey: 'test-google-key' }, + { kakaoRestApiKey: 'test-google-key' }, ); expect(result.count).toBe(1); @@ -374,10 +191,24 @@ describe('fetchMegaboxNearbyTheaters', () => { areaCode: '41', playDate: '20260315', }, - { googleMapsApiKey: 'test-google-key' }, + { kakaoRestApiKey: 'test-google-key' }, ); expect(result.location.areaCode).toBe('41'); expect(result.theater).toBeNull(); }); }); +describe('무료 위치 해석 계약', () => { + it('키워드 검색 실패는 서울 성공으로 대체하지 않는다', async () => { + await expect(resolveMegaboxLocation({ keyword: '부산역' })).rejects.toThrow('위치를 좌표로 변환하지 못했습니다'); + }); + it('좌표를 명시하면 사용자 지오코딩을 생략하고 전체 지역을 검색한다', async () => { + const result = await resolveMegaboxLocation({ latitude: 35.115, longitude: 129.041 }); + expect(result).toMatchObject({ latitude: 35.115, longitude: 129.041, areaCode: '', geocodeUsed: false }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it('부산역 주소에서 부산 지역을 결정한다', async () => { + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ documents: [{ place_name: '부산역', address_name: '부산 동구', x: '129.041', y: '35.115' }] }))); + expect(await resolveMegaboxLocation({ keyword: '부산역' }, { kakaoRestApiKey: 'free' })).toMatchObject({ areaCode: '26', latitude: 35.115 }); + }); +}); diff --git a/tests/services/megabox/tools/findNearbyTheaters.test.ts b/tests/services/megabox/tools/findNearbyTheaters.test.ts index 49e349da..0aab1e5e 100644 --- a/tests/services/megabox/tools/findNearbyTheaters.test.ts +++ b/tests/services/megabox/tools/findNearbyTheaters.test.ts @@ -15,6 +15,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -78,21 +79,12 @@ describe('createFindNearbyTheatersTool', () => { }); it('위치 키워드를 지오코드해 안산 기준 지점을 반환한다', async () => { - process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); + process.env.KAKAO_REST_API_KEY = 'test-google-key'; mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( @@ -112,6 +104,6 @@ describe('createFindNearbyTheatersTool', () => { const parsed = JSON.parse(result.content[0].text); expect(parsed.areaCode).toBe('41'); expect(parsed.theaters[0].theaterId).toBe('4431'); - delete process.env.GOOGLE_MAPS_API_KEY; + delete process.env.KAKAO_REST_API_KEY; }); }); diff --git a/tests/services/megabox/tools/getRemainingSeats.test.ts b/tests/services/megabox/tools/getRemainingSeats.test.ts index 636651bb..b0ced0d0 100644 --- a/tests/services/megabox/tools/getRemainingSeats.test.ts +++ b/tests/services/megabox/tools/getRemainingSeats.test.ts @@ -15,6 +15,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -207,21 +208,12 @@ describe('createGetRemainingSeatsTool', () => { }); it('theaterId가 없어도 위치 키워드로 가장 가까운 지점 좌석을 조회한다', async () => { - process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); + process.env.KAKAO_REST_API_KEY = 'test-google-key'; mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( @@ -262,6 +254,6 @@ describe('createGetRemainingSeatsTool', () => { expect(parsed.filters.theaterId).toBe('4431'); expect(parsed.filters.areaCode).toBe('41'); expect(parsed.resolvedTheater.theaterName).toBe('안산중앙'); - delete process.env.GOOGLE_MAPS_API_KEY; + delete process.env.KAKAO_REST_API_KEY; }); }); diff --git a/tests/services/megabox/tools/listNowShowing.test.ts b/tests/services/megabox/tools/listNowShowing.test.ts index 420acf44..e31a91ab 100644 --- a/tests/services/megabox/tools/listNowShowing.test.ts +++ b/tests/services/megabox/tools/listNowShowing.test.ts @@ -15,6 +15,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -95,21 +96,12 @@ describe('createListNowShowingTool', () => { }); it('theaterId가 없어도 위치 키워드로 가장 가까운 지점을 자동 선택한다', async () => { - process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); + process.env.KAKAO_REST_API_KEY = 'test-google-key'; mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '대한민국 경기도 안산시 단원구', - geometry: { - location: { lat: 37.3171, lng: 126.8389 }, - }, - }, - ], - }), + JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), ), ) .mockResolvedValueOnce( @@ -152,6 +144,6 @@ describe('createListNowShowingTool', () => { expect(parsed.filters.theaterId).toBe('4431'); expect(parsed.filters.areaCode).toBe('41'); expect(parsed.resolvedTheater.theaterName).toBe('안산중앙'); - delete process.env.GOOGLE_MAPS_API_KEY; + delete process.env.KAKAO_REST_API_KEY; }); }); diff --git a/tests/services/opinet/client.test.ts b/tests/services/opinet/client.test.ts index be650605..c957c82b 100644 --- a/tests/services/opinet/client.test.ts +++ b/tests/services/opinet/client.test.ts @@ -113,15 +113,13 @@ describe('opinet client', () => { }); it('위경도와 location 기반 반경 검색을 지원한다', async () => { + vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); const fetchImpl = vi .fn() .mockResolvedValueOnce(new Response(JSON.stringify({ RESULT: { OIL: [] } }))) .mockResolvedValueOnce( new Response( - JSON.stringify({ - status: 'OK', - results: [{ formatted_address: '서울 강남역', geometry: { location: { lat: 37.4979, lng: 127.0276 } } }], - }), + JSON.stringify({ documents: [{ place_name: '강남역', address_name: '서울 강남역', y: 37.4979, x: 127.0276 }] }), ), ) .mockResolvedValueOnce(new Response(JSON.stringify({ RESULT: { OIL: [] } }))); @@ -132,7 +130,7 @@ describe('opinet client', () => { ); const locationResult = await fetchOpinetStationsAround( { location: '강남역' }, - { apiKey: 'key', googleMapsApiKey: 'google-key', fetchImpl }, + { apiKey: 'key', kakaoRestApiKey: 'google-key', fetchImpl }, ); expect(coordinateResult.location).toMatchObject({ diff --git a/tests/services/opinet/location.test.ts b/tests/services/opinet/location.test.ts index cc6c3844..e09c2c78 100644 --- a/tests/services/opinet/location.test.ts +++ b/tests/services/opinet/location.test.ts @@ -1,121 +1,25 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - __testOnlyClearOpinetGeocodeCache, - resolveOpinetLocation, - wgs84ToKatec, -} from '../../../src/services/opinet/location.js'; - -afterEach(() => { - __testOnlyClearOpinetGeocodeCache(); - vi.restoreAllMocks(); +import { afterEach, expect, it, vi } from 'vitest'; +import { __testOnlyClearOpinetGeocodeCache, resolveOpinetLocation, wgs84ToKatec } from '../../../src/services/opinet/location.js'; +afterEach(() => { __testOnlyClearOpinetGeocodeCache(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); }); +it('WGS84를 KATEC으로 변환한다', () => { + expect(wgs84ToKatec(37.4979, 127.0276)).toEqual({ x: 314213.309, y: 544413.58 }); + expect(() => wgs84ToKatec(NaN, 127)).toThrow('위도/경도'); }); - -describe('opinet location', () => { - it('WGS84 위경도를 KATEC 좌표로 변환한다', () => { - expect(wgs84ToKatec(37.4979, 127.0276)).toEqual({ - x: 314213.309, - y: 544413.58, - }); - }); - - it('KATEC 좌표를 직접 받으면 변환 없이 사용한다', async () => { - await expect(resolveOpinetLocation({ x: 1, y: 2, location: '직접' })).resolves.toEqual({ - katec: { x: 1, y: 2 }, - latitude: null, - longitude: null, - location: '직접', - formattedAddress: null, - geocodeUsed: false, - inputType: 'katec', - }); - }); - - it('위경도를 받으면 KATEC 좌표를 함께 반환한다', async () => { - const result = await resolveOpinetLocation({ latitude: 37.4979, longitude: 127.0276 }); - - expect(result).toMatchObject({ - latitude: 37.4979, - longitude: 127.0276, - geocodeUsed: false, - inputType: 'coordinates', - }); - expect(result.katec.x).toBeCloseTo(314213.309, 3); - }); - - it('location 키워드를 Google Geocoding으로 변환하고 캐시한다', async () => { - const fetchImpl = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - status: 'OK', - results: [ - { - formatted_address: '서울 강남역', - geometry: { location: { lat: 37.4979, lng: 127.0276 } }, - }, - ], - }), - ), - ); - - const first = await resolveOpinetLocation( - { location: '강남역' }, - { googleMapsApiKey: 'google-key', fetchImpl }, - ); - const second = await resolveOpinetLocation( - { location: '강남역' }, - { googleMapsApiKey: 'google-key', fetchImpl }, - ); - - expect(fetchImpl).toHaveBeenCalledTimes(1); - expect(String(fetchImpl.mock.calls[0]?.[0])).toContain('address=%EA%B0%95%EB%82%A8%EC%97%AD'); - expect(first).toMatchObject({ - location: '강남역', - formattedAddress: '서울 강남역', - geocodeUsed: true, - inputType: 'location', - }); - expect(second.katec).toEqual(first.katec); - }); - - it('location 검색에 Google 키가 없으면 명확한 오류를 낸다', async () => { - await expect(resolveOpinetLocation({ location: '강남역' }, { googleMapsApiKey: '' })).rejects.toThrow( - 'GOOGLE_MAPS_API_KEY', - ); - }); - - it('좌표 변환 실패와 지오코딩 실패를 명확히 처리한다', async () => { - await expect(() => wgs84ToKatec(Number.NaN, 127)).toThrow('위도/경도'); - - const fetchImpl = vi - .fn() - .mockResolvedValueOnce(new Response(JSON.stringify({ status: 'ZERO_RESULTS' }))) - .mockResolvedValueOnce(new Response('bad gateway', { status: 502 })) - .mockResolvedValueOnce(new Response('not-json')) - .mockResolvedValueOnce(new Response(JSON.stringify({ status: 'OK', results: [{ geometry: { location: {} } }] }))) - .mockResolvedValueOnce( - new Response(JSON.stringify({ status: 'OK', results: [{ geometry: { location: { lat: 37.5, lng: 127 } } }] })), - ); - - await expect( - resolveOpinetLocation({ location: '없는곳' }, { googleMapsApiKey: 'google-key', fetchImpl }), - ).rejects.toThrow('위치를 좌표로 변환하지 못했습니다'); - - await expect( - resolveOpinetLocation({ location: '장애' }, { googleMapsApiKey: 'google-key', fetchImpl }), - ).rejects.toThrow('HTTP 502'); - - await expect( - resolveOpinetLocation({ location: '파싱실패' }, { googleMapsApiKey: 'google-key', fetchImpl }), - ).rejects.toThrow('위치를 좌표로 변환하지 못했습니다'); - - await expect( - resolveOpinetLocation({ location: '좌표없음' }, { googleMapsApiKey: 'google-key', fetchImpl }), - ).rejects.toThrow('위치를 좌표로 변환하지 못했습니다'); - - await expect( - resolveOpinetLocation({ location: '주소문자열없음' }, { googleMapsApiKey: 'google-key', fetchImpl }), - ).resolves.toMatchObject({ formattedAddress: null }); - - await expect(resolveOpinetLocation({})).rejects.toThrow('location 중 하나'); - }); +it('KATEC와 위경도 입력은 지오코더를 호출하지 않는다', async () => { + const fetchImpl = vi.fn(); + expect(await resolveOpinetLocation({ x: 1, y: 2, location: '직접' }, { fetchImpl })).toMatchObject({ katec: { x: 1, y: 2 }, inputType: 'katec', location: '직접' }); + expect(await resolveOpinetLocation({ latitude: 37.4979, longitude: 127.0276 }, { fetchImpl })).toMatchObject({ inputType: 'coordinates', geocodeUsed: false }); + expect(fetchImpl).not.toHaveBeenCalled(); +}); +it('무료 장소 검색 결과를 변환한다', async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response(JSON.stringify({ documents: [{ place_name: '강남역', address_name: '서울 강남구', y: '37.4979', x: '127.0276' }] }))); + expect(await resolveOpinetLocation({ location: '강남역' }, { kakaoRestApiKey: 'free', fetchImpl })).toMatchObject({ inputType: 'location', formattedAddress: '서울 강남구', geocodeUsed: true }); + expect(fetchImpl.mock.calls[0][0]).toContain('dapi.kakao.com'); +}); +it('키가 없거나 검색 결과가 무관하면 명확한 위치 오류를 낸다', async () => { + await expect(resolveOpinetLocation({ location: '부산역' })).rejects.toThrow('위치를 좌표로 변환하지 못했습니다'); + await expect(resolveOpinetLocation({})).rejects.toThrow('location 중 하나'); +}); +it('한국 범위 밖 좌표는 조회 좌표로 사용하지 않는다', async () => { + await expect(resolveOpinetLocation({ latitude: 0, longitude: 0 })).rejects.toThrow('유효한 위도'); });