diff --git a/.env.example b/.env.example index 4a1a5da7..1557448c 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ -ZYTE_API_KEY=your_zyte_api_key_here +# Zyte 유료 호출은 중지되어 있으며 키를 설정해도 활성화되지 않습니다. +# ZYTE_API_KEY=legacy_key_unused GOOGLE_MAPS_API_KEY=your_google_maps_api_key_here NAVER_CLIENT_ID=your_naver_client_id_here NAVER_CLIENT_SECRET=your_naver_client_secret_here @@ -8,3 +9,10 @@ HEALTH_CHECK_BASE_URL=http://localhost:8787 CLOUDFLARE_ACCOUNT_ID=your_cloudflare_account_id_here CLOUDFLARE_API_TOKEN=your_cloudflare_api_token_here CF_WORKER_SCRIPT_NAME=daiso-mcp + +# 올리브영 무료 전용 브라우저 릴레이 (선택, 사용자 입력으로 전달하지 않음) +OY_RELAY_URL= +OY_RELAY_TOKEN= +# 로컬 릴레이 실행용 선택 설정 +OY_RELAY_PORT=4319 +OY_BROWSER_EXECUTABLE= diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index abf12978..2eda263e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ npm ci cp .env.example .env ``` -`.env`에는 필요한 경우 `ZYTE_API_KEY`를 설정하세요. +Zyte 유료 호출은 비용 정책에 따라 중지되어 있으며 `ZYTE_API_KEY`를 설정해도 활성화되지 않습니다. 무료 조회와 선택적 중계 구성은 [운영 정책](docs/zyte-free-access.md)을 참고하세요. ## 개발 명령어 diff --git a/README.md b/README.md index 686c2511..45a76f67 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ +> Zyte 유료 호출을 중지했습니다. 무료 원본 조회를 우선하며 일부 서비스는 별도 실행 환경이 필요합니다. [비용 정책과 서비스별 상태](docs/zyte-free-access.md)를 확인하세요. + > [!IMPORTANT] > 최근 공개 서버 사용량이 크게 증가하여 2026년 7월 18일부터 올리브영·CGV·CU·GS25·롯데마트의 검색을 포함한 공개 GET API에 IP당 하루 합산 3,000회(KST 기준)의 호출 제한을 적용합니다. 한도를 초과하는 사용이 필요하다면 Daiso MCP는 오픈 소스이므로 이 저장소를 직접 배포해 이용해 주세요. diff --git a/docs/oliveyoung-free-relay.md b/docs/oliveyoung-free-relay.md new file mode 100644 index 00000000..f3e60461 --- /dev/null +++ b/docs/oliveyoung-free-relay.md @@ -0,0 +1,52 @@ +# 올리브영 무료 브라우저 릴레이 + +2026-09-15 검증: 이 Mac의 일반 HTTP 요청은 올리브영 검색 API에서 HTTP 403 보안 검증 HTML을 받았습니다. 개인 프로필과 분리한 **화면 있는 Chromium**에서는 네 API 모두 HTTP 200, `status: SUCCESS`였습니다. 새 headless 브라우저는 보안 검증 화면에 머물렀습니다. 따라서 GUI 로그인 세션, 브라우저, 전원과 네트워크를 유지할 수 있는 운영 호스트가 필요합니다. 설정만 추가했다고 복구된 것으로 판단하면 안 됩니다. + +## 실행 + +저장소의 개발 의존성을 설치한 뒤 실행합니다. Playwright Chromium이 없다면 `npx playwright install chromium`으로 설치하거나, 이미 설치한 Chromium 실행 파일의 절대 경로를 `OY_BROWSER_EXECUTABLE`에 지정합니다. + +```sh +# 토큰은 별도 비밀 저장소/환경에서 주입합니다. 로그나 저장소에 기록하지 않습니다. +export OY_RELAY_TOKEN='<전용 임의 토큰>' +# 선택 사항: 기본 포트 4319, Playwright 기본 Chromium 사용 +export OY_RELAY_PORT=4319 +# export OY_BROWSER_EXECUTABLE='/absolute/path/to/chromium' +npx tsx scripts/relay/start.ts +``` + +서버는 `127.0.0.1`에만 바인딩합니다. 홈페이지의 정상 `/store/` 페이지가 열린 뒤 준비 메시지를 출력합니다. 브라우저와 서버는 SIGINT/SIGTERM으로 종료합니다. 화면 없는 서버에서는 현재 검증 결과대로 작동하지 않을 수 있습니다. 세션이 보안 검증으로 바뀌면 조회는 실패하며, 운영자가 전용 브라우저 접속을 확인하고 릴레이를 재시작해야 합니다. + +Cloudflare Worker에서 쓰려면 승인된 별도 연결 경로가 필요합니다. 이번 구현은 공개 터널, LaunchAgent 또는 상시 실행을 자동 생성하지 않습니다. + +## Worker/MCP 설정 + +- `OY_RELAY_URL`: 운영자가 관리하는 릴레이의 기본 HTTPS 주소. 개발 시 `http://127.0.0.1:4319` 허용. +- `OY_RELAY_TOKEN`: 릴레이와 같은 전용 Bearer 토큰. + +이 값은 Worker 바인딩에서 REST 핸들러와 MCP 서비스로 전달됩니다. MCP 도구 사용자 인자로 설정할 수 없습니다. 릴레이가 설정되어 있으면 먼저 릴레이를 호출하고, 없으면 공식 API에 직접 요청합니다. 직접 요청 실패 시 운영자에게 릴레이 설정 안내를 반환합니다. 어느 경로도 Zyte를 호출하지 않습니다. + +## 허용 요청 + +`POST /v1/oliveyoung/{operation}`에 JSON 본문과 `Authorization: Bearer …`를 보냅니다. 대상 호스트는 `https://www.oliveyoung.co.kr`로 고정하며 임의 URL은 받지 않습니다. + +| operation | JSON 필드 | +| --- | --- | +| `find-store` | `lat`, `lon`, `pageIdx`, `searchWords`, `pogKeys`, `serviceKeys`, `mapLat`, `mapLon` | +| `product-search-v3` | `includeSoldOut`, `keyword`, `page`, `sort`, `size` | +| `stock-goods-info-v3` | `goodsNo` | +| `stock-stores` | `productId`, `lat`, `lon`, `pageIdx`, `searchWords`, `mapLat`, `mapLon` | + +숫자·문자열·불리언 타입과 필드 목록을 검증합니다. 요청 본문은 최대 16 KiB입니다. 인증 실패는 브라우저 작업 전에 401, 잘못된 경로는 404, 잘못된 본문은 400/413입니다. 한 브라우저 작업만 동시에 실행하며, 실행·대기를 합쳐 8개를 넘으면 503입니다. 취소되었거나 15초 이상 대기한 요청은 실행하지 않습니다. 브라우저 fetch는 본문을 읽을 때까지 15초 제한을 적용합니다. 정상 HTTP 200과 `SUCCESS` JSON만 반환하며, HTML·상태 오류·네트워크 실패는 원문이나 비밀을 노출하지 않는 502가 됩니다. + +기존 상품·매장·재고의 실패 시 30분 캐시 재사용 동작은 이 전송 변경에서 확대하지 않았습니다. 캐시 데이터가 실시간 재고를 보장하지는 않습니다. + +## 검증 기록 + +- 직접 Node POST: `find-store`, `product-search-v3` 모두 HTTP 403, 보안 검증 HTML. +- 새로 연 headed Chromium: 별도 로그인과 개인 쿠키 없이 네 API 성공. +- 구현한 서버를 임시 포트 `14319`에 띄워 로컬 HTTP로 검증: 잘못된 토큰 401; `find-store` 200/SUCCESS/9개, `product-search-v3` 200/SUCCESS/382개, `stock-goods-info-v3` 200/SUCCESS, `stock-stores` 200/SUCCESS/9개. +- 실제 smoke는 설치된 Chrome for Testing 148 실행 파일을 `OY_BROWSER_EXECUTABLE`로 지정했습니다. 종료 후 포트 리스너가 남지 않은 것을 확인했습니다. +- 테스트: `npx vitest run tests/relay tests/services/oliveyoung tests/api/oliveyoung-handlers.test.ts tests/app/app-api-oliveyoung.test.ts tests/app/app-api-actions.test.ts`. + +공개 Worker에서 이 로컬 Mac으로 연결되는 경로는 이번 smoke로 검증한 범위에 포함되지 않습니다. diff --git a/docs/release-1.2.0.md b/docs/release-1.2.0.md new file mode 100644 index 00000000..8c65afe3 --- /dev/null +++ b/docs/release-1.2.0.md @@ -0,0 +1,25 @@ +# 1.2.0 릴리스 노트 + +## Zyte 사용 중지 + +Zyte Extract 네트워크 호출과 유료 재시도 경로를 제거했습니다. 서버의 REST·MCP·운영 검사 모두 같은 정책을 따릅니다. 기존 ZYTE_API_KEY가 남아 있어도 유료 요청은 발생하지 않습니다. 비용 정책에 따른 조회 불가는 재시도 불가 진단을 반환합니다. + +기존 무료 원본 요청과 롯데마트 소켓·세션 재시도는 유지합니다. 무료 경로가 차단된 서비스는 조회할 수 없을 수 있습니다. 계정의 구독·약정이나 다른 프로그램의 사용량은 별도이며 이 릴리스가 계정 계약을 변경하지는 않습니다. + +## 올리브영 무료 전송 + +올리브영 클라이언트를 유료 전송에서 직접 HTTP 및 운영자가 구성하는 인증된 브라우저 중계로 전환했습니다. 중계 주소와 토큰은 운영 바인딩에서만 전달합니다. 잘못된 설정은 반복 재시도 대신 운영자 조치를 안내합니다. + +선택적인 로컬 중계는 전용 headed Chromium을 사용하며 네 가지 고정 조회 경로만 제공합니다. 인증, 16KB 요청 제한, 필드 검증, 직렬 처리와 대기 상한을 적용합니다. 사용자 브라우저 프로필이나 로그인 정보를 가져오지 않습니다. + +2026-09-15 로컬 실측에서 네 API 모두 정상 JSON을 반환했습니다. 일반 HTTP 및 headless 브라우저는 차단돼, 이 경로를 운영에 사용하려면 GUI 세션과 상시 실행 장비가 필요합니다. 중계 구현과 로컬 검증 완료는 공개 서버 복구 완료를 뜻하지 않습니다. + +## 조회 재사용 + +세븐일레븐 상품·카탈로그·인기 검색어는 5분, 매장 검색은 30분 동안 서비스 계층에서 재사용합니다. REST와 MCP의 같은 조회를 합치고 결과 복제·실패 제거·메모리 상한을 적용했습니다. 실시간 재고는 이 새 캐시에 넣지 않습니다. + +## 응답 검증 + +올리브영의 직접/중계 응답은 HTTP 200과 SUCCESS 본문을 확인합니다. 공용 HTTP 유틸리티는 선택적으로 기대 상태를 지정할 수 있으며 기존 요청의 기본 동작과 응답 본문 읽기 제한 시간은 유지합니다. + +서비스별 검증 결과와 제약은 [Zyte 없는 운영 정책](zyte-free-access.md)을 참고하세요. diff --git a/docs/superpowers/plans/2026-09-15-zyte-reduction.md b/docs/superpowers/plans/2026-09-15-zyte-reduction.md new file mode 100644 index 00000000..4fcfd9e7 --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-zyte-reduction.md @@ -0,0 +1,52 @@ +# Zyte 비용 우선 절감 구현 계획 + +> For agentic workers: Use superpowers:subagent-driven-development for implementation and independent spec/quality review. + +**Goal:** 운영 Zyte 호출을 0회로 만들고 무료 조회와 결과 재사용 범위를 확대합니다. + +**Architecture:** 공용 Zyte 전송 함수에서 비용 정책을 강제합니다. 키 존재 여부나 REST/MCP/health 진입점에 의존하지 않습니다. 기존 API 계약을 유지하고 무료 원본 요청과 캐시를 보강합니다. 유료 재허용·호출 예산 모드·새 유료 서버 구입은 제외합니다. + +**Tech Stack:** TypeScript, Vitest, Cloudflare Workers, 기존 HTTP 클라이언트와 캐시. + +## Task 1: 유료 호출 0회 + +Files: src/utils/zyte.ts, src/core/errors.ts, src/api/configStatus.ts, tests/utils/zyte.test.ts, tests/core/errors.test.ts, 관련 config/app 테스트. + +- [x] 실제 키와 process.env 키가 있어도 기본 requestByZyte는 정책 오류를 반환하고 fetch 호출은 0회인 실패 테스트를 작성합니다. 여러 동시 호출과 retries 옵션도 동일해야 합니다. +- [x] `await expect(requestByZyte({apiKey:'test',url:'https://example.com',retries:3})).rejects.toThrow('비용'); expect(fetchMock).not.toHaveBeenCalled()` 형태로 네트워크 차단을 확인합니다. +- [x] 공용 전송 경계에 정책 차단을 구현합니다. 필요 없는 유료 재시도 코드는 제거합니다. 명시적인 코드 변경 없이 운영 환경키만으로 다시 켜지지 않아야 합니다. +- [x] 정책 오류를 retryable:false로 진단하고 health 설정 화면은 키 보유와 유료 사용 중지를 구분합니다. +- [x] REST/MCP 및 health에서 키가 있어도 Zyte 요청 0회를 검증합니다. 기존 무료 요청이 계속 동작하는지도 확인합니다. +- [x] 표적 테스트, 독립 사양 리뷰, 품질 리뷰를 진행합니다. 공유 작업트리의 커밋은 통합 검증 후 부모가 수행합니다. + +## Task 2: 직접 조회 조사와 적용 + +Files: src/services/oliveyoung/client.ts, 필요 시 src/services/oliveyoung/transport.ts, tests/services/oliveyoung/*, docs/zyte-free-access.md. + +- [x] 실제 공식 올리브영 POST 요청의 로컬 응답과 Cloudflare 원격 응답을 비교합니다. Zyte는 호출하지 않습니다. +- [x] `fetch`가 직접 공식 URL을 호출하고 성공 JSON을 파싱하는 회귀 테스트를 먼저 추가합니다. 실패는 오류 또는 명시적인 기존 캐시 경로를 사용하며 빈 성공을 만들지 않습니다. +- [x] 직접 성공이 입증된 전송 경로를 반영합니다. 차단되는 경우 증거를 기록하고 동작하지 않는 대체 URL만 추가하지 않습니다. +- [x] CU·세븐일레븐·CGV·디트릭스 무료 경로와 로컬 실행 가능성을 조사합니다. 기존 개인 장비의 상시 가동 여부와 공개 중계 운영 가능성이 확인되지 않으면 새 인프라는 배포하지 않고 필요한 조건을 기록합니다. + +## Task 3: 반복 조회 감소 + +Files: src/services/seveneleven/readCache.ts, src/services/seveneleven/client.ts, tests/services/seveneleven/readCache.test.ts 및 기존 호출부 테스트. 재고 필드가 없는 세븐일레븐 공개 목록에 먼저 적용합니다. + +- [x] 캐시 적용 지점과 기존 TTL을 확인하고 REST와 MCP가 공유하는 서비스 계층에 적용합니다. 무관한 전역 캐시 재작성은 하지 않습니다. +- [x] 같은 키의 동시 요청 2개가 원본 호출 1회로 합쳐지는 테스트, 실패 후 다음 호출 재시도 가능 테스트, 서로 다른 키 분리 테스트를 작성합니다. +- [x] 세븐일레븐 상품·카탈로그·인기 검색어는 5분, 매장은 30분 재사용합니다. 이 새 캐시는 실시간 재고를 저장하지 않습니다. 올리브영의 기존 캐시 동작은 확대하지 않습니다. +- [x] 메모리 크기 제한과 실패 항목 제거를 검증하고 필요 시 결과 복제로 호출자 간 데이터 오염을 방지합니다. + +## Task 4: 통합·배포 + +- [x] `npm run check`, `npm run test:coverage` 네 항목 100%, `npm audit`, `npm run build`, Wrangler dry-run을 실행합니다. +- [x] 전체 diff의 독립 사양/품질 리뷰를 완료했습니다. +- [ ] 문서에 사용 중지 정책, 무료 가능 범위와 미해결 조건을 기록하고 PR을 생성합니다. 검증 후 승인된 유지보수 범위로 병합·배포합니다. +- [ ] 실제 REST/MCP/health에서 유료 정책 오류와 정상 무료 조회를 검증합니다. 원본 장애와 비용 정책에 따른 조회 불가를 구분합니다. +- [ ] 패키지 변경을 버전/태그와 일치하도록 릴리스하고 공개 registry를 검증합니다. 개인 프로젝트 기록과 완료 알림을 갱신합니다. + +## 운영 연결 상태 + +올리브영 로컬 브라우저 중계는 실제 네 API와 인증 거절을 검증하고 종료했습니다. 현재 Mac을 상시 GUI 중계 장비로 사용할지 사용자 응답을 기다리고 있어 공개 터널·LaunchAgent는 생성하지 않았습니다. 기존 개인 Cloudflare 계정에는 활성 터널이 없습니다. 이는 유료 호출 0회 정책의 배포를 막지 않습니다. + +최종 로컬 검증: 165개 파일·1,874개 테스트 통과. Statements 4,510/4,510, Branches 4,017/4,017, Functions 1,057/1,057, Lines 4,364/4,364로 모두 100%입니다. 릴레이 핵심 두 모듈을 커버리지 대상에 포함했고 시작 스크립트는 실제 로컬 실행으로 검증했습니다. npm audit 0건, build와 Worker dry-run 통과입니다. diff --git a/docs/superpowers/specs/2026-09-15-zyte-reduction-design.md b/docs/superpowers/specs/2026-09-15-zyte-reduction-design.md new file mode 100644 index 00000000..f469ebf7 --- /dev/null +++ b/docs/superpowers/specs/2026-09-15-zyte-reduction-design.md @@ -0,0 +1,50 @@ +# Zyte 비용 절감 설계 초안 + +## 목표와 현재 근거 + +사용자는 Zyte 비용이 과도해 사용을 최대한 줄이기를 원합니다. 월별 실제 청구액과 서비스별 유료 성공 건수는 아직 조회하지 못했습니다. 비용 절감률은 추정하지 않습니다. + +- 올리브영은 src/services/oliveyoung/client.ts의 zyteExtract가 직접 Zyte를 호출합니다. +- CU·세븐일레븐 등은 이미 직접 요청 후 특정 실패에만 Zyte를 사용합니다. 요청 순서 변경만으로 모든 비용을 없앨 수 없습니다. +- 기존 dailyRateLimit은 일부 공개 GET API의 클라이언트별 제한입니다. 세븐일레븐·MCP·인증된 health 경로 전체를 포괄하는 Zyte 지출 상한이 아닙니다. +- 공용 Zyte 함수에 전역 호출 예산 검사가 없습니다. 일부 호출은 기본 1회 재시도가 가능합니다. +- 올리브영에는 프로세스 메모리 기반 장애 시 이전 결과 캐시가 있습니다. API에는 Edge Cache도 있으므로 캐시를 처음 추가하는 작업으로 설명해서는 안 됩니다. +- 이번 유지보수에서 CU·디트릭스는 로컬 정상/Cloudflare 실패가 재현됐습니다. 별도 실행 위치를 검증할 근거가 있으나 다른 서버에서의 성공을 보장하지는 않습니다. + +## 선택지 + +1. 비용 우선: Zyte 기본 차단, 직접 요청과 유효한 캐시로 서비스 가능한 범위를 유지합니다. 일부 조회 불가를 허용하며 무료 대체 경로를 순서대로 검증합니다. +2. 균형: 작은 전역/서비스별 호출 예산 내에서만 유료 fallback을 허용합니다. 한도 도달 시 직접 요청·캐시·명시적 조회 불가로 처리합니다. +3. 가용성 우선: Zyte를 유지하고 공유 캐시와 중복 요청 합치기부터 적용합니다. 절감은 가능하나 강한 비용 상한 없이는 사용량 증가 위험이 남습니다. + +권장 방향은 1번 또는 2번과 직접 조회 대체를 병행하는 것입니다. 사용자가 “네 다 진행합시다”로 승인했으며 권장안인 비용 우선·Zyte 기본 차단을 구현합니다. 0회를 초과하는 유료 호출 허용 모드는 이번 범위에서 제외합니다. + +## 구체 설계 + +### 1단계: 유료 호출 통제 + +requestByZyte의 실제 네트워크 호출 직전에 중앙 정책 검사를 둡니다. REST·MCP·health·재시도 모두 같은 정책을 거치며, 꺼진 상태에서는 Zyte 네트워크 요청이 0회여야 합니다. 환경변수에 키가 남아 있어도 이 정책을 우회할 수 없어야 합니다. + +선택한 비용 우선 모드에서는 전송 함수가 항상 정책 오류를 반환합니다. 유료 재시도·인증·네트워크 경로를 제거하므로 원격 카운터 장애나 동시성으로 한도를 초과할 여지가 없습니다. 별도 호출 허용 모드와 예산 카운터는 구현하지 않습니다. health도 같은 정책을 따르며 서비스 실패를 정상으로 표시하지 않습니다. + +### 2단계: 무료 직접 조회 확대 + +올리브영의 직접 요청 가능성을 먼저 검증하고 성공한 요청만 직접 경로로 전환합니다. CU·세븐일레븐·CGV 등은 현재 직접 경로와 공식 웹/앱 경로를 재현해 비교합니다. Workers에서 실패하고 사용자의 컴퓨터에서 성공하는 요청은 기존 상시 실행 가능한 장비에서 제한된 중계 서버를 시험하는 후보입니다. 새 유료 서버 계약은 이 설계에 포함하지 않습니다. + +중계가 필요하면 고정된 서비스/경로만 허용하고 인증·요청 제한을 적용합니다. 임의 URL을 받아 대신 접속하는 공개 프록시로 만들지 않습니다. 운영 장비의 상시 가동 가능 여부와 외부 접근 구성을 확인한 후 구현 범위를 결정합니다. + +### 3단계: 반복 조회 감소 + +매장·상품 목록 등 상대적으로 덜 변하는 결과의 보관 시간을 늘리고 REST/MCP가 같은 조회 결과를 공유하도록 적용 지점을 검토합니다. 동시에 들어온 같은 조회는 합칩니다. 재고·좌석은 짧은 유효 시간을 적용하고 지난 결과 제공 시 조회 시각과 오래된 상태를 표시합니다. 최신 재고로 오인되는 성공 응답을 만들지 않습니다. + +## 검증 + +Zyte 차단 시 실제 요청 0회, REST/MCP/health 정책 일치, 병렬 호출·재시도에서도 유료 요청 0회, 실시간 재고의 신규 캐시 제외, 직접 조회 성공/실패 구분을 회귀 테스트로 확인합니다. 기존 check·coverage 100%·audit·배포 검사를 유지합니다. 운영 성공률과 Zyte 호출 수를 함께 비교합니다. + +## 미결정 + +- 비용 우선으로 서비스 일부 저하를 허용하는 권장안을 승인받았습니다. +- 이번 유료 호출 상한은 0회이며, 유료 재허용은 후속 명시 설정 없이 발생하지 않습니다. +- 중계 서버는 후보이며 가동할 장비와 성공 가능성을 먼저 검증해야 합니다. + +승인된 설계에 따라 별도 작업트리에서 구현·검증·배포합니다. diff --git a/docs/zyte-free-access.md b/docs/zyte-free-access.md new file mode 100644 index 00000000..ce8db6e1 --- /dev/null +++ b/docs/zyte-free-access.md @@ -0,0 +1,35 @@ +# Zyte 없이 조회하는 운영 정책 + +## 비용 정책 + +이 버전의 운영 서버는 Zyte Extract 요청을 보내지 않습니다. REST·MCP·운영 health의 공통 유료 전송 함수를 차단했고 유료 재시도와 응답 처리 코드를 제거했습니다. 기존 ZYTE_API_KEY가 남아 있어도 다시 활성화되지 않습니다. 다시 사용하려면 명시적인 코드 변경이 필요합니다. + +`/health`의 `config.zyteApiKey.configured`는 키 보유 여부이며 `enabled: false`는 유료 호출 중지를 뜻합니다. 원본 무료 경로가 실패한 경우 오류 응답은 비용 정책에 따른 유료 대체 경로 중지를 안내합니다. 자동 재시도로 해결되는 오류로 표시하지 않습니다. + +이 정책은 이 저장소의 서버 호출을 중지합니다. Zyte 계정의 구독 계약·월 약정이나 다른 프로그램에서 쓰는 키까지 취소하지는 않습니다. 계정 자체 비용은 대시보드에서 별도로 확인해야 합니다. 기존 지출 한도는 변경하지 않았습니다. + +## 직접 조회와 확인된 제약 + +2026-09-15 검증 결과입니다. + +| 대상 | 확인 결과 | +|---|---| +| 다이소·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)에 있습니다. 현재 운영 연결은 상시 실행 장비 확인 후 별도로 적용해야 합니다. + +## 반복 조회 감소 + +세븐일레븐의 공개 상품·카탈로그·인기 검색어는 5분, 매장 검색은 30분 동안 서비스 계층에서 재사용합니다. REST와 MCP가 이 계층을 함께 쓰며 같은 키의 동시 조회는 합칩니다. 응답은 호출자마다 복제하고 성공 결과와 진행 중 조회는 각각 최대 128개 키만 보관합니다. 실패 결과는 캐시하지 않습니다. + +실시간 재고는 이 새 캐시의 대상이 아닙니다. 메모리 캐시는 Worker 인스턴스별이므로 모든 지역의 전역 중복 제거를 보장하지는 않습니다. 기존 Edge Cache도 계속 적용됩니다. + +## 운영 상태 해석 + +기본 health의 200은 개별 서비스의 정상 여부를 보장하지 않습니다. 유료 fallback을 끈 뒤 일부 health/smoke가 실패할 수 있으며 이를 정상으로 위장하지 않습니다. 비용 정책에 따른 조회 불가와 원본 장애를 함께 확인해야 합니다. diff --git a/scripts/relay/browser.ts b/scripts/relay/browser.ts new file mode 100644 index 00000000..e3ce3d80 --- /dev/null +++ b/scripts/relay/browser.ts @@ -0,0 +1,20 @@ +/** 개인 프로필과 분리된 브라우저의 동일 출처 조회. */ +import type { Page } from 'playwright'; +import type { BrowserRunner } from './oliveyoung.js'; +export function createBrowserRunner(page: Pick): BrowserRunner { + return async (path, body) => { + const result = await page.evaluate(async ({ path, body, timeout }) => { + const response = await fetch(`https://www.oliveyoung.co.kr${path}`, { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + body: JSON.stringify(body), signal: AbortSignal.timeout(timeout), + }); + const text = await response.text(); + let parsed = null; + try { parsed = JSON.parse(text); } catch { /* 보안 검증 HTML을 정상 데이터로 반환하지 않습니다. */ } + return { status: response.status, body: parsed }; + }, { path, body, timeout: 15000 }); + if (result.status !== 200 || result.body?.status !== 'SUCCESS') throw new Error('올리브영 브라우저 응답 실패'); + return result.body; + }; +} diff --git a/scripts/relay/oliveyoung.ts b/scripts/relay/oliveyoung.ts new file mode 100644 index 00000000..05df4631 --- /dev/null +++ b/scripts/relay/oliveyoung.ts @@ -0,0 +1,67 @@ +/** 고정된 올리브영 조회만 허용하는 인증 릴레이. */ +import { timingSafeEqual } from 'node:crypto'; +import { OLIVEYOUNG_API } from '../../src/services/oliveyoung/api.js'; +import type { OliveyoungApiResponse } from '../../src/services/oliveyoung/types.js'; +export type BrowserRunner = (path: string, body: Record) => Promise; +const paths: Record = { + 'find-store': OLIVEYOUNG_API.STORE_FINDER_PATH, + 'product-search-v3': OLIVEYOUNG_API.PRODUCT_SEARCH_PATH, + 'stock-goods-info-v3': OLIVEYOUNG_API.STOCK_GOODS_INFO_PATH, + 'stock-stores': OLIVEYOUNG_API.STOCK_STORES_PATH, +}; +const fields: Record> = { + 'find-store': { lat: 'number', lon: 'number', pageIdx: 'number', searchWords: 'string', pogKeys: 'string', serviceKeys: 'string', mapLat: 'number', mapLon: 'number' }, + 'product-search-v3': { includeSoldOut: 'boolean', keyword: 'string', page: 'number', sort: 'string', size: 'number' }, + 'stock-goods-info-v3': { goodsNo: 'string' }, + 'stock-stores': { productId: 'string', lat: 'number', lon: 'number', pageIdx: 'number', searchWords: 'string', mapLat: 'number', mapLon: 'number' }, +}; +const error = (status: number, message: string) => Response.json({ error: message }, { status }); + +export function createOliveyoungRelay(token: string, run: BrowserRunner) { + if (!token.trim()) throw new Error('OY_RELAY_TOKEN이 필요합니다.'); + const expected = Buffer.from(`Bearer ${token}`); + let outstanding = 0; + let tail: Promise = Promise.resolve(); + return async (request: Request): Promise => { + const provided = Buffer.from(request.headers.get('authorization') || ''); + if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) return error(401, 'Unauthorized'); + const url = new URL(request.url); + const operation = url.pathname.replace(/^\/v1\/oliveyoung\//, ''); + if (request.method !== 'POST' || url.search || !Object.hasOwn(paths, operation)) return error(404, 'Not found'); + let body: Record; + try { + const reader = request.body?.getReader(); + if (!reader) return error(400, 'JSON body required'); + const chunks: Uint8Array[] = []; let size = 0; + while (true) { + const next = await reader.read(); + if (next.done) break; + size += next.value.byteLength; + if (size > 16384) { await reader.cancel(); return error(413, 'Body too large'); } + chunks.push(next.value); + } + body = JSON.parse(Buffer.concat(chunks).toString('utf8')); + const schema = fields[operation]; + if (!body || Array.isArray(body) || typeof body !== 'object' || + Object.keys(body).some((key) => !Object.hasOwn(schema, key)) || + Object.entries(schema).some(([key, type]) => typeof body[key] !== type || + (type === 'number' && !Number.isFinite(body[key])))) return error(400, 'Invalid payload'); + } catch { return error(400, 'Invalid JSON'); } + // 한 브라우저 작업과 대기 요청을 합쳐 최대 8건만 수용합니다. + if (outstanding >= 8) return error(503, 'Relay busy'); + outstanding++; + const queuedAt = Date.now(); + const task = tail.then(() => { + if (request.signal.aborted || Date.now() - queuedAt >= 15000) return null; + return run(paths[operation], body); + }); + tail = task.catch(() => undefined); + try { + const result = await task; + if (result === null) return error(503, 'Request expired'); + if (result?.status !== 'SUCCESS') return error(502, 'Upstream unavailable'); + return Response.json(result); + } catch { return error(502, 'Upstream unavailable'); } + finally { outstanding--; } + }; +} diff --git a/scripts/relay/start.ts b/scripts/relay/start.ts new file mode 100644 index 00000000..182f743a --- /dev/null +++ b/scripts/relay/start.ts @@ -0,0 +1,50 @@ +/** 실행 시에만 로컬 브라우저 릴레이를 시작합니다. */ +import { createServer } from 'node:http'; +import { Readable } from 'node:stream'; +import { pathToFileURL } from 'node:url'; +import { chromium } from 'playwright'; +import { createOliveyoungRelay } from './oliveyoung.js'; +import { createBrowserRunner } from './browser.js'; + +async function main() { + const token = process.env.OY_RELAY_TOKEN; + if (!token?.trim()) throw new Error('OY_RELAY_TOKEN이 필요합니다.'); + const port = Number(process.env.OY_RELAY_PORT || 4319); + if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('OY_RELAY_PORT 값이 잘못되었습니다.'); + const browser = await chromium.launch({ headless: false, executablePath: process.env.OY_BROWSER_EXECUTABLE }); + try { + const context = await browser.newContext({ locale: 'ko-KR' }); + const page = await context.newPage(); + await page.goto('https://www.oliveyoung.co.kr/', { waitUntil: 'domcontentloaded', timeout: 30000 }); + await page.waitForURL('**/store/**', { timeout: 30000 }); + const handler = createOliveyoungRelay(token, createBrowserRunner(page)); + const server = createServer(async (req, res) => { + try { + const controller = new AbortController(); + res.once('close', () => controller.abort()); + const request = new Request(`http://127.0.0.1:${port}${req.url}`, { + signal: controller.signal, method: req.method, headers: req.headers as Record, + body: req.method === 'GET' || req.method === 'HEAD' ? undefined : Readable.toWeb(req), + duplex: 'half', + } as RequestInit & { duplex: 'half' }); + const response = await handler(request); + res.writeHead(response.status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(await response.text()); + } catch { + res.writeHead(500); res.end('{"error":"Relay failure"}'); + } + }); + server.requestTimeout = 10000; + server.headersTimeout = 10000; + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', resolve); + }); + console.log(`Oliveyoung relay ready on 127.0.0.1:${port}`); + const close = () => { server.close(); void browser.close().then(() => process.exit(0)); }; + process.once('SIGTERM', close); process.once('SIGINT', close); + } catch (error) { await browser.close(); throw error; } +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(() => { console.error('올리브영 릴레이 시작 실패: 토큰, 포트 및 GUI 브라우저 접속을 확인하세요.'); process.exitCode = 1; }); +} diff --git a/src/api/configStatus.ts b/src/api/configStatus.ts index 5dbff745..9bd6b129 100644 --- a/src/api/configStatus.ts +++ b/src/api/configStatus.ts @@ -7,7 +7,7 @@ interface ConfigStatusItem { export interface ConfigStatus { googleMapsApiKey: ConfigStatusItem; - zyteApiKey: ConfigStatusItem; + zyteApiKey: ConfigStatusItem & { enabled: false }; naverLocalSearch: ConfigStatusItem; opinetApiKey: ConfigStatusItem; supabaseFeedback: ConfigStatusItem; @@ -26,7 +26,8 @@ export function buildConfigStatus(bindings?: AppBindings): ConfigStatus { }, zyteApiKey: { configured: isConfigured(bindings?.ZYTE_API_KEY), - usedBy: ['oliveyoung', 'gs25', 'cu', 'seveneleven', 'lottemart', 'cgv'], + enabled: false, + usedBy: [], }, naverLocalSearch: { configured: diff --git a/src/api/handlers.ts b/src/api/handlers.ts index 1cd50821..ca0c0c65 100644 --- a/src/api/handlers.ts +++ b/src/api/handlers.ts @@ -61,7 +61,8 @@ export async function handleOliveyoungSearchProducts(c: ApiContext) { includeSoldOut, }, { - apiKey: c.env?.ZYTE_API_KEY, + relayUrl: c.env?.OY_RELAY_URL, + relayToken: c.env?.OY_RELAY_TOKEN, timeout: timeoutMs, }, ); @@ -105,7 +106,8 @@ export async function handleOliveyoungFindStores(c: ApiContext) { searchWords: keyword, }, { - apiKey: c.env?.ZYTE_API_KEY, + relayUrl: c.env?.OY_RELAY_URL, + relayToken: c.env?.OY_RELAY_TOKEN, timeout: timeoutMs, } ); @@ -157,7 +159,8 @@ export async function handleOliveyoungCheckInventory(c: ApiContext) { searchWords: storeKeyword, }, { - apiKey: c.env.ZYTE_API_KEY, + relayUrl: c.env?.OY_RELAY_URL, + relayToken: c.env?.OY_RELAY_TOKEN, timeout: timeoutMs, } ), @@ -170,7 +173,8 @@ export async function handleOliveyoungCheckInventory(c: ApiContext) { includeSoldOut, }, { - apiKey: c.env.ZYTE_API_KEY, + relayUrl: c.env?.OY_RELAY_URL, + relayToken: c.env?.OY_RELAY_TOKEN, timeout: timeoutMs, } ), @@ -184,7 +188,8 @@ export async function handleOliveyoungCheckInventory(c: ApiContext) { maxProducts: Math.min(productResult.products.length, stockCheckLimit), }, { - apiKey: c.env.ZYTE_API_KEY, + relayUrl: c.env?.OY_RELAY_URL, + relayToken: c.env?.OY_RELAY_TOKEN, timeout: timeoutMs, } ); diff --git a/src/api/response.ts b/src/api/response.ts index 3d0ed1c2..d981e58b 100644 --- a/src/api/response.ts +++ b/src/api/response.ts @@ -8,6 +8,8 @@ import { toStandardErrorDiagnostics } from '../core/errors.js'; export interface AppBindings { DAILY_RATE_LIMITER?: DurableObjectNamespace; ZYTE_API_KEY?: string; + OY_RELAY_URL?: string; + OY_RELAY_TOKEN?: string; GS25_API_KEY?: string; GOOGLE_MAPS_API_KEY?: string; NAVER_CLIENT_ID?: string; diff --git a/src/core/errors.ts b/src/core/errors.ts index 814021f7..72154cc0 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -2,6 +2,8 @@ * MCP/API 공통 에러 진단 구조 */ +export const ZYTE_COST_POLICY_MESSAGE = 'Zyte 유료 호출은 비용 정책에 따라 비활성화되어 있습니다.'; + export interface StandardErrorDiagnostics { code: string; message: string; @@ -82,6 +84,7 @@ export function toStandardErrorDiagnostics( } = {}, ): StandardErrorDiagnostics { const inferred = inferServiceAndOperation(code); + const costPolicyError = message.includes(ZYTE_COST_POLICY_MESSAGE); // 실제 Zyte 진단 문구만 설정 오류로 취급해 일반적인 접근 차단과 구분합니다. const configurationError = [ 'Zyte API 호출 실패: 403 Your account has been suspended.', @@ -89,9 +92,15 @@ export function toStandardErrorDiagnostics( 'ZYTE_API_KEY가 설정되지 않았습니다.', ].some((diagnostic) => message.includes(diagnostic)); const gs25AuthenticationError = - code === 'GS25_UPSTREAM_UNAVAILABLE' && message.includes('GS25 재고 서비스 인증을 사용할 수 없습니다.'); + const oliveyoungRelayConfigurationError = [ + '올리브영 릴레이 URL은 HTTPS 또는 로컬 HTTP 주소여야 합니다.', + 'OY_RELAY_TOKEN이 필요합니다.', + '올리브영 직접 요청 실패. 운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN으로 브라우저 릴레이를 설정해주세요.', + ].includes(message); const retryable = + !oliveyoungRelayConfigurationError && + !costPolicyError && !configurationError && !gs25AuthenticationError && isRetryable(code, options.status || options.upstreamStatus); @@ -104,11 +113,15 @@ export function toStandardErrorDiagnostics( service: options.service || inferred.service, operation: options.operation || inferred.operation, upstreamStatus: options.upstreamStatus, - hint: gs25AuthenticationError - ? '운영자는 GS25_API_KEY 설정을 확인하세요.' - : configurationError - ? '운영자는 ZYTE_API_KEY 설정과 Zyte 계정 상태를 확인하세요.' - : buildHint(retryable), + hint: oliveyoungRelayConfigurationError + ? '운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN 및 브라우저 릴레이 실행 상태를 확인하세요.' + : costPolicyError + ? ZYTE_COST_POLICY_MESSAGE + : gs25AuthenticationError + ? '운영자는 GS25_API_KEY 설정을 확인하세요.' + : configurationError + ? '운영자는 ZYTE_API_KEY 설정과 Zyte 계정 상태를 확인하세요.' + : buildHint(retryable), }; } diff --git a/src/index.ts b/src/index.ts index c6f6975e..29910c2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -112,7 +112,8 @@ const createRegistry = (bindings?: AppBindings) => { }), () => createOliveyoungService({ - zyteApiKey: bindings?.ZYTE_API_KEY, + relayUrl: bindings?.OY_RELAY_URL, + relayToken: bindings?.OY_RELAY_TOKEN, }), ]); diff --git a/src/services/cgv/errors.ts b/src/services/cgv/errors.ts index 3046e10d..b6498430 100644 --- a/src/services/cgv/errors.ts +++ b/src/services/cgv/errors.ts @@ -2,9 +2,11 @@ * CGV 원본 및 대체 전송 경로를 사용할 수 없는 상태 */ +import { ZYTE_COST_POLICY_MESSAGE } from '../../core/errors.js'; + export class CgvUpstreamUnavailableError extends Error { constructor() { - super('CGV 원본 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.'); + super(`CGV 원본 서비스에 연결할 수 없습니다. ${ZYTE_COST_POLICY_MESSAGE}`); this.name = 'CgvUpstreamUnavailableError'; } } diff --git a/src/services/cgv/transport.ts b/src/services/cgv/transport.ts index 7d891078..ab913728 100644 --- a/src/services/cgv/transport.ts +++ b/src/services/cgv/transport.ts @@ -1,11 +1,10 @@ /** * CGV 전송 계층 * - * 서명 헤더 생성, 직접 호출, Zyte fallback을 담당합니다. + * 서명 헤더를 생성하고 직접 호출합니다. 유료 대체 경로는 사용하지 않습니다. */ import { createTimeoutController } from '../../utils/http.js'; -import { decodeZyteHttpBody, requestByZyte } from '../../utils/zyte.js'; import { CGV_API } from './api.js'; import { CgvUpstreamUnavailableError } from './errors.js'; @@ -56,39 +55,11 @@ async function parseJsonResponse(response: Response): Promise( - path: string, - searchParams: URLSearchParams, - timeout: number, - apiKey: string, -): Promise { - const url = `${CGV_API.BASE_URL}${path}?${searchParams.toString()}`; - const timestamp = Math.floor(Date.now() / 1000).toString(); - const signature = await createSignature(path, '', timestamp); - const result = await requestByZyte({ - apiKey, - timeout, - url, - headers: [ - { name: 'Accept', value: 'application/json' }, - { name: 'Accept-Language', value: 'ko-KR' }, - { name: 'X-TIMESTAMP', value: timestamp }, - { name: 'X-SIGNATURE', value: signature }, - ], - tags: { service: 'cgv' }, - }); - - if (result.statusCode === 401 || result.statusCode === 403) { - throw new CgvUpstreamUnavailableError(); - } - return decodeZyteHttpBody(result); -} - export async function requestCgv( path: string, searchParams: URLSearchParams, timeout = 15000, - zyteApiKey?: string, + _zyteApiKey?: string, ): Promise { const url = `${CGV_API.BASE_URL}${path}?${searchParams.toString()}`; const timestamp = Math.floor(Date.now() / 1000).toString(); @@ -112,19 +83,7 @@ export async function requestCgv( } if (response.status === 401 || response.status === 403) { - const normalizedZyteApiKey = zyteApiKey?.trim(); - if (!normalizedZyteApiKey) { - throw new CgvUpstreamUnavailableError(); - } - - try { - return await requestByZyteCgv(path, searchParams, timeout, normalizedZyteApiKey); - } catch (fallbackError) { - if (fallbackError instanceof CgvUpstreamUnavailableError) { - throw fallbackError; - } - throw new CgvUpstreamUnavailableError(); - } + throw new CgvUpstreamUnavailableError(); } throw new Error(`CGV API 호출 실패: ${response.status}`); diff --git a/src/services/cu/client.ts b/src/services/cu/client.ts index a948fed1..8cfa99a1 100644 --- a/src/services/cu/client.ts +++ b/src/services/cu/client.ts @@ -3,7 +3,7 @@ */ import { fetchJson } from '../../utils/http.js'; -import { decodeBase64, requestByZyte } from '../../utils/zyte.js'; +import { ZYTE_COST_POLICY_MESSAGE } from '../../core/errors.js'; import { fetchJsonWithZyteFallback } from '../../utils/zyteJsonFallback.js'; import { CU_API } from './api.js'; import { cuStockUnavailableReason } from './upstreamError.js'; @@ -127,7 +127,7 @@ async function requestCuJson( async function requestCuWebHtml( path: string, body: Record, - apiKey?: string, + _apiKey?: string, timeout = 15000, ): Promise { const form = new URLSearchParams(body); @@ -145,23 +145,7 @@ async function requestCuWebHtml( } if (response.status === 400 || response.status === 403 || response.status === 429) { - try { - const result = await requestByZyte({ - apiKey, - url: targetUrl, - timeout, - method: 'POST', - headers: Object.entries(CU_WEB_DEFAULT_HEADERS).map(([name, value]) => ({ name, value })), - bodyText: formText, - tags: { service: 'cu' }, - }); - - if (result.statusCode === 200 && result.httpResponseBody) { - return decodeBase64(result.httpResponseBody); - } - } catch { - // 직접 호출 실패 시 Zyte 재시도도 실패하면 원본 에러를 반환합니다. - } + throw new Error(ZYTE_COST_POLICY_MESSAGE); } throw new Error(`API 요청 실패: ${response.status} ${response.statusText}`); diff --git a/src/services/cu/upstreamError.ts b/src/services/cu/upstreamError.ts index 4f147fdb..f744676e 100644 --- a/src/services/cu/upstreamError.ts +++ b/src/services/cu/upstreamError.ts @@ -8,9 +8,5 @@ export function cuStockUnavailableReason(error: unknown): string | null { if (error instanceof HttpError && [400, 403, 429].includes(error.status)) { return `CU 재고 API가 차단되었습니다 (${error.status} Request Blocked).`; } - const message = error instanceof Error ? error.message : ''; - if (message.includes('Zyte API 호출 실패: 520') || message.includes('Zyte 대상 응답 실패: 520')) { - return 'CU 재고 API가 차단되었습니다 (Zyte Website Ban 520).'; - } return null; } diff --git a/src/services/gs25/productSearch.ts b/src/services/gs25/productSearch.ts index 5941d18c..7107e4de 100644 --- a/src/services/gs25/productSearch.ts +++ b/src/services/gs25/productSearch.ts @@ -4,7 +4,7 @@ /* c8 ignore start */ import { fetchJson, HttpError } from '../../utils/http.js'; -import { decodeZyteHttpBody, requestByZyte } from '../../utils/zyte.js'; +import { requestByZyte } from '../../utils/zyte.js'; import { GS25_API } from './api.js'; import { toNumber } from './storeUtils.js'; @@ -87,20 +87,7 @@ async function fetchGs25TotalSearchResponse( throw error; } - const result = await requestByZyte({ - apiKey: zyteApiKey, - url: endpoint.toString(), - method: 'POST', - timeout, - retries: 1, - headers: Object.entries(GS25_TOTAL_SEARCH_HEADERS).map(([name, value]) => ({ - name, - value, - })), - bodyText, - tags: { service: 'gs25' }, - }); - return decodeZyteHttpBody(result); + return requestByZyte({ apiKey: zyteApiKey, url: endpoint.toString() }); } } diff --git a/src/services/gs25/storeStockTransport.ts b/src/services/gs25/storeStockTransport.ts index 38790370..7505d2d2 100644 --- a/src/services/gs25/storeStockTransport.ts +++ b/src/services/gs25/storeStockTransport.ts @@ -3,7 +3,6 @@ */ import { fetchJson, HttpError } from '../../utils/http.js'; -import { decodeZyteHttpBody, requestByZyte } from '../../utils/zyte.js'; import { Gs25UpstreamUnavailableError } from './errors.js'; import type { Gs25StoreStockResponse } from './types.js'; @@ -42,30 +41,6 @@ export async function fetchGs25StoreStockResponse( throw error; } - const zyteApiKey = options.zyteApiKey?.trim(); - if (error.status === 401 || !zyteApiKey) { - throw new Gs25UpstreamUnavailableError(); - } - - try { - const result = await requestByZyte({ - apiKey: zyteApiKey, - url, - method: 'GET', - timeout: options.timeout, - retries: 1, - headers: Object.entries(requestHeaders).map(([name, value]) => ({ name, value })), - tags: { service: 'gs25' }, - }); - if (isAuthenticationStatus(result.statusCode)) { - throw new Gs25UpstreamUnavailableError(); - } - return decodeZyteHttpBody(result); - } catch (fallbackError) { - if (fallbackError instanceof Gs25UpstreamUnavailableError) { - throw fallbackError; - } - throw new Gs25UpstreamUnavailableError(); - } + throw new Gs25UpstreamUnavailableError(); } } diff --git a/src/services/lottemart/session.ts b/src/services/lottemart/session.ts index d032bcb0..e9a2ceca 100644 --- a/src/services/lottemart/session.ts +++ b/src/services/lottemart/session.ts @@ -3,11 +3,10 @@ */ import { HttpError, fetchWithTimeout } from '../../utils/http.js'; -import { decodeBase64, requestByZyte } from '../../utils/zyte.js'; +import { ZYTE_COST_POLICY_MESSAGE } from '../../core/errors.js'; import { LOTTEMART_API } from './api.js'; import { fetchLotteMartSocketResponse, - toLotteMartBodyText, withLotteMartSessionCookie, } from './socketTransport.js'; @@ -116,41 +115,6 @@ async function fetchLotteMartResponse( } } -function toZyteHeaders(headers: Headers): Array<{ name: string; value: string }> { - return Array.from(headers.entries()).map(([name, value]) => ({ name, value })); -} - -async function fetchLotteMartHtmlByZyte( - url: string, - init: RequestInit, - timeout: number, - sessionCookie: string, - zyteApiKey: string, -): Promise { - const headers = withLotteMartSessionCookie( - { - Accept: 'text/html, */*; q=0.01', - ...init.headers, - }, - sessionCookie, - ); - const result = await requestByZyte({ - apiKey: zyteApiKey, - url, - timeout, - method: (init.method as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | undefined) || 'GET', - headers: toZyteHeaders(headers), - bodyText: toLotteMartBodyText(init.body), - tags: { service: 'lottemart' }, - }); - - if (!result.httpResponseBody) { - throw new Error('Zyte HTTP 응답 본문이 비어 있습니다.'); - } - - return decodeBase64(result.httpResponseBody); -} - function toBodyPreview(bodyText: string): string | null { const normalized = bodyText.trim().replace(/\s+/g, ' '); return normalized.length > 0 ? normalized.slice(0, 300) : null; @@ -192,34 +156,15 @@ export async function probeLotteMartRequest( } if (zyteApiKey) { - try { - const bodyText = await fetchLotteMartHtmlByZyte( - url, - init, - timeout, - sessionCookie, - zyteApiKey, - ); - attempts.push({ - used: 'zyte', - success: true, - status: 200, - statusText: 'OK', - error: null, - bodyPreview: toBodyPreview(bodyText), - sessionCookie: sessionCookie || null, - }); - } catch (error) { - attempts.push({ - used: 'zyte', - success: false, - status: null, - statusText: null, - error: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.', - bodyPreview: null, - sessionCookie: sessionCookie || null, - }); - } + attempts.push({ + used: 'zyte', + success: false, + status: null, + statusText: null, + error: ZYTE_COST_POLICY_MESSAGE, + bodyPreview: null, + sessionCookie: sessionCookie || null, + }); } return attempts; @@ -273,14 +218,7 @@ export async function fetchLotteMartHtml( return bodyText; } catch (error) { if (zyteApiKey && error instanceof Error && !error.message.includes('Zyte')) { - const fallbackCookie = await getCachedLotteMartSessionCookie(timeout); - return fetchLotteMartHtmlByZyte( - url, - init, - Math.min(timeout, FALLBACK_TIMEOUT_MS), - fallbackCookie || sessionCookie, - zyteApiKey, - ); + throw new Error(ZYTE_COST_POLICY_MESSAGE); } throw error; diff --git a/src/services/oliveyoung/client.ts b/src/services/oliveyoung/client.ts index da1223bb..7a07b540 100644 --- a/src/services/oliveyoung/client.ts +++ b/src/services/oliveyoung/client.ts @@ -1,12 +1,11 @@ /** - * 올리브영 Zyte 클라이언트 + * 올리브영 무료 API 클라이언트 * - * Zyte extract API를 통해 올리브영 내부 API를 우회 호출합니다. + * 공식 API 또는 운영자가 설정한 브라우저 릴레이를 호출합니다. */ import { OLIVEYOUNG_API } from './api.js'; import type { - OliveyoungApiResponse, OliveyoungProduct, OliveyoungProductStoreInventory, OliveyoungStore, @@ -16,12 +15,7 @@ import { resolveOliveyoungInStock, resolveOliveyoungStoreStock, } from './normalize.js'; -import { decodeBase64, requestByZyte } from '../../utils/zyte.js'; - -interface RequestOptions { - apiKey?: string; - timeout?: number; -} +import { requestOliveyoung, type OliveyoungRequestOptions as RequestOptions } from './transport.js'; interface FindStoresParams { latitude: number; @@ -127,48 +121,6 @@ function writeStaleResult( }); } -async function zyteExtract( - targetPath: string, - requestBody: Record, - options: RequestOptions = {} -): Promise { - const { timeout = 15000, apiKey } = options; - - try { - const result = await requestByZyte({ - apiKey, - timeout, - url: `${OLIVEYOUNG_API.BASE_URL}${targetPath}`, - method: 'POST', - headers: [ - { name: 'Content-Type', value: 'application/json' }, - { name: 'Accept', value: 'application/json' }, - { name: 'X-Requested-With', value: 'XMLHttpRequest' }, - ], - bodyText: JSON.stringify(requestBody), - tags: { service: 'oliveyoung' }, - }); - - if (result.statusCode !== 200 || !result.httpResponseBody) { - throw new Error(`올리브영 API 응답 실패: ${result.statusCode || 'unknown'}`); - } - - const decodedBody = decodeBase64(result.httpResponseBody); - const parsedBody = JSON.parse(decodedBody) as OliveyoungApiResponse; - - if (parsedBody.status !== 'SUCCESS') { - throw new Error(`올리브영 API 상태 오류: ${parsedBody.status || 'UNKNOWN'}`); - } - - return parsedBody; - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw new Error('올리브영 API 요청 시간 초과'); - } - throw error; - } -} - export async function fetchOliveyoungStores( params: FindStoresParams, options: RequestOptions = {} @@ -186,7 +138,7 @@ export async function fetchOliveyoungStores( const cacheKey = createOliveyoungStoreSearchCacheKey(params); try { - const body = await zyteExtract(OLIVEYOUNG_API.STORE_FINDER_PATH, payload, options); + const body = await requestOliveyoung(OLIVEYOUNG_API.STORE_FINDER_PATH, payload, options); const stores = (body.data?.storeList || []).map((store) => ({ storeCode: store.storeCode || '', @@ -227,7 +179,7 @@ export async function fetchOliveyoungProducts( const cacheKey = createOliveyoungProductSearchCacheKey(params); try { - const body = await zyteExtract(OLIVEYOUNG_API.PRODUCT_SEARCH_PATH, payload, options); + const body = await requestOliveyoung(OLIVEYOUNG_API.PRODUCT_SEARCH_PATH, payload, options); const list = body.data?.serachList || body.data?.searchList || []; const products = list.map((product) => { @@ -283,7 +235,7 @@ async function fetchOliveyoungProductId( return cached.productId; } - const body = await zyteExtract( + const body = await requestOliveyoung( OLIVEYOUNG_API.STOCK_GOODS_INFO_PATH, { goodsNo: normalizedGoodsNumber }, options @@ -307,7 +259,7 @@ async function fetchOliveyoungStockStores( const cacheKey = createOliveyoungStockStoresCacheKey(params); try { - const body = await zyteExtract( + const body = await requestOliveyoung( OLIVEYOUNG_API.STOCK_STORES_PATH, { productId: params.productId, diff --git a/src/services/oliveyoung/index.ts b/src/services/oliveyoung/index.ts index 7e7fd698..9b322f67 100644 --- a/src/services/oliveyoung/index.ts +++ b/src/services/oliveyoung/index.ts @@ -12,11 +12,13 @@ const OLIVEYOUNG_METADATA: ServiceMetadata = { id: 'oliveyoung', name: '올리브영', version: '1.0.0', - description: '올리브영 상품 검색, 주변 매장 탐색 및 재고 파악 서비스 (Zyte API 기반)', + description: '올리브영 상품 검색, 주변 매장 탐색 및 재고 파악 서비스 (무료 직접 요청 및 브라우저 릴레이)', }; interface OliveyoungServiceOptions { zyteApiKey?: string; + relayUrl?: string; + relayToken?: string; } class OliveyoungService implements ServiceProvider { @@ -26,9 +28,9 @@ class OliveyoungService implements ServiceProvider { getTools(): ToolRegistration[] { return [ - createSearchProductsTool(this.options.zyteApiKey), - createFindNearbyStoresTool(this.options.zyteApiKey), - createCheckInventoryTool(this.options.zyteApiKey), + createSearchProductsTool(this.options.zyteApiKey, this.options), + createFindNearbyStoresTool(this.options.zyteApiKey, this.options), + createCheckInventoryTool(this.options.zyteApiKey, this.options), ]; } } diff --git a/src/services/oliveyoung/tools/checkInventory.ts b/src/services/oliveyoung/tools/checkInventory.ts index 313500c9..74604f6d 100644 --- a/src/services/oliveyoung/tools/checkInventory.ts +++ b/src/services/oliveyoung/tools/checkInventory.ts @@ -1,3 +1,4 @@ +import type { OliveyoungRequestOptions } from '../transport.js'; /** * 올리브영 재고 파악 도구 */ @@ -25,7 +26,7 @@ interface CheckInventoryArgs { zyteApiKey?: string; } -async function checkInventory(args: CheckInventoryArgs): Promise { +async function checkInventory(args: CheckInventoryArgs, transport: OliveyoungRequestOptions): Promise { const { keyword, latitude = 37.5665, @@ -56,6 +57,7 @@ async function checkInventory(args: CheckInventoryArgs): Promise - checkInventory({ ...args, zyteApiKey: apiKey })) as (args: unknown) => Promise, + checkInventory({ ...args, zyteApiKey: apiKey }, transport)) as (args: unknown) => Promise, }; } diff --git a/src/services/oliveyoung/tools/findNearbyStores.ts b/src/services/oliveyoung/tools/findNearbyStores.ts index 78f84dd4..d5aee7e4 100644 --- a/src/services/oliveyoung/tools/findNearbyStores.ts +++ b/src/services/oliveyoung/tools/findNearbyStores.ts @@ -1,3 +1,4 @@ +import type { OliveyoungRequestOptions } from '../transport.js'; /** * 올리브영 주변 매장 탐색 도구 */ @@ -16,7 +17,7 @@ interface FindNearbyStoresArgs { zyteApiKey?: string; } -async function findNearbyStores(args: FindNearbyStoresArgs): Promise { +async function findNearbyStores(args: FindNearbyStoresArgs, transport: OliveyoungRequestOptions): Promise { const { latitude = 37.5665, longitude = 126.978, @@ -37,6 +38,7 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise - findNearbyStores({ ...args, zyteApiKey: apiKey })) as (args: unknown) => Promise, + findNearbyStores({ ...args, zyteApiKey: apiKey }, transport)) as (args: unknown) => Promise, }; } diff --git a/src/services/oliveyoung/tools/searchProducts.ts b/src/services/oliveyoung/tools/searchProducts.ts index a273a6c7..54fc2b8f 100644 --- a/src/services/oliveyoung/tools/searchProducts.ts +++ b/src/services/oliveyoung/tools/searchProducts.ts @@ -1,3 +1,4 @@ +import type { OliveyoungRequestOptions } from '../transport.js'; /** * 올리브영 상품 검색 도구 */ @@ -16,7 +17,7 @@ interface SearchProductsArgs { zyteApiKey?: string; } -async function searchProducts(args: SearchProductsArgs): Promise { +async function searchProducts(args: SearchProductsArgs, transport: OliveyoungRequestOptions): Promise { const { keyword, page = 1, @@ -42,6 +43,7 @@ async function searchProducts(args: SearchProductsArgs): Promise - searchProducts({ ...args, zyteApiKey: apiKey })) as (args: unknown) => Promise, + searchProducts({ ...args, zyteApiKey: apiKey }, transport)) as (args: unknown) => Promise, }; } diff --git a/src/services/oliveyoung/transport.ts b/src/services/oliveyoung/transport.ts new file mode 100644 index 00000000..472322ee --- /dev/null +++ b/src/services/oliveyoung/transport.ts @@ -0,0 +1,54 @@ +/** 올리브영 무료 직접 요청 및 운영자가 설정한 브라우저 릴레이 전송. */ +import { fetchJson } from '../../utils/http.js'; +import { OLIVEYOUNG_API } from './api.js'; +import type { OliveyoungApiResponse } from './types.js'; + +export interface OliveyoungRequestOptions { + /** 기존 호출부와의 호환용이며 유료 요청에는 사용하지 않습니다. */ + apiKey?: string; + timeout?: number; + relayUrl?: string; + relayToken?: string; +} + +export async function requestOliveyoung( + path: string, + body: Record, + options: OliveyoungRequestOptions = {}, +): Promise { + const { timeout = 15000, relayUrl, relayToken } = options; + const headers: Record = { + 'Content-Type': 'application/json', Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }; + let url = `${OLIVEYOUNG_API.BASE_URL}${path}`; + if (relayUrl) { + let relay: URL; + try { relay = new URL(relayUrl); } catch { + throw new Error('올리브영 릴레이 URL은 HTTPS 또는 로컬 HTTP 주소여야 합니다.'); + } + if (relay.username || relay.password || relay.search || relay.hash || + (relay.protocol !== 'https:' && !(relay.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(relay.hostname)))) { + throw new Error('올리브영 릴레이 URL은 HTTPS 또는 로컬 HTTP 주소여야 합니다.'); + } + if (!relayToken?.trim()) throw new Error('OY_RELAY_TOKEN이 필요합니다.'); + url = `${relayUrl.replace(/\/$/, '')}/v1/oliveyoung/${path.split('/').pop()}`; + headers.Authorization = `Bearer ${relayToken}`; + } + let result: OliveyoungApiResponse; + try { + result = await fetchJson(url, { + method: 'POST', headers, body: JSON.stringify(body), timeout, retries: 0, expectedStatus: 200, + }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('올리브영 API 요청 시간 초과'); + } + if (relayUrl) throw new Error('올리브영 브라우저 릴레이 요청 실패'); + throw new Error('올리브영 직접 요청 실패. 운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN으로 브라우저 릴레이를 설정해주세요.', { cause: error }); + } + if (result?.status !== 'SUCCESS') { + throw new Error(`올리브영 API 상태 오류: ${result?.status || 'UNKNOWN'}`); + } + return result; +} diff --git a/src/services/seveneleven/client.ts b/src/services/seveneleven/client.ts index 57006ce8..b4e0f5bd 100644 --- a/src/services/seveneleven/client.ts +++ b/src/services/seveneleven/client.ts @@ -3,6 +3,7 @@ */ /* c8 ignore start */ +import { withSevenElevenReadCache } from './readCache.js'; import { fetchJsonWithZyteFallback } from '../../utils/zyteJsonFallback.js'; import { SEVENELEVEN_API } from './api.js'; import type { @@ -188,7 +189,7 @@ async function requestSevenElevenJson( const { timeout = 15000, zyteApiKey } = options; const url = `${SEVENELEVEN_API.BASE_URL}${path}`; - return fetchJsonWithZyteFallback>(url, { + return withSevenElevenReadCache(path, body, timeout, () => fetchJsonWithZyteFallback>(url, { ...SEVENELEVEN_DEFAULT_FETCH_OPTIONS, method, retryUnsafeMethods: method === 'POST', @@ -197,7 +198,7 @@ async function requestSevenElevenJson( body: method === 'POST' ? JSON.stringify(body || {}) : undefined, zyteApiKey, zyteTags: { service: 'seveneleven' }, - }); + })); } export async function searchSevenElevenProducts( diff --git a/src/services/seveneleven/readCache.ts b/src/services/seveneleven/readCache.ts new file mode 100644 index 00000000..ce5dcd0e --- /dev/null +++ b/src/services/seveneleven/readCache.ts @@ -0,0 +1,53 @@ +/** 공개 상품·매장 조회를 REST와 MCP에서 함께 재사용합니다. 재고는 저장하지 않습니다. */ +import { SEVENELEVEN_API } from './api.js'; +import type { SevenElevenApiEnvelope } from './types.js'; + +const MAX_ENTRIES = 128; +const TTL_BY_PATH = new Map([ + [SEVENELEVEN_API.SEARCH_GOODS_PATH, 5 * 60_000], + [SEVENELEVEN_API.SEARCH_STORE_PATH, 30 * 60_000], + [SEVENELEVEN_API.SEARCH_POPWORD_PATH, 5 * 60_000], + [SEVENELEVEN_API.PRODUCT_PAGES_PATH, 5 * 60_000], + [SEVENELEVEN_API.PRODUCT_ISSUES_PATH, 5 * 60_000], + [SEVENELEVEN_API.EXHIBITION_MAIN_PATH, 5 * 60_000], +]); +const values = new Map }>(); +const pending = new Map>>(); + +export function clearSevenElevenReadCache(): void { + values.clear(); + pending.clear(); +} + +export async function withSevenElevenReadCache( + path: string, + body: unknown, + timeout: number | undefined, + request: () => Promise>, +): Promise> { + const ttl = TTL_BY_PATH.get(path.split('?')[0]); + if (!ttl) return request(); + const key = JSON.stringify([path, body, timeout]); + const cached = values.get(key); + if (cached && cached.expiresAt > Date.now()) { + return structuredClone(cached.value) as SevenElevenApiEnvelope; + } + values.delete(key); + const existing = pending.get(key); + if (existing) return structuredClone(await existing) as SevenElevenApiEnvelope; + // 부하가 높아져도 진행 중인 조회 키를 무제한으로 보관하지 않습니다. + if (pending.size >= MAX_ENTRIES) return request(); + const promise = request().then((value) => { + if (value.success === true && value.data !== undefined) { + if (values.size >= MAX_ENTRIES) values.delete(values.keys().next().value!); + values.set(key, { expiresAt: Date.now() + ttl, value: structuredClone(value) }); + } + return value; + }); + pending.set(key, promise); + try { + return structuredClone(await promise); + } finally { + pending.delete(key); + } +} diff --git a/src/utils/http.ts b/src/utils/http.ts index b56c2ddf..b77f509f 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -4,6 +4,8 @@ export interface FetchOptions extends RequestInit { timeout?: number; + /** fetchJson에서 허용할 정확한 HTTP 상태입니다. 생략하면 모든 2xx를 허용합니다. */ + expectedStatus?: number; retries?: number; retryDelayMs?: number; retryStatusCodes?: number[]; @@ -120,6 +122,7 @@ async function requestWithTimeout( retryUnsafeMethods = false, retryMethods = DEFAULT_RETRY_METHODS, onRetry, + expectedStatus: _expectedStatus, ...restOptions } = options; const maxAttempts = Math.max(1, Math.trunc(retries) + 1); @@ -197,7 +200,7 @@ async function readTextResponse(url: string, options: FetchOptions) { export async function fetchJson(url: string, options: FetchOptions = {}): Promise { const { response, body } = await readTextResponse(url, options); - if (!response.ok) { + if (!response.ok || (options.expectedStatus !== undefined && response.status !== options.expectedStatus)) { throw new HttpError(response.status, response.statusText, body); } diff --git a/src/utils/zyte.ts b/src/utils/zyte.ts index 7453e120..6e8878c3 100644 --- a/src/utils/zyte.ts +++ b/src/utils/zyte.ts @@ -2,7 +2,7 @@ * Zyte Extract API 공통 유틸리티 */ -import { createTimeoutController } from './http.js'; +import { ZYTE_COST_POLICY_MESSAGE } from '../core/errors.js'; export interface ZyteExtractResponse { statusCode?: number; @@ -23,34 +23,6 @@ export interface ZyteExtractOptions { tags?: Record; } -export function resolveZyteApiKey(apiKey?: string): string { - if (apiKey && apiKey.trim().length > 0) { - return apiKey; - } - - /* c8 ignore start */ - if (typeof process !== 'undefined' && process.env?.ZYTE_API_KEY) { - return process.env.ZYTE_API_KEY; - } - /* c8 ignore end */ - - throw new Error('ZYTE_API_KEY가 설정되지 않았습니다. .env 또는 Cloudflare Worker Secret을 확인해주세요.'); -} - -export function encodeBasicAuth(apiKey: string): string { - if (typeof btoa === 'function') { - return btoa(`${apiKey}:`); - } - - /* c8 ignore start */ - if (typeof Buffer !== 'undefined') { - return Buffer.from(`${apiKey}:`).toString('base64'); - } - /* c8 ignore end */ - - throw new Error('Basic 인증 인코딩을 지원하지 않는 런타임입니다.'); -} - export function decodeBase64(value: string): string { if (typeof atob === 'function') { const binary = atob(value); @@ -67,114 +39,9 @@ export function decodeBase64(value: string): string { throw new Error('Base64 디코딩을 지원하지 않는 런타임입니다.'); } -function isAbortError(error: unknown): boolean { - return error instanceof Error && error.name === 'AbortError'; -} - -class RetryableZyteError extends Error {} - -function isRetryableZyteStatus(status: number): boolean { - return status === 408 || status === 429 || status >= 500; -} - -function isRetryableTargetStatus(statusCode?: number): boolean { - return typeof statusCode === 'number' && statusCode >= 500; -} - -function isRetryableError(error: unknown): boolean { - return isAbortError(error) || error instanceof RetryableZyteError; -} - -function parseZyteResponseBody(text: string): ZyteExtractResponse { - try { - return JSON.parse(text) as ZyteExtractResponse; - } catch { - return { - detail: text.trim().replace(/\s+/g, ' ').slice(0, 300), - }; - } -} - -function wait(ms: number): Promise { - if (ms <= 0) { - return Promise.resolve(); - } - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -export async function requestByZyte(options: ZyteExtractOptions): Promise { - const { - timeout = 15000, - retries = 1, - retryDelayMs = 250, - url, - method = 'GET', - headers = [], - bodyText, - apiKey, - tags, - } = options; - const auth = encodeBasicAuth(resolveZyteApiKey(apiKey)); - const maxAttempts = Math.max(1, Math.trunc(retries) + 1); - - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const { controller, timeoutId } = createTimeoutController(timeout); - - try { - let response: Response; - try { - response = await fetch('https://api.zyte.com/v1/extract', { - method: 'POST', - headers: { - Authorization: `Basic ${auth}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ - url, - httpRequestMethod: method, - customHttpRequestHeaders: headers, - httpRequestText: bodyText, - httpResponseBody: true, - ...(tags ? { tags } : {}), - }), - signal: controller.signal, - }); - } catch (error) { - if (error instanceof TypeError) { - throw new RetryableZyteError(error.message); - } - throw error; - } - - const result = parseZyteResponseBody(await response.text()); - - if (!response.ok) { - const message = `Zyte API 호출 실패: ${response.status} ${result.detail || result.title || ''}`.trim(); - if (isRetryableZyteStatus(response.status)) { - throw new RetryableZyteError(message); - } - throw new Error(message); - } - - if (isRetryableTargetStatus(result.statusCode) && attempt < maxAttempts) { - throw new RetryableZyteError(`Zyte target response failed: ${result.statusCode}`); - } - - return result; - } catch (error) { - if (!isRetryableError(error) || attempt >= maxAttempts) { - throw error; - } - await wait(retryDelayMs); - } finally { - clearTimeout(timeoutId); - } - } - - throw new Error('Zyte API 호출 실패'); +/** 키와 재시도 설정에 관계없이 유료 네트워크 경로를 차단합니다. */ +export async function requestByZyte(_options: ZyteExtractOptions): Promise { + throw new Error(ZYTE_COST_POLICY_MESSAGE); } export function decodeZyteHttpBody(result: ZyteExtractResponse): TResponse { diff --git a/src/utils/zyteJsonFallback.ts b/src/utils/zyteJsonFallback.ts index e00ba4c0..a1a8901e 100644 --- a/src/utils/zyteJsonFallback.ts +++ b/src/utils/zyteJsonFallback.ts @@ -1,14 +1,9 @@ /** - * 차단 응답에만 Zyte를 사용하는 JSON 전송 유틸리티 + * 직접 JSON 조회와 유료 대체 경로 중지 안내 */ import { fetchJson, HttpError, type FetchOptions } from './http.js'; -import { - decodeZyteHttpBody, - requestByZyte, - type ZyteExtractOptions, - type ZyteExtractResponse, -} from './zyte.js'; +import { requestByZyte } from './zyte.js'; export interface ZyteJsonFallbackOptions extends FetchOptions { zyteApiKey?: string; @@ -16,38 +11,6 @@ export interface ZyteJsonFallbackOptions extends FetchOptions { } const ZYTE_FALLBACK_STATUSES = new Set([400, 403, 429]); -const ZYTE_METHODS = new Set([ - 'GET', - 'POST', - 'PUT', - 'PATCH', - 'DELETE', -]); - -function normalizeMethod(method?: string): ZyteExtractOptions['method'] { - const normalized = (method || 'GET').toUpperCase() as ZyteExtractOptions['method']; - if (!ZYTE_METHODS.has(normalized)) { - throw new Error(`Zyte에서 지원하지 않는 HTTP 메서드입니다: ${normalized}`); - } - return normalized; -} - -function toZyteHeaders(headers?: HeadersInit): Array<{ name: string; value: string }> { - const normalized = new Headers(headers); - const result: Array<{ name: string; value: string }> = []; - normalized.forEach((value, name) => { - result.push({ name, value }); - }); - return result; -} - -function assertSuccessfulTarget(result: ZyteExtractResponse): void { - const status = result.statusCode; - if (typeof status !== 'number' || status < 200 || status >= 300) { - throw new Error(`Zyte 대상 응답 실패: ${status ?? '알 수 없음'}`); - } -} - function hasZyteApiKey(apiKey?: string): boolean { if (apiKey?.trim()) { return true; @@ -74,16 +37,5 @@ export async function fetchJsonWithZyteFallback( } } - const result = await requestByZyte({ - apiKey: zyteApiKey, - url, - timeout: directOptions.timeout, - retries: 0, - method: normalizeMethod(directOptions.method), - headers: toZyteHeaders(directOptions.headers), - bodyText: typeof directOptions.body === 'string' ? directOptions.body : undefined, - tags: zyteTags, - }); - assertSuccessfulTarget(result); - return decodeZyteHttpBody(result); + return requestByZyte({ apiKey: zyteApiKey, url, tags: zyteTags }); } diff --git a/tests/api/cgv-handlers.test.ts b/tests/api/cgv-handlers.test.ts index 73f4e372..98fb2be5 100644 --- a/tests/api/cgv-handlers.test.ts +++ b/tests/api/cgv-handlers.test.ts @@ -46,7 +46,7 @@ describe('handleCgvFindTheaters', () => { success: false, error: { code: 'CGV_UPSTREAM_UNAVAILABLE', - message: 'CGV 원본 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.', + message: expect.stringContaining('비용 정책'), }, }), 503, diff --git a/tests/api/configStatus.test.ts b/tests/api/configStatus.test.ts new file mode 100644 index 00000000..25daa8b3 --- /dev/null +++ b/tests/api/configStatus.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { buildConfigStatus } from '../../src/api/configStatus.js'; + +describe('Zyte 설정 상태', () => { + it('키 설정 여부와 비용 정책에 따른 비활성화를 구분한다', () => { + expect(buildConfigStatus({ ZYTE_API_KEY: 'test-key' }).zyteApiKey).toEqual({ + configured: true, + enabled: false, + usedBy: [], + }); + }); + + it('키가 없어도 유료 호출은 비활성화되어 있다', () => { + expect(buildConfigStatus().zyteApiKey).toEqual({ + configured: false, + enabled: false, + usedBy: [], + }); + }); +}); diff --git a/tests/api/cu-handlers.test.ts b/tests/api/cu-handlers.test.ts index b01a7385..6f3df58c 100644 --- a/tests/api/cu-handlers.test.ts +++ b/tests/api/cu-handlers.test.ts @@ -157,49 +157,12 @@ describe('handleCuFindStores', () => { }); describe('handleCuCheckInventory', () => { - it('Worker Zyte 키로 재고 폴백을 시도하고 차단 상태를 성공 envelope로 반환한다', async () => { - const zyteBan = new Response( - JSON.stringify({ - type: '/download/website-ban', - title: 'Website Ban', - status: 520, - detail: 'Zyte API could not get a ban-free response.', - }), - { - status: 520, - headers: { 'Content-Type': 'application/json' }, - }, - ); - mockFetch - .mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] }))) - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce(zyteBan) - .mockResolvedValueOnce(zyteBan.clone()); - - const ctx = createMockContextWithEnv( - { keyword: '과자', storeCheck: 'false' }, - { ZYTE_API_KEY: 'worker-key' }, - ); + it('원본 차단은 유료 호출 없이 비재시도 오류로 반환한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('{}')).mockResolvedValueOnce(new Response('blocked',{status:403})); + const ctx=createMockContextWithEnv({keyword:'과자',storeCheck:'false'},{ZYTE_API_KEY:'worker-key'}); await handleCuCheckInventory(ctx); - - expect(ctx.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: true, - data: expect.objectContaining({ - inventory: expect.objectContaining({ - available: false, - unavailableReason: expect.stringContaining('Website Ban'), - totalCount: 0, - items: [], - }), - }), - }), - ); - - const zyteHeaders = new Headers(mockFetch.mock.calls[2][1]?.headers); - expect(zyteHeaders.get('Authorization')).toBe( - `Basic ${Buffer.from('worker-key:', 'utf8').toString('base64')}`, - ); + expect(ctx.json).toHaveBeenCalledWith(expect.objectContaining({success:false,diagnostics:expect.objectContaining({retryable:false})}),500); + expect(mockFetch).toHaveBeenCalledTimes(2); }); it('CU 재고 검색 결과를 반환한다', async () => { diff --git a/tests/api/gs25-handlers.test.ts b/tests/api/gs25-handlers.test.ts index f53280f1..6ade6936 100644 --- a/tests/api/gs25-handlers.test.ts +++ b/tests/api/gs25-handlers.test.ts @@ -38,27 +38,42 @@ function createMockContext(query: Record = {}) { describe('handleGs25FindStores', () => { it('재고 API 인증 실패 시 키워드 공개 검색으로 복구한다', async () => { - mockFetch.mockResolvedValueOnce(new Response('{}', { status: 401 })) - .mockResolvedValueOnce(new Response(JSON.stringify([ - { shopCode: 'VY010', shopName: 'GS25강남', posX: 127.02, posY: 37.49 }, - ]))); + mockFetch + .mockResolvedValueOnce(new Response('{}', { status: 401 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify([{ shopCode: 'VY010', shopName: 'GS25강남', posX: 127.02, posY: 37.49 }]), + ), + ); const ctx = createMockContext({ keyword: '강남' }); await handleGs25FindStores(ctx); - expect(ctx.json).toHaveBeenCalledWith(expect.objectContaining({ - success: true, data: expect.objectContaining({ fallbackUsed: true, - stores: [expect.objectContaining({ storeCode: 'VY010' })] }), - })); + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + fallbackUsed: true, + stores: [expect.objectContaining({ storeCode: 'VY010' })], + }), + }), + ); expect(mockFetch).toHaveBeenCalledTimes(2); }); it.each([401, 200])('원본 %s 이후 공개 매장 검색 실패를 숨기지 않는다', async (status) => { - mockFetch.mockResolvedValueOnce(new Response('{"stores":[]}', { status })) - .mockImplementation(() => Promise.resolve(new Response('public unavailable', { status: 503 }))); + mockFetch + .mockResolvedValueOnce(new Response('{"stores":[]}', { status })) + .mockImplementation(() => + Promise.resolve(new Response('public unavailable', { status: 503 })), + ); const ctx = createMockContext({ keyword: '강남' }); await handleGs25FindStores(ctx); - expect(ctx.json).toHaveBeenCalledWith(expect.objectContaining({ - success: false, error: expect.objectContaining({ message: expect.stringContaining('503') }), - }), 500); + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('503') }), + }), + 500, + ); }); it('매장 검색 결과를 반환한다', async () => { @@ -84,9 +99,13 @@ describe('handleGs25FindStores', () => { it('store/stock 매장 조회가 0건이면 GS25 웹 매장 검색으로 fallback한다', async () => { mockFetch .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))) - .mockResolvedValueOnce(new Response(JSON.stringify([ - { shopCode: 'VY010', shopName: 'GS25강남', address: '서울', posY: 37.49, posX: 127.02 }, - ]))); + .mockResolvedValueOnce( + new Response( + JSON.stringify([ + { shopCode: 'VY010', shopName: 'GS25강남', address: '서울', posY: 37.49, posX: 127.02 }, + ]), + ), + ); const ctx = createMockContext({ keyword: '강남', limit: '1' }); await handleGs25FindStores(ctx); @@ -209,44 +228,22 @@ describe('handleGs25SearchProducts', () => { ); }); - it('상품 검색에서 Worker 403이 발생하면 env Zyte 키로 fallback한다', async () => { - const zyteBody = Buffer.from( - JSON.stringify({ - SearchQueryResult: { - Collection: [ - { - Documentset: { - Document: [{ field: { itemCode: '123', itemName: '오감자', stockCheckYn: 'Y' } }], - }, - }, - ], - }, - }), - 'utf8', - ).toString('base64'); - - mockFetch - .mockResolvedValueOnce(new Response('forbidden', { status: 403, statusText: 'Forbidden' })) - .mockResolvedValueOnce( - new Response(JSON.stringify({ statusCode: 200, httpResponseBody: zyteBody })), - ); - + it('handleGs25SearchProducts는 차단 시 유료 호출 없이 비용 정책 오류를 반환한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); const ctx = createMockContext({ keyword: '오감자' }); - (ctx as { env: Record }).env = { ZYTE_API_KEY: 'test-zyte-key' }; + (ctx as { env: Record }).env = { ZYTE_API_KEY: 'test-key' }; await handleGs25SearchProducts(ctx); - expect(ctx.json).toHaveBeenCalledWith( expect.objectContaining({ - success: true, - data: expect.objectContaining({ - count: 1, - products: [expect.objectContaining({ itemCode: '123' })], + success: false, + diagnostics: expect.objectContaining({ + retryable: false, + hint: expect.stringContaining('비용 정책'), }), }), + 500, ); - expect(String((mockFetch.mock.calls[1][1] as RequestInit).body)).toContain( - '"url":"https://b2c-apigw.woodongs.com/search/v3/totalSearch"', - ); + expect(mockFetch).toHaveBeenCalledTimes(1); }); it('상품 검색 중 예외 발생 시 에러를 반환한다', async () => { @@ -294,7 +291,8 @@ describe('handleGs25CheckInventory', () => { success: false, error: { code: 'GS25_UPSTREAM_UNAVAILABLE', - message: 'GS25 재고 서비스 인증을 사용할 수 없습니다. 운영자는 GS25_API_KEY 설정을 확인하세요.', + message: + 'GS25 재고 서비스 인증을 사용할 수 없습니다. 운영자는 GS25_API_KEY 설정을 확인하세요.', }, }), 503, @@ -434,51 +432,22 @@ describe('handleGs25CheckInventory', () => { ); }); - it('keyword 기반 재고 조회도 상품 검색 403을 env Zyte 키로 fallback한다', async () => { - const zyteBody = Buffer.from( - JSON.stringify({ - SearchQueryResult: { - Collection: [ - { Documentset: { Document: [{ field: { itemCode: '123', itemName: '오감자' } }] } }, - ], - }, - }), - 'utf8', - ).toString('base64'); - - mockFetch - .mockResolvedValueOnce(new Response('forbidden', { status: 403, statusText: 'Forbidden' })) - .mockResolvedValueOnce( - new Response(JSON.stringify({ statusCode: 200, httpResponseBody: zyteBody })), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - stores: [ - { - storeCode: '1', - storeName: '강남역점', - searchItemName: '오감자', - realStockQuantity: 1, - }, - ], - }), - ), - ); - + it('handleGs25CheckInventory는 차단 시 유료 호출 없이 비용 정책 오류를 반환한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); const ctx = createMockContext({ keyword: '오감자' }); - (ctx as { env: Record }).env = { ZYTE_API_KEY: 'test-zyte-key' }; + (ctx as { env: Record }).env = { ZYTE_API_KEY: 'test-key' }; await handleGs25CheckInventory(ctx); - expect(ctx.json).toHaveBeenCalledWith( expect.objectContaining({ - success: true, - data: expect.objectContaining({ - itemCode: '123', - inventory: expect.objectContaining({ inStockStoreCount: 1 }), + success: false, + diagnostics: expect.objectContaining({ + retryable: false, + hint: expect.stringContaining('비용 정책'), }), }), + 500, ); + expect(mockFetch).toHaveBeenCalledTimes(1); }); it('storeKeyword를 먼저 직접 지오코딩해 재고 조회 좌표로 사용한다', async () => { diff --git a/tests/api/oliveyoung-handlers.test.ts b/tests/api/oliveyoung-handlers.test.ts index 5490140d..406db3b3 100644 --- a/tests/api/oliveyoung-handlers.test.ts +++ b/tests/api/oliveyoung-handlers.test.ts @@ -8,6 +8,7 @@ import { handleOliveyoungFindStores, handleOliveyoungCheckInventory, } from '../../src/api/handlers.js'; +import * as oliveyoungClient from '../../src/services/oliveyoung/client.js'; import { __testOnlyClearOliveyoungCaches } from '../../src/services/oliveyoung/client.js'; const mockFetch = vi.fn(); @@ -38,20 +39,14 @@ function createMockContext(query: Record = {}) { } as unknown as Parameters[0]; } -function createMockZyteResponse(body: unknown) { - const encoded = Buffer.from(JSON.stringify(body), 'utf8').toString('base64'); - return new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: encoded, - }) - ); +function createMockDirectResponse(body: unknown) { + return Response.json(body); } describe('handleOliveyoungFindStores', () => { it('올리브영 매장 검색 결과를 반환한다', async () => { mockFetch.mockResolvedValue( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -84,7 +79,7 @@ describe('handleOliveyoungFindStores', () => { it('timeoutMs 쿼리를 올리브영 매장 검색 요청에 전달한다', async () => { const timeoutSpy = vi.spyOn(globalThis, 'setTimeout'); mockFetch.mockResolvedValue( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 0, storeList: [] }, }) @@ -99,7 +94,7 @@ describe('handleOliveyoungFindStores', () => { it('매장 검색 timeoutMs 쿼리가 유효하지 않으면 기본 제한 시간을 사용한다', async () => { const timeoutSpy = vi.spyOn(globalThis, 'setTimeout'); mockFetch.mockResolvedValue( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 0, storeList: [] }, }) @@ -120,7 +115,7 @@ describe('handleOliveyoungFindStores', () => { expect(ctx.json).toHaveBeenCalledWith( expect.objectContaining({ success: false, - error: { code: 'OLIVEYOUNG_STORE_SEARCH_FAILED', message: 'zyte fail' }, + error: { code: 'OLIVEYOUNG_STORE_SEARCH_FAILED', message: expect.stringContaining('올리브영 직접 요청 실패') }, }), 500 ); @@ -128,7 +123,7 @@ describe('handleOliveyoungFindStores', () => { it('keyword 없이도 기본 검색을 수행한다', async () => { mockFetch.mockResolvedValue( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 0, storeList: [] }, }) @@ -153,7 +148,7 @@ describe('handleOliveyoungFindStores', () => { expect(ctx.json).toHaveBeenCalledWith( expect.objectContaining({ success: false, - error: { code: 'OLIVEYOUNG_STORE_SEARCH_FAILED', message: '알 수 없는 오류가 발생했습니다.' }, + error: { code: 'OLIVEYOUNG_STORE_SEARCH_FAILED', message: expect.stringContaining('올리브영 직접 요청 실패') }, }), 500 ); @@ -163,7 +158,7 @@ describe('handleOliveyoungFindStores', () => { describe('handleOliveyoungSearchProducts', () => { it('올리브영 상품 검색 결과를 반환한다', async () => { mockFetch.mockResolvedValue( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -207,7 +202,7 @@ describe('handleOliveyoungSearchProducts', () => { it('timeoutMs 쿼리를 올리브영 상품 검색 요청에 전달한다', async () => { const timeoutSpy = vi.spyOn(globalThis, 'setTimeout'); mockFetch.mockResolvedValue( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 0, nextPage: false, serachList: [] }, }) @@ -222,7 +217,7 @@ describe('handleOliveyoungSearchProducts', () => { it('상품 검색 timeoutMs 쿼리가 0 이하이면 기본 제한 시간을 사용한다', async () => { const timeoutSpy = vi.spyOn(globalThis, 'setTimeout'); mockFetch.mockResolvedValue( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 0, nextPage: false, serachList: [] }, }) @@ -256,7 +251,7 @@ describe('handleOliveyoungSearchProducts', () => { expect(ctx.json).toHaveBeenCalledWith( expect.objectContaining({ success: false, - error: { code: 'OLIVEYOUNG_PRODUCT_SEARCH_FAILED', message: 'search fail' }, + error: { code: 'OLIVEYOUNG_PRODUCT_SEARCH_FAILED', message: expect.stringContaining('올리브영 직접 요청 실패') }, }), 500 ); @@ -271,7 +266,7 @@ describe('handleOliveyoungSearchProducts', () => { expect(ctx.json).toHaveBeenCalledWith( expect.objectContaining({ success: false, - error: { code: 'OLIVEYOUNG_PRODUCT_SEARCH_FAILED', message: '알 수 없는 오류가 발생했습니다.' }, + error: { code: 'OLIVEYOUNG_PRODUCT_SEARCH_FAILED', message: expect.stringContaining('올리브영 직접 요청 실패') }, }), 500 ); @@ -282,7 +277,7 @@ describe('handleOliveyoungCheckInventory', () => { it('올리브영 재고 정보를 반환한다', async () => { mockFetch .mockResolvedValueOnce( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -299,7 +294,7 @@ describe('handleOliveyoungCheckInventory', () => { }) ) .mockResolvedValueOnce( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -319,13 +314,13 @@ describe('handleOliveyoungCheckInventory', () => { }) ) .mockResolvedValueOnce( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8801' } }, }) ) .mockResolvedValueOnce( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -372,13 +367,13 @@ describe('handleOliveyoungCheckInventory', () => { const timeoutSpy = vi.spyOn(globalThis, 'setTimeout'); mockFetch .mockResolvedValueOnce( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, storeList: [{ storeCode: 'D176', storeName: '올리브영 명동 타운' }] }, }) ) .mockResolvedValueOnce( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -388,13 +383,13 @@ describe('handleOliveyoungCheckInventory', () => { }) ) .mockResolvedValueOnce( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8801' } }, }) ) .mockResolvedValueOnce( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, storeList: [{ storeCode: 'D176', salesStoreYn: true, remainQuantity: 2 }] }, }) @@ -419,13 +414,13 @@ describe('handleOliveyoungCheckInventory', () => { it('stockCheckLimit 쿼리로 주변 매장 재고 보강 상품 수를 제한한다', async () => { mockFetch .mockResolvedValueOnce( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, storeList: [{ storeCode: 'D176', storeName: '올리브영 명동 타운' }] }, }) ) .mockResolvedValueOnce( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 2, @@ -438,13 +433,13 @@ describe('handleOliveyoungCheckInventory', () => { }) ) .mockResolvedValueOnce( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8801' } }, }) ) .mockResolvedValueOnce( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, storeList: [{ storeCode: 'D176', salesStoreYn: true, remainQuantity: 2 }] }, }) @@ -470,13 +465,13 @@ describe('handleOliveyoungCheckInventory', () => { it('stockCheckLimit 쿼리가 유효하지 않으면 기본 보강 상품 수를 사용한다', async () => { mockFetch .mockResolvedValueOnce( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, storeList: [{ storeCode: 'D176', storeName: '올리브영 명동 타운' }] }, }) ) .mockResolvedValueOnce( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 2, @@ -489,25 +484,25 @@ describe('handleOliveyoungCheckInventory', () => { }) ) .mockResolvedValueOnce( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8801' } }, }) ) .mockResolvedValueOnce( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8802' } }, }) ) .mockResolvedValueOnce( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, storeList: [{ storeCode: 'D176', salesStoreYn: true, remainQuantity: 2 }] }, }) ) .mockResolvedValueOnce( - createMockZyteResponse({ + createMockDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, storeList: [{ storeCode: 'D177', salesStoreYn: true, remainQuantity: 1 }] }, }) @@ -552,7 +547,7 @@ describe('handleOliveyoungCheckInventory', () => { expect(ctx.json).toHaveBeenCalledWith( expect.objectContaining({ success: false, - error: { code: 'OLIVEYOUNG_INVENTORY_CHECK_FAILED', message: 'inventory fail' }, + error: { code: 'OLIVEYOUNG_INVENTORY_CHECK_FAILED', message: expect.stringContaining('올리브영 직접 요청 실패') }, }), 500 ); @@ -567,9 +562,22 @@ describe('handleOliveyoungCheckInventory', () => { expect(ctx.json).toHaveBeenCalledWith( expect.objectContaining({ success: false, - error: { code: 'OLIVEYOUNG_INVENTORY_CHECK_FAILED', message: '알 수 없는 오류가 발생했습니다.' }, + error: { code: 'OLIVEYOUNG_INVENTORY_CHECK_FAILED', message: expect.stringContaining('올리브영 직접 요청 실패') }, }), 500 ); }); }); + +it.each([ + ['fetchOliveyoungProducts', handleOliveyoungSearchProducts], + ['fetchOliveyoungStores', handleOliveyoungFindStores], + ['fetchOliveyoungProducts', handleOliveyoungCheckInventory], +] as const)('도메인 함수가 비 Error 값을 던져도 핸들러가 안전하게 응답한다: %s', async (method, handler) => { + vi.spyOn(oliveyoungClient, method).mockRejectedValueOnce('unexpected'); + const ctx = createMockContext({ keyword: '과자', latitude: '37.5', longitude: '127' }); + await handler(ctx); + expect(ctx.json).toHaveBeenCalledWith(expect.objectContaining({ + success: false, error: expect.objectContaining({ message: '알 수 없는 오류가 발생했습니다.' }), + }), 500); +}); diff --git a/tests/api/response.test.ts b/tests/api/response.test.ts index a22659ef..12ae7af4 100644 --- a/tests/api/response.test.ts +++ b/tests/api/response.test.ts @@ -105,3 +105,30 @@ describe('GS25 인증 장애 안내', () => { expect(diagnostics.hint).toContain('GS25_API_KEY'); }); }); + +describe('Zyte 비용 정책 진단', () => { + it('서비스 오류로 감싸져도 재시도나 키 설정을 권하지 않는다', () => { + const diagnostics = toStandardErrorDiagnostics( + 'OLIVEYOUNG_STORE_SEARCH_FAILED', + '조회 실패: Zyte 유료 호출은 비용 정책에 따라 비활성화되어 있습니다.', + { status: 503 }, + ); + expect(diagnostics.retryable).toBe(false); + expect(diagnostics.hint).toContain('비용 정책'); + expect(diagnostics.hint).not.toContain('ZYTE_API_KEY'); + }); +}); + +describe('올리브영 무료 중계 설정 안내', () => { + it.each([ + '올리브영 릴레이 URL은 HTTPS 또는 로컬 HTTP 주소여야 합니다.', + 'OY_RELAY_TOKEN이 필요합니다.', + '올리브영 직접 요청 실패. 운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN으로 브라우저 릴레이를 설정해주세요.', + ])('설정이 필요한 오류는 재시도를 권하지 않는다: %s', (message) => { + expect(toStandardErrorDiagnostics('OLIVEYOUNG_PRODUCT_SEARCH_FAILED', message, { status: 500 })) + .toMatchObject({ retryable: false, hint: '운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN 및 브라우저 릴레이 실행 상태를 확인하세요.' }); + }); + it.each(['올리브영 브라우저 릴레이 요청 실패', '올리브영 API 요청 시간 초과'])('일시적인 실행 오류의 재시도는 유지한다: %s', (message) => { + expect(toStandardErrorDiagnostics('OLIVEYOUNG_PRODUCT_SEARCH_FAILED', message, { status: 500 }).retryable).toBe(true); + }); +}); diff --git a/tests/api/seveneleven-handlers.test.ts b/tests/api/seveneleven-handlers.test.ts index 973c8b26..68a66369 100644 --- a/tests/api/seveneleven-handlers.test.ts +++ b/tests/api/seveneleven-handlers.test.ts @@ -1,3 +1,4 @@ +import { clearSevenElevenReadCache } from '../../src/services/seveneleven/readCache.js'; /** * 세븐일레븐 API 핸들러 테스트 */ @@ -14,6 +15,7 @@ import { const mockFetch = vi.fn(); beforeEach(() => { + clearSevenElevenReadCache(); mockFetch.mockReset(); vi.stubGlobal('fetch', mockFetch); }); @@ -22,10 +24,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -function createMockContext( - query: Record = {}, - env: Record = {}, -) { +function createMockContext(query: Record = {}, env: Record = {}) { return { env, req: { @@ -87,53 +86,21 @@ describe('handleSevenElevenSearchProducts', () => { ); }); - it('원본 API가 차단되면 Worker Zyte 키로 상품을 조회한다', async () => { - const zytePayload = { - success: true, - data: { - SearchQueryResult: { - query: '삼각김밥', - Collection: [ - { - CollectionId: 'offline', - Documentset: { - totalCount: 1, - Document: [{ prdNo: '1', itemCd: '8801', itemOnm: '참치마요' }], - }, - }, - ], - }, - }, - }; - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: Buffer.from(JSON.stringify(zytePayload)).toString('base64'), - }), - ), - ); - + it('Worker 키가 있어도 차단 시 유료 호출 없이 비용 정책 오류를 반환한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); const ctx = createMockContext({ query: '삼각김밥' }, { ZYTE_API_KEY: 'worker-key' }); await handleSevenElevenSearchProducts(ctx); - expect(ctx.json).toHaveBeenCalledWith( expect.objectContaining({ - success: true, - data: expect.objectContaining({ count: 1 }), - }), - ); - expect(mockFetch).toHaveBeenNthCalledWith( - 2, - 'https://api.zyte.com/v1/extract', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: `Basic ${Buffer.from('worker-key:').toString('base64')}`, + success: false, + diagnostics: expect.objectContaining({ + retryable: false, + hint: expect.stringContaining('비용 정책'), }), }), + 500, ); + expect(mockFetch).toHaveBeenCalledTimes(1); }); }); @@ -164,7 +131,11 @@ describe('handleSevenElevenSearchStores', () => { CollectionId: 'store', Documentset: { totalCount: 1, - Document: [{ field: { storCd: '54928', storNm: '안산중앙일번가점', addr: '경기 안산시' } }], + Document: [ + { + field: { storCd: '54928', storNm: '안산중앙일번가점', addr: '경기 안산시' }, + }, + ], }, }, ], @@ -291,7 +262,11 @@ describe('handleSevenElevenCheckInventory', () => { ), ); - const ctx = createMockContext({ keyword: '핫식스', storeKeyword: '안산 중앙역', storeLimit: '10' }); + const ctx = createMockContext({ + keyword: '핫식스', + storeKeyword: '안산 중앙역', + storeLimit: '10', + }); await handleSevenElevenCheckInventory(ctx); expect(ctx.json).toHaveBeenCalledWith( diff --git a/tests/app/app-api-actions.test.ts b/tests/app/app-api-actions.test.ts index f3f7ab3e..c7a24c2a 100644 --- a/tests/app/app-api-actions.test.ts +++ b/tests/app/app-api-actions.test.ts @@ -252,9 +252,7 @@ describe('GET /api/actions/query', () => { it('올리브영 상품 검색을 action facade로 위임한다', async () => { mockFetch.mockResolvedValue( new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: Buffer.from( + Buffer.from( JSON.stringify({ status: 'SUCCESS', data: { @@ -271,8 +269,7 @@ describe('GET /api/actions/query', () => { }, }), 'utf8', - ).toString('base64'), - }), + ).toString('utf8'), ), ); diff --git a/tests/app/app-api-gs25.test.ts b/tests/app/app-api-gs25.test.ts index 4fcdeaa8..d1a8bde8 100644 --- a/tests/app/app-api-gs25.test.ts +++ b/tests/app/app-api-gs25.test.ts @@ -32,33 +32,13 @@ describe('GET /api/gs25/stores', () => { expect(data.data.stores).toHaveLength(1); }); - it('GS25 매장 검색 403이면 Zyte fallback을 사용한다', async () => { - const zyteBody = Buffer.from( - JSON.stringify({ - stores: [{ storeCode: '1', storeName: '강남역점', storeAddress: '서울 강남구' }], - }), - 'utf8', - ).toString('base64'); - - mockFetch - .mockResolvedValueOnce(new Response('forbidden', { status: 403, statusText: 'Forbidden' })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: zyteBody, - }), - ), - ); - - const res = await app.request('/api/gs25/stores?keyword=강남', undefined, { - ZYTE_API_KEY: 'test-zyte-key', - }); - expect(res.status).toBe(200); - - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.data.stores).toHaveLength(1); + it('GS25 매장 검색 차단 시 유료 호출 없이 인증 장애를 반환한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); + const res = await app.request('/api/gs25/stores', undefined, { ZYTE_API_KEY: 'test-key' }); + expect(res.status).toBe(500); + const result = await res.json(); + expect(result.diagnostics.retryable).toBe(false); + expect(mockFetch).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/app/app-api-oliveyoung.test.ts b/tests/app/app-api-oliveyoung.test.ts index 50772b38..fa405374 100644 --- a/tests/app/app-api-oliveyoung.test.ts +++ b/tests/app/app-api-oliveyoung.test.ts @@ -22,7 +22,7 @@ describe('GET /api/oliveyoung/stores', () => { 'utf8', ).toString('base64'); - mockFetch.mockResolvedValue(new Response(JSON.stringify({ statusCode: 200, httpResponseBody: encoded }))); + mockFetch.mockResolvedValue(new Response(Buffer.from(encoded, 'base64').toString('utf8'))); const res = await app.request('/api/oliveyoung/stores?keyword=명동', undefined, { ZYTE_API_KEY: 'test-key', @@ -57,7 +57,7 @@ describe('GET /api/oliveyoung/products', () => { ).toString('base64'); mockFetch.mockResolvedValue( - new Response(JSON.stringify({ statusCode: 200, httpResponseBody: productEncoded })), + new Response(Buffer.from(productEncoded, 'base64').toString('utf8')), ); const res = await app.request('/api/oliveyoung/products?keyword=마스크팩', undefined, { @@ -121,10 +121,10 @@ describe('GET /api/oliveyoung/inventory', () => { ).toString('base64'); mockFetch - .mockResolvedValueOnce(new Response(JSON.stringify({ statusCode: 200, httpResponseBody: storeEncoded }))) - .mockResolvedValueOnce(new Response(JSON.stringify({ statusCode: 200, httpResponseBody: productEncoded }))) - .mockResolvedValueOnce(new Response(JSON.stringify({ statusCode: 200, httpResponseBody: goodsInfoEncoded }))) - .mockResolvedValueOnce(new Response(JSON.stringify({ statusCode: 200, httpResponseBody: stockStoresEncoded }))); + .mockResolvedValueOnce(new Response(Buffer.from(storeEncoded, 'base64').toString('utf8'))) + .mockResolvedValueOnce(new Response(Buffer.from(productEncoded, 'base64').toString('utf8'))) + .mockResolvedValueOnce(new Response(Buffer.from(goodsInfoEncoded, 'base64').toString('utf8'))) + .mockResolvedValueOnce(new Response(Buffer.from(stockStoresEncoded, 'base64').toString('utf8'))); const res = await app.request('/api/oliveyoung/inventory?keyword=선크림', undefined, { ZYTE_API_KEY: 'test-key', @@ -152,3 +152,14 @@ describe('GET /api/oliveyoung/inventory', () => { expect(data.error.code).toBe('MISSING_QUERY'); }); }); + +it('Worker 바인딩의 릴레이 설정을 REST 상품 검색에 전달한다', async () => { + mockFetch.mockResolvedValue(Response.json({ status: 'SUCCESS', data: { totalCount: 0 } })); + const response = await app.request('/api/oliveyoung/products?keyword=relay-binding-proof', undefined, { + OY_RELAY_URL: 'https://relay.example', OY_RELAY_TOKEN: 'trusted-token', + }); + expect(response.status).toBe(200); + expect(mockFetch).toHaveBeenCalledWith('https://relay.example/v1/oliveyoung/product-search-v3', expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer trusted-token' }), + })); +}); diff --git a/tests/app/app-api-seveneleven.test.ts b/tests/app/app-api-seveneleven.test.ts index 2ddc9b0e..b08d22e3 100644 --- a/tests/app/app-api-seveneleven.test.ts +++ b/tests/app/app-api-seveneleven.test.ts @@ -2,8 +2,10 @@ * 앱 통합 테스트 - 세븐일레븐 API */ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import app from '../../src/index.js'; +import { clearSevenElevenReadCache } from '../../src/services/seveneleven/readCache.js'; +beforeEach(clearSevenElevenReadCache); import { setupFetchMock } from './testHelpers.js'; const mockFetch = vi.fn(); diff --git a/tests/app/app-health-checks.test.ts b/tests/app/app-health-checks.test.ts index 28eef65f..daa54cb3 100644 --- a/tests/app/app-health-checks.test.ts +++ b/tests/app/app-health-checks.test.ts @@ -722,3 +722,18 @@ describe('GET /api/health/checks', () => { expect(data.error.code).toBe('INVALID_HEALTH_CHECK_MODE'); }); }); + +it('인증된 내부 health도 원본 차단 후 Zyte를 호출하지 않는다', async () => { + mockFetch.mockResolvedValue(new Response('blocked', { status: 403 })); + const response = await app.request( + '/api/health/checks?check=cgv.theaters&fresh=true', + { headers: { Authorization: 'Bearer health-test', 'x-health-check-force-fresh': 'true' } }, + { HEALTH_CHECK_SECRET: 'health-test', ZYTE_API_KEY: 'remaining-key' }, + ); + expect(response.status).toBe(200); + const data = await response.json() as { checks: Array<{ status: string; message: string }> }; + expect(data.checks[0].status).toBe('fail'); + expect(data.checks[0].message).toContain('비용 정책'); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls.every(([url]) => new URL(String(url)).hostname !== 'api.zyte.com')).toBe(true); +}); diff --git a/tests/app/app-info-pages.test.ts b/tests/app/app-info-pages.test.ts index cac777a6..ce14d344 100644 --- a/tests/app/app-info-pages.test.ts +++ b/tests/app/app-info-pages.test.ts @@ -296,7 +296,8 @@ describe('기본 페이지', () => { googleMapsApiKey: { configured: true, usedBy: expect.arrayContaining(['gs25', 'cgv']) }, zyteApiKey: { configured: false, - usedBy: expect.arrayContaining(['oliveyoung', 'cgv', 'cu', 'seveneleven']), + enabled: false, + usedBy: [], }, naverLocalSearch: { configured: false, usedBy: ['places'] }, opinetApiKey: { configured: false, usedBy: ['opinet'] }, diff --git a/tests/app/app-mcp-client.test.ts b/tests/app/app-mcp-client.test.ts index 4eeade2f..9c1f86a6 100644 --- a/tests/app/app-mcp-client.test.ts +++ b/tests/app/app-mcp-client.test.ts @@ -5,6 +5,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import app from '../../src/index.js'; +import type { AppBindings } from '../../src/api/response.js'; import { createMockProductResponse } from '../api/testHelpers.js'; const mockFetch = vi.fn(); @@ -33,12 +34,12 @@ afterEach(() => { vi.restoreAllMocks(); }); -async function createLocalMcpClient(): Promise { +async function createLocalMcpClient(bindings?: AppBindings): Promise { const client = new Client({ name: 'vitest-mcp-client', version: '1.0.0' }); const transport = new StreamableHTTPClientTransport(new URL('https://local.test/mcp'), { fetch: async (url, init) => { const request = new Request(url, init); - return app.request(request); + return app.request(request, undefined, bindings); }, }); @@ -131,3 +132,15 @@ describe('MCP client smoke', () => { } }); }); + +it('MCP도 키가 남아 있어도 유료 호출 없이 정책 오류를 전달한다', async () => { + mockFetch.mockResolvedValue(new Response('blocked', { status: 403 })); + const client = await createLocalMcpClient({ ZYTE_API_KEY: 'remaining-key' }); + try { + const result = await client.callTool({ name: 'cgv_find_theaters', arguments: { limit: 1 } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain('비용 정책'); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls.every(([url]) => new URL(String(url)).hostname !== 'api.zyte.com')).toBe(true); + } finally { await client.close(); } +}); diff --git a/tests/relay/browser.test.ts b/tests/relay/browser.test.ts new file mode 100644 index 00000000..af5603e2 --- /dev/null +++ b/tests/relay/browser.test.ts @@ -0,0 +1,26 @@ +import { expect, it, vi } from 'vitest'; +import { createBrowserRunner } from '../../scripts/relay/browser.js'; +it('성공 JSON만 반환하고 브라우저 fetch에 제한시간을 설정한다', async () => { + const page = { evaluate: vi.fn().mockResolvedValue({ status: 200, body: { status: 'SUCCESS' } }) }; + const run = createBrowserRunner(page); + expect(await run('/oystore/api/stock/stock-goods-info-v3', { goodsNo: 'A1' })).toEqual({ status: 'SUCCESS' }); + expect(page.evaluate).toHaveBeenCalledWith(expect.any(Function), expect.objectContaining({ timeout: 15000 })); + for (const response of [{status: 403, body: {}}, {status: 200, body: null}, {status: 200, body: {status:'FAIL'}}]) { + page.evaluate.mockResolvedValue(response); + await expect(run('/path', {})).rejects.toThrow('올리브영 브라우저 응답 실패'); + } +}); +it('브라우저 컨텍스트에서 JSON 요청과 응답을 처리하고 HTML은 거절한다', async () => { + const fetch = vi.fn().mockResolvedValue(Response.json({status:'SUCCESS'})); + vi.stubGlobal('fetch', fetch); + const page = { evaluate: vi.fn().mockImplementation((fn, args) => fn(args)) }; + const run = createBrowserRunner(page); + try { + expect(await run('/oystore/api/stock/stock-goods-info-v3', {goodsNo:'A1'})).toEqual({status:'SUCCESS'}); + expect(fetch).toHaveBeenCalledWith('https://www.oliveyoung.co.kr/oystore/api/stock/stock-goods-info-v3', expect.objectContaining({method:'POST',credentials:'include',body:'{"goodsNo":"A1"}',signal:expect.any(AbortSignal)})); + fetch.mockResolvedValue(new Response('challenge')); + await expect(run('/p', {})).rejects.toThrow('브라우저 응답 실패'); + fetch.mockRejectedValue(new Error('network')); + await expect(run('/p', {})).rejects.toThrow('network'); + } finally { vi.unstubAllGlobals(); } +}); diff --git a/tests/relay/oliveyoung.test.ts b/tests/relay/oliveyoung.test.ts new file mode 100644 index 00000000..ff321676 --- /dev/null +++ b/tests/relay/oliveyoung.test.ts @@ -0,0 +1,69 @@ +import { expect, it, vi } from 'vitest'; +import { createOliveyoungRelay } from '../../scripts/relay/oliveyoung.js'; +const payload = { goodsNo: 'A1' }; +const request = (path = 'stock-goods-info-v3', body = JSON.stringify(payload), token = 'test') => new Request(`http://localhost/v1/oliveyoung/${path}`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body }); +it('인증 실패 시 브라우저를 호출하지 않는다', async () => { + const runner = vi.fn(); const relay = createOliveyoungRelay('test', runner); + expect((await relay(request(undefined, undefined, 'bad'))).status).toBe(401); + expect(runner).not.toHaveBeenCalled(); +}); +it('허용된 경로와 검증한 JSON만 브라우저로 전달한다', async () => { + const result = { status: 'SUCCESS', data: { goodsInfo: {} } }; + const runner = vi.fn().mockResolvedValue(result); const relay = createOliveyoungRelay('test', runner); + expect(await (await relay(request())).json()).toEqual(result); + expect(runner).toHaveBeenCalledWith('/oystore/api/stock/stock-goods-info-v3', payload); + expect((await relay(request('https://evil.example'))).status).toBe(404); + expect((await relay(request(undefined, '{'))).status).toBe(400); + expect((await relay(request(undefined, '{}'))).status).toBe(400); + expect((await relay(request(undefined, 'x'.repeat(16385)))).status).toBe(413); + expect(runner).toHaveBeenCalledTimes(1); +}); +it('브라우저 오류를 숨기고 동시 요청을 제한한다', async () => { + let reject!: (reason: Error) => void; + const runner = vi.fn().mockImplementation(() => new Promise((_, fail) => { reject = fail; })); + const relay = createOliveyoungRelay('test', runner); + const first = relay(request()); + await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(1)); + const queued = Array.from({ length: 7 }, () => relay(request())); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect((await relay(request())).status).toBe(503); + runner.mockResolvedValue({ status: 'SUCCESS' }); + reject(new Error('secret details')); + const response = await first; + expect(response.status).toBe(502); + expect(await response.text()).not.toContain('secret'); + expect((await Promise.all(queued)).every((item) => item.status === 200)).toBe(true); + runner.mockResolvedValue({ status: 'SUCCESS' }); + expect((await relay(request())).status).toBe(200); +}); +it('대기 중 취소된 요청은 브라우저 작업을 생략한다', async () => { + let finish!: (value: {status: string}) => void; + const runner = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const relay = createOliveyoungRelay('test', runner); + const first = relay(request()); + await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(1)); + const controller = new AbortController(); + const pending = relay(new Request(request(), { signal: controller.signal })); + await new Promise((resolve) => setTimeout(resolve, 0)); + controller.abort(); finish({status:'SUCCESS'}); + await first; + expect((await pending).status).toBe(503); + expect(runner).toHaveBeenCalledTimes(1); +}); +it('누락 인증, 빈 토큰, 잘못된 메서드와 요청 형태를 거절한다', async () => { + const runner = vi.fn(); + expect(() => createOliveyoungRelay(' ', runner)).toThrow('OY_RELAY_TOKEN'); + const relay = createOliveyoungRelay('test', runner); + expect((await relay(new Request('http://localhost/v1/oliveyoung/find-store'))).status).toBe(401); + expect((await relay(new Request('http://localhost/v1/oliveyoung/find-store', { headers: {Authorization:'Bearer test'} }))).status).toBe(404); + expect((await relay(new Request('http://localhost/v1/oliveyoung/find-store', {method:'POST',headers:{Authorization:'Bearer test'}}))).status).toBe(400); + for (const body of ['null', '[]', '1', '{"goodsNo":4}', '{"goodsNo":"A1","url":"https://evil.example"}', '{"lat":1e400,"lon":1,"pageIdx":1,"searchWords":"","pogKeys":"","serviceKeys":"","mapLat":1,"mapLon":1}']) { + expect((await relay(request(body.includes('lat')?'find-store':undefined, body))).status).toBe(400); + } + expect((await relay(request('stock-goods-info-v3?x=1'))).status).toBe(404); + expect(runner).not.toHaveBeenCalled(); +}); +it('실패 상태의 브라우저 JSON을 데이터로 전달하지 않는다', async () => { + const relay = createOliveyoungRelay('test', vi.fn().mockResolvedValue({status:'FAIL'})); + expect((await relay(request())).status).toBe(502); +}); diff --git a/tests/services/cgv/client.test.ts b/tests/services/cgv/client.test.ts index 50c80b87..eb2a6c6e 100644 --- a/tests/services/cgv/client.test.ts +++ b/tests/services/cgv/client.test.ts @@ -49,38 +49,11 @@ describe('fetchCgvTheaters', () => { expect(result[0]).toEqual({ theaterCode: '0056', theaterName: '강남', regionCode: '01' }); }); - it('403이면 Zyte fallback을 사용한다', async () => { - const zyteBody = Buffer.from( - JSON.stringify({ - statusCode: 0, - statusMessage: '조회 되었습니다.', - data: [ - { - regnGrpCd: '01', - regnGrpNm: '서울', - siteList: [{ siteNo: '0056', siteNm: '강남' }], - }, - ], - }), - 'utf8', - ).toString('base64'); - - mockFetch - .mockResolvedValueOnce(new Response('forbidden', { status: 403 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: zyteBody, - }), - ), - ); - - const result = await fetchCgvTheaters({ zyteApiKey: 'test-key' }); - - expect(result).toHaveLength(1); - expect(result[0].theaterCode).toBe('0056'); - expect(mockFetch).toHaveBeenCalledTimes(2); + it('403이면 유료 호출 없이 서비스 이용 불가를 알린다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); + await expect(fetchCgvTheaters({ zyteApiKey: 'test-key' })).rejects.toThrow('CGV'); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls.some(([url]) => new URL(String(url)).hostname === 'api.zyte.com')).toBe(false); }); it('HTTP 에러를 처리한다', async () => { diff --git a/tests/services/cgv/transport.test.ts b/tests/services/cgv/transport.test.ts index f6e2e389..4fd5ba11 100644 --- a/tests/services/cgv/transport.test.ts +++ b/tests/services/cgv/transport.test.ts @@ -3,6 +3,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { toStandardErrorDiagnostics } from '../../../src/core/errors.js'; import { CgvUpstreamUnavailableError } from '../../../src/services/cgv/errors.js'; import { requestCgv } from '../../../src/services/cgv/transport.js'; @@ -50,33 +51,18 @@ describe('requestCgv', () => { ).rejects.toThrow('CGV API 응답 파싱 실패'); }); - it('403 + zyteApiKey면 Zyte fallback을 사용한다', async () => { - const body = Buffer.from( - JSON.stringify({ statusCode: 0, data: [{ siteNo: '0056' }] }), - 'utf8', - ).toString('base64'); - - mockFetch - .mockResolvedValueOnce(new Response('forbidden', { status: 403 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: body, - }), - { status: 200 }, - ), - ); - - const result = await requestCgv<{ statusCode: number; data: Array<{ siteNo: string }> }>( - '/cnm/atkt/searchRegnList', - new URLSearchParams({ coCd: 'A420' }), - 1000, - 'test-key', - ); - - expect(result.data[0].siteNo).toBe('0056'); - expect(String(mockFetch.mock.calls[1][0])).toContain('https://api.zyte.com/v1/extract'); + it('키가 있어도 403이면 유료 호출 없이 서비스 이용 불가를 알린다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); + await expect( + requestCgv( + '/cnm/atkt/searchRegnList', + new URLSearchParams({ coCd: 'A420' }), + 1000, + 'test-key', + ), + ).rejects.toThrow('CGV'); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls.some(([url]) => new URL(String(url)).hostname === 'api.zyte.com')).toBe(false); }); it('직접 요청이 403이고 Zyte 키가 없으면 명시적인 upstream unavailable 오류를 던진다', async () => { @@ -95,58 +81,6 @@ describe('requestCgv', () => { ).rejects.toBeInstanceOf(CgvUpstreamUnavailableError); }); - it.each([401, 403])( - 'Zyte 대상 응답이 %i이면 명시적인 upstream unavailable 오류를 던진다', - async (statusCode) => { - mockFetch - .mockResolvedValueOnce(new Response('forbidden', { status: 403 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode, - httpResponseBody: Buffer.from('forbidden').toString('base64'), - }), - { status: 200 }, - ), - ); - - await expect( - requestCgv( - '/cnm/atkt/searchRegnList', - new URLSearchParams({ coCd: 'A420' }), - 1000, - 'test-key', - ), - ).rejects.toBeInstanceOf(CgvUpstreamUnavailableError); - }, - ); - - it('Zyte 계정이 중지된 경우 원문 대신 명시적인 upstream unavailable 오류를 던진다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('forbidden', { status: 403 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - title: 'Forbidden', - detail: 'account suspended', - }), - { status: 403 }, - ), - ); - - await expect( - requestCgv( - '/cnm/atkt/searchRegnList', - new URLSearchParams({ coCd: 'A420' }), - 1000, - 'test-key', - ), - ).rejects.toMatchObject({ - name: 'CgvUpstreamUnavailableError', - message: expect.not.stringContaining('account suspended'), - }); - }); - it('AbortError는 시간 초과 에러로 변환한다', async () => { mockFetch.mockRejectedValueOnce(new DOMException('aborted', 'AbortError')); @@ -187,3 +121,16 @@ describe('requestCgv', () => { ).rejects.toThrow('Base64 인코딩을 지원하지 않는 런타임입니다.'); }); }); + +it('CGV 인증 차단은 비용 정책 안내와 함께 재시도 불가로 진단한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); + const error = await requestCgv('/test', new URLSearchParams(), 1000, 'test-key').catch( + (error: Error) => error, + ); + expect(error).toBeInstanceOf(CgvUpstreamUnavailableError); + expect( + toStandardErrorDiagnostics('CGV_UPSTREAM_UNAVAILABLE', (error as Error).message, { + status: 503, + }), + ).toMatchObject({ retryable: false, hint: expect.stringContaining('비용 정책') }); +}); diff --git a/tests/services/cu/client.test.ts b/tests/services/cu/client.test.ts index eb56fa5e..0e5cabbe 100644 --- a/tests/services/cu/client.test.ts +++ b/tests/services/cu/client.test.ts @@ -33,31 +33,6 @@ it('재고 HTML 응답은 빈 재고 성공 대신 사용 불가로 반환한다 expect(mockFetch).toHaveBeenCalledTimes(2); }); -function zyteJsonResponse(value: unknown): Response { - return new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: Buffer.from(JSON.stringify(value), 'utf8').toString('base64'), - }), - { headers: { 'Content-Type': 'application/json' } }, - ); -} - -function zyteWebsiteBanResponse(): Response { - return new Response( - JSON.stringify({ - type: '/download/website-ban', - title: 'Website Ban', - status: 520, - detail: 'Zyte API could not get a ban-free response.', - }), - { - status: 520, - headers: { 'Content-Type': 'application/json' }, - }, - ); -} - beforeEach(() => { mockFetch.mockReset(); vi.stubGlobal('fetch', mockFetch); @@ -139,91 +114,6 @@ describe('fetchCuStores', () => { ); }); - it.each([400, 403, 429])('웹 GET 검색이 %s이면 기존 POST 형식으로 Zyte 재시도한다', async (status) => { - const zyteBody = Buffer.from( - ` - - - - - - -
안산중앙역에코점
- `, - 'utf8', - ).toString('base64'); - - mockFetch - .mockResolvedValueOnce( - new Response('bad request', { - status, - statusText: 'Bad Request', - }), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: zyteBody, - }), - ), - ); - - const result = await fetchCuStores( - { searchWord: '안산 중앙역' }, - { - apiKey: 'test-key', - }, - ); - - expect(result.totalCount).toBe(1); - expect(result.stores[0].storeName).toBe('안산중앙역에코점'); - expect(mockFetch).toHaveBeenNthCalledWith(2, 'https://api.zyte.com/v1/extract', expect.any(Object)); - expect(mockFetch).toHaveBeenCalledTimes(2); - const fallbackBody = JSON.parse(mockFetch.mock.calls[1][1].body); - expect(fallbackBody).toMatchObject({ - url: 'https://cu.bgfretail.com/store/list_Ajax.do', - httpRequestMethod: 'POST', - }); - expect(fallbackBody.httpRequestText).toBe(new URL(mockFetch.mock.calls[0][0]).search.slice(1)); - }); - - it('웹 검색 400 + Zyte 실패 시 원본 에러를 반환한다', async () => { - mockFetch - .mockResolvedValueOnce( - new Response('bad request', { - status: 400, - statusText: 'Bad Request', - }), - ) - .mockRejectedValueOnce(new Error('zyte fail')); - - await expect(fetchCuStores({ searchWord: '안산 중앙역' }, { apiKey: 'test-key' })).rejects.toThrow( - 'API 요청 실패: 400 Bad Request', - ); - }); - - it('웹 검색 400 + Zyte 본문 누락 시 원본 에러를 반환한다', async () => { - mockFetch - .mockResolvedValueOnce( - new Response('bad request', { - status: 400, - statusText: 'Bad Request', - }), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 200, - }), - ), - ); - - await expect(fetchCuStores({ searchWord: '안산 중앙역' }, { apiKey: 'test-key' })).rejects.toThrow( - 'API 요청 실패: 400 Bad Request', - ); - }); - it('웹 매장 검색에서 이름 없는 행은 제외한다', async () => { mockFetch.mockResolvedValue( new Response( @@ -450,165 +340,6 @@ describe('primeCuStockDisplay', () => { }); describe('fetchCuStock', () => { - it('선택적 워밍업이 차단되어도 Zyte는 본 재고 조회에만 사용한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('warmup blocked', { status: 403 })) - .mockResolvedValueOnce(new Response('stock blocked', { status: 403 })) - .mockResolvedValueOnce( - zyteJsonResponse({ - spellModifyYn: 'N', - data: { - stockResult: { - result: { - total_count: 1, - rows: [{ fields: { item_cd: '8801', item_nm: '감자칩' } }], - }, - }, - }, - }), - ); - - const result = await fetchCuStock( - { keyword: '과자', limit: 1, offset: 0, searchSort: 'recom' }, - { apiKey: 'test-key' }, - ); - - expect(result.items[0].itemCode).toBe('8801'); - expect(mockFetch).toHaveBeenCalledTimes(3); - expect(mockFetch.mock.calls[1][0]).toBe('https://www.pocketcu.co.kr/api/search/rest/stock/main'); - expect(mockFetch.mock.calls[2][0]).toBe('https://api.zyte.com/v1/extract'); - }); - - it('원본 재고 API가 차단되면 Zyte JSON 응답을 정규화한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] }))) - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce( - zyteJsonResponse({ - spellModifyYn: 'N', - data: { - stockResult: { - result: { - total_count: 1, - rows: [ - { - fields: { - item_cd: '8801', - item_nm: '감자칩', - hyun_maega: '1700', - pickup_yn: 'Y', - deliv_yn: 'N', - reserv_yn: 'N', - }, - }, - ], - }, - }, - }, - }), - ); - - const result = await fetchCuStock( - { keyword: '과자', limit: 1, offset: 0, searchSort: 'recom' }, - { apiKey: 'test-key' }, - ); - - expect(result.available).toBe(true); - expect(result.unavailableReason).toBeNull(); - expect(result.items[0].itemCode).toBe('8801'); - expect(mockFetch).toHaveBeenCalledTimes(3); - }); - - it('원본 차단 후 Zyte Website Ban이면 재고를 unavailable로 반환한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] }))) - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce(zyteWebsiteBanResponse()) - .mockResolvedValueOnce(zyteWebsiteBanResponse()); - - const result = await fetchCuStock( - { keyword: '과자', limit: 1, offset: 0, searchSort: 'recom' }, - { apiKey: 'test-key' }, - ); - - expect(result).toEqual({ - available: false, - unavailableReason: expect.stringContaining('Website Ban'), - totalCount: 0, - spellModifyYn: 'N', - items: [], - }); - }); - - it('Zyte가 평문 HTTP 520을 반환해도 재고를 unavailable로 반환한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] }))) - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce(new Response('error code: 520', { status: 520 })); - - const result = await fetchCuStock( - { keyword: '과자', limit: 1, offset: 0, searchSort: 'recom' }, - { apiKey: 'test-key' }, - ); - - expect(result).toEqual({ - available: false, - unavailableReason: expect.stringContaining('Website Ban'), - totalCount: 0, - spellModifyYn: 'N', - items: [], - }); - }); - - it('Zyte 키 없이 원본 재고가 차단되어도 unavailable로 반환한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] }))) - .mockResolvedValueOnce(new Response('Request Blocked', { status: 403 })); - - const result = await fetchCuStock({ - keyword: '과자', - limit: 1, - offset: 0, - searchSort: 'recom', - }); - - expect(result).toEqual({ - available: false, - unavailableReason: expect.stringContaining('Request Blocked'), - totalCount: 0, - spellModifyYn: 'N', - items: [], - }); - expect(mockFetch).toHaveBeenCalledTimes(2); - }); - - it('Zyte 대상 상태가 520이어도 재고를 unavailable로 반환한다', async () => { - const targetBan = new Response( - JSON.stringify({ - statusCode: 520, - httpResponseBody: Buffer.from('{}').toString('base64'), - }), - { headers: { 'Content-Type': 'application/json' } }, - ); - mockFetch - .mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] }))) - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce(targetBan) - .mockResolvedValueOnce(targetBan.clone()); - - const result = await fetchCuStock( - { keyword: '과자', limit: 1, offset: 0, searchSort: 'recom' }, - { apiKey: 'test-key' }, - ); - - expect(result).toEqual({ - available: false, - unavailableReason: expect.stringContaining('Website Ban'), - totalCount: 0, - spellModifyYn: 'N', - items: [], - }); - }); it('원본 500 오류는 unavailable로 숨기지 않는다', async () => { mockFetch @@ -816,3 +547,28 @@ describe('geocodeCuAddress', () => { expect(result).toBeNull(); }); }); + +it.each([400, 403, 429])('웹 매장 %i 차단 후 유료 호출 없이 비용 정책을 안내한다', async (status) => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status })); + await expect(fetchCuStores({ searchWord: '강남' }, { apiKey: 'remaining-key' })) + .rejects.toThrow('비용 정책'); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(String(mockFetch.mock.calls[0][0])).toContain('cu.bgfretail.com'); +}); + +it('재고 원본 차단 후 Zyte 요청을 보내지 않는다', async () => { + mockFetch.mockResolvedValueOnce(new Response('{}')) + .mockResolvedValueOnce(new Response('blocked', { status: 403 })); + await expect(fetchCuStock({ keyword: '과자', limit: 1, offset: 0, searchSort: 'recom' }, { apiKey: 'remaining-key' })) + .rejects.toThrow('비용 정책'); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls.every(([url]) => new URL(String(url)).hostname !== 'api.zyte.com')).toBe(true); +}); + +it.each([400, 403, 429])('키가 없는 재고 원본 %i 차단은 조회 불가로 표시한다', async (status) => { + mockFetch.mockResolvedValueOnce(new Response('{}')).mockResolvedValueOnce(new Response('blocked', { status })); + const result = await fetchCuStock({ keyword: '과자', limit: 1, offset: 0, searchSort: 'recom' }); + expect(result.available).toBe(false); + expect(result.unavailableReason).toContain(`${status} Request Blocked`); + expect(mockFetch).toHaveBeenCalledTimes(2); +}); diff --git a/tests/services/cu/tools/checkInventory.test.ts b/tests/services/cu/tools/checkInventory.test.ts index 5415cf74..019d17d0 100644 --- a/tests/services/cu/tools/checkInventory.test.ts +++ b/tests/services/cu/tools/checkInventory.test.ts @@ -20,8 +20,7 @@ describe('createCheckInventoryTool', () => { it('storeLimit이 0이면 차단된 재고 뒤의 매장 조회를 생략한다', async () => { mockFetch .mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] }))) - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce(new Response('error code: 520', { status: 520 })); + .mockResolvedValueOnce(new Response('blocked')); const tool = createCheckInventoryTool({ zyteApiKey: 'worker-key' }); const result = await tool.handler({ keyword: '커피', storeLimit: 0 }); @@ -40,16 +39,14 @@ describe('createCheckInventoryTool', () => { stores: [], }), ); - expect(mockFetch).toHaveBeenCalledTimes(3); + expect(mockFetch).toHaveBeenCalledTimes(2); }); it('기본 매장 조회까지 차단되어도 degraded 결과를 반환한다', async () => { mockFetch .mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] }))) - .mockResolvedValueOnce(new Response('stock blocked', { status: 403 })) - .mockResolvedValueOnce(new Response('error code: 520', { status: 520 })) - .mockResolvedValueOnce(new Response('store blocked', { status: 403 })) - .mockResolvedValueOnce(new Response('error code: 520', { status: 520 })); + .mockResolvedValueOnce(new Response('stock blocked')) + .mockResolvedValueOnce(new Response('store blocked', { status: 403 })); const tool = createCheckInventoryTool({ zyteApiKey: 'worker-key' }); const result = await tool.handler({ keyword: '커피' }); @@ -59,12 +56,12 @@ describe('createCheckInventoryTool', () => { expect(parsed.nearbyStores).toEqual( expect.objectContaining({ available: false, - unavailableReason: expect.stringContaining('520'), + unavailableReason: expect.stringContaining('비용 정책'), totalCount: 0, stores: [], }), ); - expect(mockFetch).toHaveBeenCalledTimes(5); + expect(mockFetch).toHaveBeenCalledTimes(3); }); it('매장 조회가 비 Error 값으로 실패해도 안전한 원인을 반환한다', async () => { @@ -91,48 +88,10 @@ describe('createCheckInventoryTool', () => { ); }); - it('주입된 Worker 키로 폴백하고 차단된 재고를 unavailable로 반환한다', async () => { - const zyteBanBody = JSON.stringify({ - type: '/download/website-ban', - title: 'Website Ban', - status: 520, - detail: 'Zyte API could not get a ban-free response.', - }); - mockFetch - .mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] }))) - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce( - new Response(zyteBanBody, { - status: 520, - headers: { 'Content-Type': 'application/json' }, - }), - ) - .mockResolvedValueOnce(new Response(JSON.stringify({ totalCnt: 0, storeList: [] }))); - - const tool = createCheckInventoryTool({ - zyteApiKey: 'worker-key', - googleMapsApiKey: 'maps-key', - }); - const result = await tool.handler({ - keyword: '과자', - latitude: 37.5, - longitude: 127, - storeLimit: 1, - }); - - const parsed = JSON.parse(result.content[0].text); - expect(parsed.inventory).toEqual( - expect.objectContaining({ - available: false, - unavailableReason: expect.stringContaining('Website Ban'), - totalCount: 0, - items: [], - }), - ); - const zyteHeaders = new Headers(mockFetch.mock.calls[2][1]?.headers); - expect(zyteHeaders.get('Authorization')).toBe( - `Basic ${Buffer.from('worker-key:', 'utf8').toString('base64')}`, - ); + it('키가 있어도 차단된 재고 조회에 유료 요청을 보내지 않는다', async () => { + mockFetch.mockResolvedValueOnce(new Response('{}')).mockResolvedValueOnce(new Response('blocked', {status:403})); + await expect(createCheckInventoryTool({zyteApiKey:'worker-key'}).handler({keyword:'과자',storeLimit:1})).rejects.toThrow('비용 정책'); + expect(mockFetch).toHaveBeenCalledTimes(2); }); it('올바른 도구 정의를 반환한다', () => { diff --git a/tests/services/cu/tools/findNearbyStores.test.ts b/tests/services/cu/tools/findNearbyStores.test.ts index 2825ade8..b84c6973 100644 --- a/tests/services/cu/tools/findNearbyStores.test.ts +++ b/tests/services/cu/tools/findNearbyStores.test.ts @@ -17,35 +17,10 @@ afterEach(() => { }); describe('createFindNearbyStoresTool', () => { - it('주입된 Worker Zyte 키로 차단된 매장 검색을 복구한다', async () => { - const html = ` - - - - -
강남점
- `; - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 400 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: Buffer.from(html, 'utf8').toString('base64'), - }), - { headers: { 'Content-Type': 'application/json' } }, - ), - ); - - const tool = createFindNearbyStoresTool('worker-key'); - const result = await tool.handler({ keyword: '강남', limit: 1 }); - - const parsed = JSON.parse(result.content[0].text); - expect(parsed.stores[0].storeName).toBe('강남점'); - const zyteHeaders = new Headers(mockFetch.mock.calls[1][1]?.headers); - expect(zyteHeaders.get('Authorization')).toBe( - `Basic ${Buffer.from('worker-key:', 'utf8').toString('base64')}`, - ); + it('키가 있어도 차단된 매장 검색에 유료 요청을 보내지 않는다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', {status:400})); + await expect(createFindNearbyStoresTool('worker-key').handler({keyword:'강남',limit:1})).rejects.toThrow('비용 정책'); + expect(mockFetch).toHaveBeenCalledTimes(1); }); it('올바른 도구 정의를 반환한다', () => { diff --git a/tests/services/gs25/client.test.ts b/tests/services/gs25/client.test.ts index e8cbcc95..94bfb51a 100644 --- a/tests/services/gs25/client.test.ts +++ b/tests/services/gs25/client.test.ts @@ -45,62 +45,13 @@ describe('fetchGs25Stores', () => { expect(mockFetch).toHaveBeenCalledTimes(2); }); - it('store/stock 403이면 Zyte fallback으로 매장을 조회한다', async () => { - const zyteBody = Buffer.from( - JSON.stringify({ - stores: [{ storeCode: 'VE463', storeName: '강남역점' }], - }), - 'utf8', - ).toString('base64'); - - mockFetch - .mockResolvedValueOnce(new Response('forbidden', { status: 403, statusText: 'Forbidden' })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: zyteBody, - }), - ), - ); - - const result = await fetchGs25Stores({ useCache: false }, { zyteApiKey: 'test-zyte-key' }); - - expect(result.stores[0].storeCode).toBe('VE463'); - expect(mockFetch).toHaveBeenNthCalledWith( - 2, - 'https://api.zyte.com/v1/extract', - expect.any(Object), + it('매장 조회 차단 시 유료 호출 없이 서비스 이용 불가를 알린다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); + await expect(fetchGs25Stores({ useCache: false }, { zyteApiKey: 'test-key' })).rejects.toThrow( + 'GS25', ); - }); - - it('Zyte 대상이 인증을 거부하면 명시적인 upstream unavailable 오류를 던진다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('forbidden', { status: 403, statusText: 'Forbidden' })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 403, - httpResponseBody: Buffer.from('forbidden').toString('base64'), - }), - ), - ); - - await expect( - fetchGs25Stores({ useCache: false }, { zyteApiKey: 'test-zyte-key' }), - ).rejects.toBeInstanceOf(Gs25UpstreamUnavailableError); - }); - - it('Zyte 호출 자체가 실패해도 명시적인 upstream unavailable 오류를 던진다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('forbidden', { status: 403, statusText: 'Forbidden' })) - .mockResolvedValueOnce( - new Response(JSON.stringify({ detail: 'account suspended' }), { status: 403 }), - ); - - await expect( - fetchGs25Stores({ useCache: false }, { zyteApiKey: 'test-zyte-key' }), - ).rejects.toBeInstanceOf(Gs25UpstreamUnavailableError); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls.some(([url]) => new URL(String(url)).hostname === 'api.zyte.com')).toBe(false); }); it('store/stock 403이어도 Zyte 키가 없으면 원본 에러를 반환한다', async () => { @@ -305,52 +256,13 @@ describe('fetchGs25SearchProducts', () => { expect(mockFetch).toHaveBeenCalledTimes(2); }); - it('상품 검색 403이면 Zyte fallback으로 검색한다', async () => { - const zyteBody = Buffer.from( - JSON.stringify({ - SearchQueryResult: { - Collection: [ - { - Documentset: { - Document: [{ field: { itemCode: '8801', itemName: '콜라', stockCheckYn: 'Y' } }], - }, - }, - ], - }, - }), - 'utf8', - ).toString('base64'); - - mockFetch - .mockResolvedValueOnce(new Response('forbidden', { status: 403, statusText: 'Forbidden' })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: zyteBody, - }), - ), - ); - - const result = await fetchGs25SearchProducts('콜라', { zyteApiKey: 'test-zyte-key' }); - - expect(result[0]).toEqual( - expect.objectContaining({ - itemCode: '8801', - itemName: '콜라', - stockCheckEnabled: true, - }), - ); - expect(mockFetch).toHaveBeenNthCalledWith( - 2, - 'https://api.zyte.com/v1/extract', - expect.objectContaining({ - body: expect.stringContaining('"httpRequestMethod":"POST"'), - }), - ); - expect(String((mockFetch.mock.calls[1][1] as RequestInit).body)).toContain( - '"httpRequestText":"{\\"query\\":\\"콜라\\"}"', + it('상품 검색 차단 시 유료 호출 없이 비용 정책을 알린다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); + await expect(fetchGs25SearchProducts('콜라', { zyteApiKey: 'test-key' })).rejects.toThrow( + '비용 정책', ); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls.some(([url]) => new URL(String(url)).hostname === 'api.zyte.com')).toBe(false); }); it('상품 검색 403이어도 Zyte 키가 없으면 원본 에러를 반환한다', async () => { diff --git a/tests/services/lottemart/session.test.ts b/tests/services/lottemart/session.test.ts index 157b4dd8..b7e96704 100644 --- a/tests/services/lottemart/session.test.ts +++ b/tests/services/lottemart/session.test.ts @@ -8,6 +8,7 @@ import { probeLotteMartRequest, } from '../../../src/services/lottemart/session.js'; import { + toLotteMartBodyText, __testOnlyCreateLotteMartSocketResponse, __testOnlyFetchLotteMartSocketResponse, } from '../../../src/services/lottemart/socketTransport.js'; @@ -28,21 +29,6 @@ vi.mock('../../../src/services/lottemart/socketTransport.js', async () => { const mockFetch = vi.fn(); -function createZyteResponse(bodyText: string, status = 200) { - return new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: Buffer.from(bodyText, 'utf8').toString('base64'), - }), - { - status, - headers: { - 'Content-Type': 'application/json', - }, - }, - ); -} - beforeEach(() => { mockFetch.mockReset(); socketMocks.fetchResponse.mockReset().mockResolvedValue(null); @@ -534,129 +520,6 @@ describe('lottemart session helpers', () => { ).rejects.toThrow('broken stream'); }); - it('direct fetch가 abort되면 Zyte로 재시도한다', async () => { - mockFetch - .mockRejectedValueOnce(new Error('The operation was aborted')) - .mockResolvedValueOnce(createZyteResponse('')); - - await expect( - fetchLotteMartHtml( - 'https://company.lottemart.com/mobiledowa/test', - { method: 'GET' }, - 1000, - '', - 'test-key', - ), - ).resolves.toContain('강변점'); - - expect(String(mockFetch.mock.calls[1]?.[0])).toBe('https://api.zyte.com/v1/extract'); - }); - - it('Zyte 요청에는 URLSearchParams body를 문자열로 전달한다', async () => { - mockFetch - .mockRejectedValueOnce(new Error('The operation was aborted')) - .mockResolvedValueOnce(createZyteResponse('ok')); - - await expect( - fetchLotteMartHtml( - 'https://company.lottemart.com/mobiledowa/test', - { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ keyword: '핫식스', page: '2' }), - }, - 1000, - '', - 'test-key', - ), - ).resolves.toBe('ok'); - - const zyteBody = JSON.parse(String(mockFetch.mock.calls[1]?.[1]?.body)) as { - httpRequestText?: string; - customHttpRequestHeaders?: Array<{ name: string; value: string }>; - }; - expect(zyteBody.httpRequestText).toBe('keyword=%ED%95%AB%EC%8B%9D%EC%8A%A4&page=2'); - expect(zyteBody.customHttpRequestHeaders).toEqual( - expect.arrayContaining([ - { name: 'content-type', value: 'application/x-www-form-urlencoded' }, - ]), - ); - }); - - it('Zyte 요청에는 문자열 body도 그대로 전달한다', async () => { - mockFetch - .mockRejectedValueOnce(new Error('The operation was aborted')) - .mockResolvedValueOnce(createZyteResponse('ok')); - - await expect( - fetchLotteMartHtml( - 'https://company.lottemart.com/mobiledowa/test', - { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: 'keyword=%ED%95%AB%EC%8B%9D%EC%8A%A4&page=1', - }, - 1000, - '', - 'test-key', - ), - ).resolves.toBe('ok'); - - const zyteBody = JSON.parse(String(mockFetch.mock.calls[1]?.[1]?.body)) as { - httpRequestText?: string; - }; - expect(zyteBody.httpRequestText).toBe('keyword=%ED%95%AB%EC%8B%9D%EC%8A%A4&page=1'); - }); - - it('Zyte fallback 시 method가 비어 있으면 GET으로 보낸다', async () => { - mockFetch - .mockRejectedValueOnce(new Error('The operation was aborted')) - .mockResolvedValueOnce(createZyteResponse('ok')); - - await expect( - fetchLotteMartHtml( - 'https://company.lottemart.com/mobiledowa/test', - { - headers: { - 'X-Test': '1', - }, - }, - 1000, - '', - 'test-key', - ), - ).resolves.toBe('ok'); - - const zyteBody = JSON.parse(String(mockFetch.mock.calls[1]?.[1]?.body)) as { - httpRequestMethod?: string; - }; - expect(zyteBody.httpRequestMethod).toBe('GET'); - }); - - it('Zyte 응답 본문이 비어 있으면 에러를 던진다', async () => { - mockFetch.mockRejectedValueOnce(new Error('The operation was aborted')).mockResolvedValueOnce( - new Response(JSON.stringify({ statusCode: 200 }), { - headers: { - 'Content-Type': 'application/json', - }, - }), - ); - - await expect( - fetchLotteMartHtml( - 'https://company.lottemart.com/mobiledowa/test', - { method: 'GET' }, - 1000, - '', - 'test-key', - ), - ).rejects.toThrow('Zyte HTTP 응답 본문이 비어 있습니다.'); - }); - it('페이지 래퍼는 절대 경로로 조합해 HTML을 가져온다', async () => { mockFetch.mockResolvedValue(new Response('page ok')); @@ -729,66 +592,6 @@ describe('lottemart session helpers', () => { ]); }); - it('probeLotteMartRequest는 Zyte 성공 시 세션 쿠키 유무를 보존한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('direct ok')) - .mockResolvedValueOnce(createZyteResponse('zyte ok')) - .mockResolvedValueOnce(new Response('direct ok')) - .mockResolvedValueOnce(createZyteResponse('zyte ok')); - - const withCookie = await probeLotteMartRequest( - 'https://company.lottemart.com/mobiledowa/test', - { method: 'GET' }, - 1000, - 'ASPSESSIONID=PROBE', - 'test-key', - ); - const withoutCookie = await probeLotteMartRequest( - 'https://company.lottemart.com/mobiledowa/test', - { method: 'GET' }, - 1000, - '', - 'test-key', - ); - - expect(withCookie[1]).toEqual( - expect.objectContaining({ - used: 'zyte', - success: true, - sessionCookie: 'ASPSESSIONID=PROBE', - }), - ); - expect(withoutCookie[1]).toEqual( - expect.objectContaining({ - used: 'zyte', - success: true, - sessionCookie: null, - }), - ); - }); - - it('probeLotteMartRequest는 Error가 아닌 Zyte 실패도 기본 메시지로 요약한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('direct ok')) - .mockRejectedValueOnce('zyte unavailable'); - - const result = await probeLotteMartRequest( - 'https://company.lottemart.com/mobiledowa/test', - { method: 'GET' }, - 1000, - '', - 'test-key', - ); - - expect(result[1]).toEqual( - expect.objectContaining({ - used: 'zyte', - success: false, - error: '알 수 없는 오류가 발생했습니다.', - }), - ); - }); - it('probeLotteMartRequest는 direct 실패와 zyte 실패를 함께 기록한다', async () => { mockFetch.mockRejectedValueOnce(new Error('The operation was aborted')).mockResolvedValueOnce( new Response(JSON.stringify({ title: 'Website Ban', detail: 'ban', status: 520 }), { @@ -820,3 +623,24 @@ describe('lottemart session helpers', () => { ]); }); }); + +it('키가 있어도 원본 실패 후 유료 요청을 보내지 않는다', async () => { + mockFetch.mockRejectedValue(new Error('원본 연결 실패')); + await expect(fetchLotteMartHtml('https://example.com/test', { method: 'GET' }, 100, '', 'remaining-key')) + .rejects.toThrow('비용 정책'); + expect(mockFetch.mock.calls.every(([url]) => new URL(String(url)).hostname !== 'api.zyte.com')).toBe(true); +}); + +it('진단은 유료 경로 중지를 표시하고 실제 유료 호출은 하지 않는다', async () => { + mockFetch.mockResolvedValue(new Response('ok')); + const result = await probeLotteMartRequest('https://example.com/test', {}, 100, 'ASPSESSIONID=abc', 'remaining-key'); + expect(result).toHaveLength(2); + expect(result[1]).toMatchObject({ used: 'zyte', success: false, status: null, sessionCookie: 'ASPSESSIONID=abc' }); + expect(result[1].error).toContain('비용 정책'); + expect(mockFetch).toHaveBeenCalledTimes(1); +}); + +it('소켓 POST 전송용 폼 본문은 URLSearchParams 인코딩을 유지한다', () => { + expect(toLotteMartBodyText(new URLSearchParams({ keyword: '우유', area: '서울' }))) + .toBe('keyword=%EC%9A%B0%EC%9C%A0&area=%EC%84%9C%EC%9A%B8'); +}); diff --git a/tests/services/lottemart/tools/findNearbyStores.test.ts b/tests/services/lottemart/tools/findNearbyStores.test.ts index eb4bf2a0..39f7b6bb 100644 --- a/tests/services/lottemart/tools/findNearbyStores.test.ts +++ b/tests/services/lottemart/tools/findNearbyStores.test.ts @@ -8,18 +8,7 @@ import { createFindNearbyStoresTool } from '../../../../src/services/lottemart/t const mockFetch = vi.fn(); const createSessionResponse = () => new Response('', { headers: { 'set-cookie': 'ASPSESSIONID=TEST; path=/' } }); -const createZyteResponse = (bodyText: string) => - new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: Buffer.from(bodyText, 'utf8').toString('base64'), - }), - { - headers: { - 'Content-Type': 'application/json', - }, - }, - ); + beforeEach(() => { mockFetch.mockReset(); @@ -135,40 +124,11 @@ describe('createFindNearbyStoresTool', () => { expect(parsed.count).toBe(1); }); - it('기본 Zyte 키로 직접 매장 조회 실패를 우회한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('origin timeout', { status: 522, statusText: 'Origin Timeout' })) - .mockResolvedValueOnce( - createZyteResponse(` -
-
    -
  • -
    잠실점
    -
    -
      -
    • 주소 : 서울 송파구 올림픽로 240
    • -
    • 상담전화 : 02-411-8025
    • -
    -
    - -
  • -
-
- `), - ); - - const tool = createFindNearbyStoresTool(undefined, 'test-zyte-key'); - const result = await tool.handler({ - area: '서울', - keyword: '잠실', - limit: 1, - timeoutMs: 1234, - }); - - const parsed = JSON.parse(result.content[0].text); - expect(parsed.count).toBe(1); - expect(parsed.stores[0].storeName).toBe('잠실점'); - expect(String(mockFetch.mock.calls[1]?.[0])).toBe('https://api.zyte.com/v1/extract'); + it('키가 있어도 원본 매장 실패에 유료 요청을 보내지 않는다', async () => { + mockFetch.mockResolvedValueOnce(new Response('origin timeout',{status:522,statusText:'Origin Timeout'})); + const tool=createFindNearbyStoresTool(undefined,'worker-key'); + await expect(tool.handler({area:'서울',keyword:'잠실',limit:1,timeoutMs:1234})).rejects.toThrow('비용 정책'); + expect(mockFetch.mock.calls.every(([url])=>new URL(String(url)).hostname !== 'api.zyte.com')).toBe(true); }); it('키워드가 있으면 롯데마트 upstream 매장 검색어 파라미터를 함께 보낸다', async () => { diff --git a/tests/services/lottemart/tools/searchProducts.test.ts b/tests/services/lottemart/tools/searchProducts.test.ts index 4550505b..17a9b18a 100644 --- a/tests/services/lottemart/tools/searchProducts.test.ts +++ b/tests/services/lottemart/tools/searchProducts.test.ts @@ -8,18 +8,7 @@ import { createSearchProductsTool } from '../../../../src/services/lottemart/too const mockFetch = vi.fn(); const createSessionResponse = () => new Response('', { headers: { 'set-cookie': 'ASPSESSIONID=TEST; path=/' } }); -const createZyteResponse = (bodyText: string) => - new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: Buffer.from(bodyText, 'utf8').toString('base64'), - }), - { - headers: { - 'Content-Type': 'application/json', - }, - }, - ); + beforeEach(() => { mockFetch.mockReset(); @@ -82,29 +71,13 @@ describe('createSearchProductsTool', () => { expect(parsed.products[0].productName).toBe('코카콜라'); }); - it('기본 Zyte 키로 직접 매장 옵션 조회 실패를 우회한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('origin timeout', { status: 522, statusText: 'Origin Timeout' })) - .mockResolvedValueOnce(createZyteResponse('')) - .mockResolvedValueOnce( - new Response(` - -
검색결과 : 0건
- -
    - `), - ); - - const tool = createSearchProductsTool('test-zyte-key'); - const result = await tool.handler({ - area: '서울', - storeCode: '2301', - keyword: '콜라', - }); - - const parsed = JSON.parse(result.content[0].text); - expect(parsed.storeCode).toBe('2301'); - expect(String(mockFetch.mock.calls[1]?.[0])).toBe('https://api.zyte.com/v1/extract'); + it('매장 옵션 조회가 실패해도 무료 제타 상품 경로는 유지한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('origin timeout',{status:522,statusText:'Origin Timeout'})) + .mockResolvedValueOnce(new Response(JSON.stringify({productGroups:[]}))); + const result=await createSearchProductsTool('worker-key').handler({area:'서울',storeCode:'2301',keyword:'콜라'}); + expect(JSON.parse(result.content[0].text).storeCode).toBe('2301'); + expect(String(mockFetch.mock.calls[1][0])).toContain('https://lottemartzetta.com/'); + expect(mockFetch.mock.calls.every(([url])=>new URL(String(url)).hostname !== 'api.zyte.com')).toBe(true); }); it('source 옵션을 제타 상품 API 경로로 전달한다', async () => { diff --git a/tests/services/oliveyoung/client.test.ts b/tests/services/oliveyoung/client.test.ts index 672ef499..b579d5a7 100644 --- a/tests/services/oliveyoung/client.test.ts +++ b/tests/services/oliveyoung/client.test.ts @@ -24,15 +24,8 @@ afterEach(() => { delete process.env.ZYTE_API_KEY; }); -function createZyteResponse(body: unknown, status = 200) { - const encoded = Buffer.from(JSON.stringify(body), 'utf8').toString('base64'); - return new Response( - JSON.stringify({ - statusCode: status, - httpResponseBody: encoded, - }), - { status } - ); +function createDirectResponse(body: unknown, status = 200) { + return Response.json(body, { status }); } function createDeferred() { @@ -49,7 +42,7 @@ function createDeferred() { describe('fetchOliveyoungStores', () => { it('매장 목록을 반환한다', async () => { mockFetch.mockResolvedValue( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -77,27 +70,22 @@ describe('fetchOliveyoungStores', () => { expect(result.stores[0].pickupYn).toBe(true); }); - it('환경 변수 API 키를 사용한다', async () => { + it('환경 변수의 유료 키를 무시하고 직접 호출한다', async () => { process.env.ZYTE_API_KEY = 'env-key'; - mockFetch.mockResolvedValue(createZyteResponse({ status: 'SUCCESS', data: { totalCount: 0, storeList: [] } })); + mockFetch.mockResolvedValue(createDirectResponse({ status: 'SUCCESS', data: { totalCount: 0, storeList: [] } })); await fetchOliveyoungStores({ latitude: 37.5, longitude: 127.0, pageIdx: 1, searchWords: '' }); expect(mockFetch).toHaveBeenCalledWith( - 'https://api.zyte.com/v1/extract', + 'https://www.oliveyoung.co.kr/oystore/api/storeFinder/find-store', expect.objectContaining({ headers: expect.objectContaining({ - Authorization: expect.stringMatching(/^Basic /), + 'Content-Type': 'application/json', }), }) ); }); - it('API 키가 없으면 에러를 던진다', async () => { - await expect( - fetchOliveyoungStores({ latitude: 37.5, longitude: 127.0, pageIdx: 1, searchWords: '' }) - ).rejects.toThrow('ZYTE_API_KEY가 설정되지 않았습니다. .env 또는 Cloudflare Worker Secret을 확인해주세요.'); - }); it('Zyte HTTP 에러를 처리한다', async () => { mockFetch.mockResolvedValue( @@ -109,7 +97,7 @@ describe('fetchOliveyoungStores', () => { { latitude: 37.5, longitude: 127.0, pageIdx: 1, searchWords: '' }, { apiKey: 'test-key' } ) - ).rejects.toThrow('Zyte API 호출 실패: 400 bad request'); + ).rejects.toThrow('올리브영 직접 요청 실패'); }); it('Zyte HTTP 에러에서 title 필드를 사용한다', async () => { @@ -122,7 +110,7 @@ describe('fetchOliveyoungStores', () => { { latitude: 37.5, longitude: 127.0, pageIdx: 1, searchWords: '' }, { apiKey: 'test-key' } ) - ).rejects.toThrow('Zyte API 호출 실패: 401 bad title'); + ).rejects.toThrow('올리브영 직접 요청 실패'); }); it('Zyte HTTP 에러에서 detail/title이 없으면 상태코드만 사용한다', async () => { @@ -133,11 +121,11 @@ describe('fetchOliveyoungStores', () => { { latitude: 37.5, longitude: 127.0, pageIdx: 1, searchWords: '' }, { apiKey: 'test-key' } ) - ).rejects.toThrow('Zyte API 호출 실패: 403'); + ).rejects.toThrow('올리브영 직접 요청 실패'); }); it('storeList가 없어도 기본값을 반환한다', async () => { - mockFetch.mockResolvedValue(createZyteResponse({ status: 'SUCCESS', data: {} })); + mockFetch.mockResolvedValue(createDirectResponse({ status: 'SUCCESS', data: {} })); const result = await fetchOliveyoungStores( { latitude: 37.5, longitude: 127.0, pageIdx: 1, searchWords: '' }, @@ -150,7 +138,7 @@ describe('fetchOliveyoungStores', () => { it('매장 필드가 비어있으면 기본값을 사용한다', async () => { mockFetch.mockResolvedValue( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -171,7 +159,7 @@ describe('fetchOliveyoungStores', () => { it('매장 검색 실패 시 같은 조건의 직전 성공 결과를 반환한다', async () => { mockFetch .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -187,12 +175,12 @@ describe('fetchOliveyoungStores', () => { const fallback = await fetchOliveyoungStores(params, { apiKey: 'test-key' }); expect(fallback).toEqual(first); - expect(mockFetch).toHaveBeenCalledTimes(3); + expect(mockFetch).toHaveBeenCalledTimes(2); }); it('올리브영 API 상태 오류를 처리한다', async () => { mockFetch.mockResolvedValue( - createZyteResponse({ status: 'FAIL', data: {} }) + createDirectResponse({ status: 'FAIL', data: {} }) ); await expect( @@ -234,7 +222,7 @@ describe('fetchOliveyoungStores', () => { it('btoa가 없으면 Buffer 인코딩 경로를 사용한다', async () => { const originalBtoa = globalThis.btoa; vi.stubGlobal('btoa', undefined); - mockFetch.mockResolvedValue(createZyteResponse({ status: 'SUCCESS', data: { totalCount: 0, storeList: [] } })); + mockFetch.mockResolvedValue(createDirectResponse({ status: 'SUCCESS', data: { totalCount: 0, storeList: [] } })); await fetchOliveyoungStores( { latitude: 37.5, longitude: 127.0, pageIdx: 1, searchWords: '' }, @@ -244,28 +232,12 @@ describe('fetchOliveyoungStores', () => { globalThis.btoa = originalBtoa; }); - it('인코딩 수단이 없으면 에러를 던진다', async () => { - const originalBtoa = globalThis.btoa; - const originalBuffer = globalThis.Buffer; - vi.stubGlobal('btoa', undefined); - vi.stubGlobal('Buffer', undefined); - - await expect( - fetchOliveyoungStores( - { latitude: 37.5, longitude: 127.0, pageIdx: 1, searchWords: '' }, - { apiKey: 'test-key' } - ) - ).rejects.toThrow('Basic 인증 인코딩을 지원하지 않는 런타임입니다.'); - - globalThis.btoa = originalBtoa; - globalThis.Buffer = originalBuffer; - }); }); describe('fetchOliveyoungProducts', () => { it('상품 목록을 반환한다', async () => { mockFetch.mockResolvedValue( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -303,7 +275,7 @@ describe('fetchOliveyoungProducts', () => { it('절대 이미지 URL은 그대로 유지한다', async () => { mockFetch.mockResolvedValue( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -331,7 +303,7 @@ describe('fetchOliveyoungProducts', () => { it('프로토콜 상대 이미지 URL은 https로 보정한다', async () => { mockFetch.mockResolvedValue( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -359,7 +331,7 @@ describe('fetchOliveyoungProducts', () => { it('슬래시 없는 상대 이미지 경로는 절대 URL로 보정한다', async () => { mockFetch.mockResolvedValue( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -387,7 +359,7 @@ describe('fetchOliveyoungProducts', () => { it('o2oRemainQuantity가 0이어도 o2oStockFlag가 true면 재고 있음으로 본다', async () => { mockFetch.mockResolvedValue( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -415,7 +387,7 @@ describe('fetchOliveyoungProducts', () => { it('searchList 대체 필드를 처리한다', async () => { mockFetch.mockResolvedValue( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -436,7 +408,7 @@ describe('fetchOliveyoungProducts', () => { it('상품 필드가 비어있으면 기본값을 사용한다', async () => { mockFetch.mockResolvedValue( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -458,7 +430,7 @@ describe('fetchOliveyoungProducts', () => { it('검색 리스트가 없으면 빈 배열을 반환한다', async () => { mockFetch.mockResolvedValue( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 0, nextPage: false }, }) @@ -475,7 +447,7 @@ describe('fetchOliveyoungProducts', () => { it('상품 검색 실패 시 같은 조건의 직전 성공 결과를 반환한다', async () => { mockFetch .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -493,7 +465,7 @@ describe('fetchOliveyoungProducts', () => { expect(first.products[0].goodsName).toBe('립밤'); expect(fallback).toEqual(first); - expect(mockFetch).toHaveBeenCalledTimes(3); + expect(mockFetch).toHaveBeenCalledTimes(2); }); it('상품 검색 실패 시 만료된 직전 결과는 사용하지 않는다', async () => { @@ -501,7 +473,7 @@ describe('fetchOliveyoungProducts', () => { vi.spyOn(Date, 'now').mockImplementation(() => now); mockFetch .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -530,7 +502,7 @@ describe('fetchOliveyoungProducts', () => { { keyword: '선크림', page: 1, size: 20, sort: '01', includeSoldOut: false }, { apiKey: 'test-key' } ) - ).rejects.toThrow('올리브영 API 응답 실패: 200'); + ).rejects.toThrow('올리브영 API 상태 오류: UNKNOWN'); }); it('statusCode가 없으면 unknown 오류를 반환한다', async () => { @@ -543,7 +515,7 @@ describe('fetchOliveyoungProducts', () => { { keyword: '선크림', page: 1, size: 20, sort: '01', includeSoldOut: false }, { apiKey: 'test-key' } ) - ).rejects.toThrow('올리브영 API 응답 실패: unknown'); + ).rejects.toThrow('올리브영 API 상태 오류: UNKNOWN'); }); it('status 필드가 없으면 UNKNOWN 오류를 반환한다', async () => { @@ -569,7 +541,7 @@ describe('fetchOliveyoungProducts', () => { const originalAtob = globalThis.atob; vi.stubGlobal('atob', undefined); mockFetch.mockResolvedValue( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 0, nextPage: false, serachList: [] }, }) @@ -584,59 +556,25 @@ describe('fetchOliveyoungProducts', () => { globalThis.atob = originalAtob; }); - it('디코딩 수단이 없으면 에러를 던진다', async () => { - const encodedBody = Buffer.from( - JSON.stringify({ - status: 'SUCCESS', - data: { totalCount: 0, nextPage: false, serachList: [] }, - }), - 'utf8' - ).toString('base64'); - - const originalAtob = globalThis.atob; - const originalBuffer = globalThis.Buffer; - vi.stubGlobal('atob', undefined); - vi.stubGlobal('Buffer', undefined); - - mockFetch.mockResolvedValue({ - ok: true, - status: 200, - text: async () => - JSON.stringify({ - statusCode: 200, - httpResponseBody: encodedBody, - }), - }); - - await expect( - fetchOliveyoungProducts( - { keyword: '테스트', page: 1, size: 20, sort: '01', includeSoldOut: false }, - { apiKey: 'test-key' } - ) - ).rejects.toThrow('Base64 디코딩을 지원하지 않는 런타임입니다.'); - - globalThis.atob = originalAtob; - globalThis.Buffer = originalBuffer; - }); }); describe('enrichOliveyoungProductsWithNearbyStoreInventory', () => { it('상품별 주변 매장 수량 정보를 붙이고 재고 있는 상품을 앞으로 정렬한다', async () => { mockFetch .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8801' } }, }) ) .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8802' } }, }) ) .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 2, @@ -648,7 +586,7 @@ describe('enrichOliveyoungProductsWithNearbyStoreInventory', () => { }) ) .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -707,9 +645,8 @@ describe('enrichOliveyoungProductsWithNearbyStoreInventory', () => { const firstGoodsInfo = createDeferred(); const requestedPaths: string[] = []; - mockFetch.mockImplementation((_: string, init?: RequestInit) => { - const payload = JSON.parse(String(init?.body || '{}')) as { url?: string }; - const path = payload.url ? new URL(payload.url).pathname : ''; + mockFetch.mockImplementation((url: string) => { + const path = new URL(url).pathname; requestedPaths.push(path); if (path.endsWith('/stock-goods-info-v3')) { @@ -720,7 +657,7 @@ describe('enrichOliveyoungProductsWithNearbyStoreInventory', () => { } return Promise.resolve( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8802' } }, }) @@ -728,7 +665,7 @@ describe('enrichOliveyoungProductsWithNearbyStoreInventory', () => { } return Promise.resolve( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 0, storeList: [] }, }) @@ -776,7 +713,7 @@ describe('enrichOliveyoungProductsWithNearbyStoreInventory', () => { expect(requestedPaths.filter((item) => item.endsWith('/stock-goods-info-v3'))).toHaveLength(2); firstGoodsInfo.resolve( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8801' } }, }) @@ -835,13 +772,13 @@ describe('enrichOliveyoungProductsWithNearbyStoreInventory', () => { mockFetch .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8801' } }, }) ) .mockResolvedValue( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -871,17 +808,14 @@ describe('enrichOliveyoungProductsWithNearbyStoreInventory', () => { { apiKey: 'test-key' } ); - const requestedPaths = mockFetch.mock.calls.map(([, init]) => { - const payload = JSON.parse(String(init?.body || '{}')) as { url?: string }; - return payload.url ? new URL(payload.url).pathname : ''; - }); + const requestedPaths = mockFetch.mock.calls.map(([url]) => new URL(url).pathname); expect(requestedPaths.filter((path) => path.endsWith('/stock-goods-info-v3'))).toHaveLength(1); expect(requestedPaths.filter((path) => path.endsWith('/stock-stores'))).toHaveLength(2); }); it('goods info에 masterGoodsNumber가 없으면 상품을 그대로 유지한다', async () => { mockFetch.mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { goodsInfo: {} }, }) @@ -1011,13 +945,13 @@ describe('enrichOliveyoungProductsWithNearbyStoreInventory', () => { ]; mockFetch .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8801' } }, }) ) .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -1052,26 +986,26 @@ describe('enrichOliveyoungProductsWithNearbyStoreInventory', () => { expect(first.checkedCount).toBe(1); expect(fallback.checkedCount).toBe(1); expect(fallback.products[0]).toEqual(first.products[0]); - expect(mockFetch).toHaveBeenCalledTimes(4); + expect(mockFetch).toHaveBeenCalledTimes(3); }); it('상품별 매장 재고 보강 실패는 해당 상품만 원본 상태로 유지하고 나머지 상품 조회를 계속한다', async () => { mockFetch .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8801' } }, }) ) .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8802' } }, }) ) .mockRejectedValueOnce(new Error('stock timeout')) .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -1136,13 +1070,13 @@ describe('enrichOliveyoungProductsWithNearbyStoreInventory', () => { it('stock-stores 응답에 빈 데이터가 오면 주변 매장 재고를 품절로 처리한다', async () => { mockFetch .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8803' } }, }) ) .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: {}, }) @@ -1188,13 +1122,13 @@ describe('enrichOliveyoungProductsWithNearbyStoreInventory', () => { it('stock-stores 항목이 비어 있어도 기본값과 재고 라벨을 계산한다', async () => { mockFetch .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8804' } }, }) ) .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, diff --git a/tests/services/oliveyoung/tools/checkInventory.test.ts b/tests/services/oliveyoung/tools/checkInventory.test.ts index 46202dbc..63ca6845 100644 --- a/tests/services/oliveyoung/tools/checkInventory.test.ts +++ b/tests/services/oliveyoung/tools/checkInventory.test.ts @@ -18,14 +18,8 @@ afterEach(() => { vi.restoreAllMocks(); }); -function createZyteResponse(body: unknown) { - const encoded = Buffer.from(JSON.stringify(body), 'utf8').toString('base64'); - return new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: encoded, - }) - ); +function createDirectResponse(body: unknown, status = 200) { + return Response.json(body, { status }); } describe('createCheckInventoryTool', () => { @@ -45,7 +39,7 @@ describe('createCheckInventoryTool', () => { it('주변 매장과 재고 결과를 함께 반환한다', async () => { mockFetch .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -64,7 +58,7 @@ describe('createCheckInventoryTool', () => { }) ) .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 2, @@ -94,19 +88,19 @@ describe('createCheckInventoryTool', () => { }) ) .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8801' } }, }) ) .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8802' } }, }) ) .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -122,7 +116,7 @@ describe('createCheckInventoryTool', () => { }) ) .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -158,13 +152,13 @@ describe('createCheckInventoryTool', () => { it('stockCheckLimit으로 주변 매장 재고 보강 상품 수를 제한한다', async () => { mockFetch .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, storeList: [{ storeCode: 'D176', storeName: '올리브영 명동 타운' }] }, }) ) .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 2, @@ -177,13 +171,13 @@ describe('createCheckInventoryTool', () => { }) ) .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8801' } }, }) ) .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, storeList: [{ storeCode: 'D176', salesStoreYn: true, remainQuantity: 2 }] }, }) @@ -201,7 +195,7 @@ describe('createCheckInventoryTool', () => { it('상품 API의 searchList 오타 보정 필드를 처리한다', async () => { mockFetch .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 0, @@ -210,7 +204,7 @@ describe('createCheckInventoryTool', () => { }) ) .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, @@ -230,13 +224,13 @@ describe('createCheckInventoryTool', () => { }) ) .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8803' } }, }) ) .mockResolvedValueOnce( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, diff --git a/tests/services/oliveyoung/tools/findNearbyStores.test.ts b/tests/services/oliveyoung/tools/findNearbyStores.test.ts index affd31f6..c84e0297 100644 --- a/tests/services/oliveyoung/tools/findNearbyStores.test.ts +++ b/tests/services/oliveyoung/tools/findNearbyStores.test.ts @@ -16,14 +16,8 @@ afterEach(() => { vi.restoreAllMocks(); }); -function createZyteResponse(body: unknown) { - const encoded = Buffer.from(JSON.stringify(body), 'utf8').toString('base64'); - return new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: encoded, - }) - ); +function createDirectResponse(body: unknown, status = 200) { + return Response.json(body, { status }); } describe('createFindNearbyStoresTool', () => { @@ -36,7 +30,7 @@ describe('createFindNearbyStoresTool', () => { it('주변 매장 목록을 반환한다', async () => { mockFetch.mockResolvedValue( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 2, @@ -73,9 +67,9 @@ describe('createFindNearbyStoresTool', () => { expect(parsed.stores[0].storeCode).toBe('D176'); }); - it('Zyte 요청 헤더에 Basic 인증을 포함한다', async () => { + it('직접 요청에 JSON 헤더를 포함한다', async () => { mockFetch.mockResolvedValue( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 0, storeList: [] }, }) @@ -85,11 +79,11 @@ describe('createFindNearbyStoresTool', () => { await tool.handler({}); expect(mockFetch).toHaveBeenCalledWith( - 'https://api.zyte.com/v1/extract', + 'https://www.oliveyoung.co.kr/oystore/api/storeFinder/find-store', expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ - Authorization: expect.stringMatching(/^Basic /), + 'Content-Type': 'application/json', }), }) ); diff --git a/tests/services/oliveyoung/tools/searchProducts.test.ts b/tests/services/oliveyoung/tools/searchProducts.test.ts index d3ba5521..271e4cd4 100644 --- a/tests/services/oliveyoung/tools/searchProducts.test.ts +++ b/tests/services/oliveyoung/tools/searchProducts.test.ts @@ -16,14 +16,8 @@ afterEach(() => { vi.restoreAllMocks(); }); -function createZyteResponse(body: unknown) { - const encoded = Buffer.from(JSON.stringify(body), 'utf8').toString('base64'); - return new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: encoded, - }), - ); +function createDirectResponse(body: unknown, status = 200) { + return Response.json(body, { status }); } describe('createSearchProductsTool', () => { @@ -36,7 +30,7 @@ describe('createSearchProductsTool', () => { it('상품 목록을 반환한다', async () => { mockFetch.mockResolvedValue( - createZyteResponse({ + createDirectResponse({ status: 'SUCCESS', data: { totalCount: 1, diff --git a/tests/services/oliveyoung/transport.test.ts b/tests/services/oliveyoung/transport.test.ts new file mode 100644 index 00000000..3897fcc9 --- /dev/null +++ b/tests/services/oliveyoung/transport.test.ts @@ -0,0 +1,43 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { fetchOliveyoungStores } from '../../../src/services/oliveyoung/client.js'; +const params = { latitude: 37.5, longitude: 127, pageIdx: 1, searchWords: '' }; +afterEach(() => vi.unstubAllGlobals()); +it('키 없이 공식 API JSON을 직접 읽고 Zyte를 호출하지 않는다', async () => { + const fetch = vi.fn().mockResolvedValue(Response.json({ status: 'SUCCESS', data: { totalCount: 7 } })); + vi.stubGlobal('fetch', fetch); + expect((await fetchOliveyoungStores(params)).totalCount).toBe(7); + expect(fetch.mock.calls[0][0]).toBe('https://www.oliveyoung.co.kr/oystore/api/storeFinder/find-store'); +}); +it('신뢰한 옵션의 릴레이를 우선 사용하고 토큰을 전달한다', async () => { + const fetch = vi.fn().mockResolvedValue(Response.json({ status: 'SUCCESS', data: { totalCount: 8 } })); + vi.stubGlobal('fetch', fetch); + expect((await fetchOliveyoungStores(params, { relayUrl: 'https://relay.example', relayToken: 'test' })).totalCount).toBe(8); + expect(fetch).toHaveBeenCalledWith('https://relay.example/v1/oliveyoung/find-store', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer test' }) })); +}); +it('HTTP201의 SUCCESS 본문도 정상 응답으로 처리하지 않는다', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({status:'SUCCESS'}, {status:201}))); + const { requestOliveyoung } = await import('../../../src/services/oliveyoung/transport.js'); + await expect(requestOliveyoung('/oystore/api/stock/product-search-v3', {})).rejects.toThrow('직접 요청 실패'); +}); +it('릴레이 토큰과 주소 설정 오류는 네트워크 호출 전에 거절한다', async () => { + const fetch = vi.fn(); vi.stubGlobal('fetch', fetch); + const { requestOliveyoung } = await import('../../../src/services/oliveyoung/transport.js'); + for (const relayUrl of ['bad url', 'http://remote.example', 'https://user:pass@relay.example', 'https://relay.example?token=x', 'https://relay.example#x', 'ftp://localhost']) { + await expect(requestOliveyoung('/p', {}, {relayUrl,relayToken:'test'})).rejects.toThrow('릴레이 URL'); + } + await expect(requestOliveyoung('/p', {}, {relayUrl:'https://relay.example'})).rejects.toThrow('OY_RELAY_TOKEN'); + expect(fetch).not.toHaveBeenCalled(); +}); +it('로컬 릴레이 요청의 실패는 비밀 없이 전달하고 재시도하지 않는다', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('secret', {status:502})); vi.stubGlobal('fetch', fetch); + const { requestOliveyoung } = await import('../../../src/services/oliveyoung/transport.js'); + await expect(requestOliveyoung('/p', {}, {relayUrl:'http://127.0.0.1:4319/',relayToken:'secret'})).rejects.toThrow('올리브영 브라우저 릴레이 요청 실패'); + expect(fetch).toHaveBeenCalledTimes(1); +}); +it('도구의 사용자 입력으로 신뢰한 릴레이 설정을 덮어쓸 수 없다', async () => { + const fetch = vi.fn().mockResolvedValue(Response.json({status:'SUCCESS',data:{}})); vi.stubGlobal('fetch',fetch); + const { createSearchProductsTool } = await import('../../../src/services/oliveyoung/tools/searchProducts.js'); + const tool = createSearchProductsTool(undefined, {relayUrl:'https://trusted.example',relayToken:'trusted'}); + await tool.handler({keyword:'팩',relayUrl:'https://evil.example',relayToken:'evil'}); + expect(fetch).toHaveBeenCalledWith('https://trusted.example/v1/oliveyoung/product-search-v3',expect.objectContaining({headers:expect.objectContaining({Authorization:'Bearer trusted'})})); +}); diff --git a/tests/services/seveneleven/client.test.ts b/tests/services/seveneleven/client.test.ts index 7d4c9d02..9d8a019c 100644 --- a/tests/services/seveneleven/client.test.ts +++ b/tests/services/seveneleven/client.test.ts @@ -1,3 +1,4 @@ +import { clearSevenElevenReadCache } from '../../../src/services/seveneleven/readCache.js'; /** * 세븐일레븐 클라이언트 테스트 */ @@ -13,17 +14,8 @@ import { const mockFetch = vi.fn(); -function zyteJsonResponse(value: unknown): Response { - return new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: Buffer.from(JSON.stringify(value), 'utf8').toString('base64'), - }), - { headers: { 'Content-Type': 'application/json' } }, - ); -} - beforeEach(() => { + clearSevenElevenReadCache(); mockFetch.mockReset(); vi.stubGlobal('fetch', mockFetch); }); @@ -33,136 +25,60 @@ afterEach(() => { }); describe('seveneleven client retry defaults', () => { - it('상품 검색 403을 Zyte로 복구한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce( - zyteJsonResponse({ - success: true, - data: { - SearchQueryResult: { - query: '커피', - Collection: [ - { - CollectionId: 'offline', - Documentset: { - totalCount: 1, - Document: [{ field: { itemCd: '8801', itemOnm: '커피' } }], - }, - }, - ], - }, - }, - message: '성공', - code: 200, - }), - ); - - const result = await searchSevenElevenProducts( - { query: '커피', size: 1 }, - { zyteApiKey: 'worker-key' }, - ); - - expect(result.products[0].itemCode).toBe('8801'); - const payload = JSON.parse(String(mockFetch.mock.calls[1][1]?.body)); - expect(payload).toMatchObject({ - url: 'https://new.7-elevenapp.co.kr/api/v1/open/search/goods', - httpRequestMethod: 'POST', - tags: { service: 'seveneleven' }, - }); + it('상품 검색 차단 시 유료 호출 없이 비용 정책을 알린다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); + await expect( + searchSevenElevenProducts({ query: '커피' }, { zyteApiKey: 'worker-key' }), + ).rejects.toThrow('비용 정책'); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls.some(([url]) => new URL(String(url)).hostname === 'api.zyte.com')).toBe(false); }); - it('매장 검색 403을 Zyte로 복구한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce( - zyteJsonResponse({ - success: true, - data: { - SearchQueryResult: { - query: '강남', - Collection: [ - { - Documentset: { - totalCount: 1, - Document: [{ field: { storeCd: 'S1', storeNm: '강남점' } }], - }, - }, - ], - }, - }, - }), - ); - - const result = await fetchSevenElevenStoresByKeyword( - { keyword: '강남', limit: 1 }, - { zyteApiKey: 'worker-key' }, - ); - - expect(result.stores[0].storeCode).toBe('S1'); + it('매장 검색 차단 시 유료 호출 없이 비용 정책을 알린다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); + await expect( + fetchSevenElevenStoresByKeyword({ keyword: '강남' }, { zyteApiKey: 'worker-key' }), + ).rejects.toThrow('비용 정책'); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls.some(([url]) => new URL(String(url)).hostname === 'api.zyte.com')).toBe(false); }); - it('인기 검색어 403을 Zyte로 복구한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce( - zyteJsonResponse({ - success: true, - data: { keywords: ['커피', '도시락'] }, - }), - ); - + it('인기 검색어 차단 시 유료 호출 없이 비용 정책을 알린다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); await expect( fetchSevenElevenSearchPopwords('home', { zyteApiKey: 'worker-key' }), - ).resolves.toEqual(['커피', '도시락']); + ).rejects.toThrow('비용 정책'); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls.some(([url]) => new URL(String(url)).hostname === 'api.zyte.com')).toBe(false); }); - it('재고 상품 메타 403을 Zyte로 복구한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce( - zyteJsonResponse({ - prdNo: 'P1', - itemCd: '8801', - itemOnm: '커피', - smCd: 'SM1', - stokMngCd: 'STOCK', - stokMngQty: 3, - stockApplicationRate: '100', - }), - ); - - const result = await fetchSevenElevenStockProductMeta('8801', { - zyteApiKey: 'worker-key', - }); - - expect(result).toEqual(expect.objectContaining({ itemCode: '8801', smCode: 'SM1' })); + it('재고 상품 메타 차단 시 유료 호출 없이 비용 정책을 알린다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); + await expect( + fetchSevenElevenStockProductMeta('8801', { zyteApiKey: 'worker-key' }), + ).rejects.toThrow('비용 정책'); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls.some(([url]) => new URL(String(url)).hostname === 'api.zyte.com')).toBe(false); }); - it('카탈로그 페이지 403을 Zyte로 복구한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce( - zyteJsonResponse({ - success: true, - data: { - content: [{ itemCd: '8801', itemOnm: '커피' }], - }, - }), - ); - - const result = await fetchSevenElevenCatalogSnapshot({ - includeIssues: false, - includeExhibition: false, - zyteApiKey: 'worker-key', - }); - - expect(result.pages[0].itemCode).toBe('8801'); + it('카탈로그 페이지 차단 시 유료 호출 없이 빈 결과를 반환한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); + await expect( + fetchSevenElevenCatalogSnapshot({ + includeIssues: false, + includeExhibition: false, + zyteApiKey: 'worker-key', + }), + ).resolves.toEqual({ pages: [], issues: [], exhibitions: [] }); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls.some(([url]) => new URL(String(url)).hostname === 'api.zyte.com')).toBe(false); }); it('일시적 GET 실패는 기본 재시도로 복구한다', async () => { mockFetch - .mockResolvedValueOnce(new Response('origin timeout', { status: 522, statusText: 'Origin Timeout' })) + .mockResolvedValueOnce( + new Response('origin timeout', { status: 522, statusText: 'Origin Timeout' }), + ) .mockResolvedValueOnce( new Response( JSON.stringify({ @@ -183,7 +99,9 @@ describe('seveneleven client retry defaults', () => { it('읽기성 POST 상품 검색은 allowlist로 재시도한다', async () => { mockFetch - .mockResolvedValueOnce(new Response('origin timeout', { status: 522, statusText: 'Origin Timeout' })) + .mockResolvedValueOnce( + new Response('origin timeout', { status: 522, statusText: 'Origin Timeout' }), + ) .mockResolvedValueOnce( new Response( JSON.stringify({ diff --git a/tests/services/seveneleven/index.test.ts b/tests/services/seveneleven/index.test.ts index e5e19f26..b82eff70 100644 --- a/tests/services/seveneleven/index.test.ts +++ b/tests/services/seveneleven/index.test.ts @@ -1,3 +1,4 @@ +import { clearSevenElevenReadCache } from '../../../src/services/seveneleven/readCache.js'; /** * 세븐일레븐 서비스 테스트 */ @@ -8,6 +9,7 @@ import { createSevenElevenService } from '../../../src/services/seveneleven/inde const mockFetch = vi.fn(); beforeEach(() => { + clearSevenElevenReadCache(); mockFetch.mockReset(); vi.stubGlobal('fetch', mockFetch); }); @@ -45,48 +47,17 @@ describe('createSevenElevenService', () => { ]); }); - it('서비스 옵션의 Zyte 키를 MCP 도구에 전달한다', async () => { - const payload = { - success: true, - data: { - SearchQueryResult: { - query: '커피', - Collection: [ - { - CollectionId: 'offline', - Documentset: { - totalCount: 1, - Document: [{ prdNo: '1', itemCd: '8801', itemOnm: '아메리카노' }], - }, - }, - ], - }, - }, - }; - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: Buffer.from(JSON.stringify(payload)).toString('base64'), - }), - ), - ); - - const service = createSevenElevenService({ zyteApiKey: 'worker-key' }); - const tool = service.getTools().find((item) => item.name === 'seveneleven_search_products'); + it('서비스에 키가 있어도 MCP 상품 조회는 유료 호출 없이 차단된다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); + const tool = createSevenElevenService({ zyteApiKey: 'worker-key' }) + .getTools() + .find((item) => item.name === 'seveneleven_search_products'); const result = await tool?.handler({ query: '커피', size: 1 }); - - expect(result?.structuredContent).toMatchObject({ count: 1 }); - expect(mockFetch).toHaveBeenNthCalledWith( - 2, - 'https://api.zyte.com/v1/extract', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: `Basic ${Buffer.from('worker-key:').toString('base64')}`, - }), - }), - ); + expect(result?.structuredContent).toMatchObject({ + count: 0, + status: 'degraded', + message: expect.stringContaining('비용 정책'), + }); + expect(mockFetch).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/services/seveneleven/productKeyword.test.ts b/tests/services/seveneleven/productKeyword.test.ts index 6e860399..bc57fa86 100644 --- a/tests/services/seveneleven/productKeyword.test.ts +++ b/tests/services/seveneleven/productKeyword.test.ts @@ -1,3 +1,4 @@ +import { clearSevenElevenReadCache } from '../../../src/services/seveneleven/readCache.js'; /** * 세븐일레븐 상품 검색어 보정 테스트 */ @@ -34,34 +35,8 @@ function makeProductResponse(query: string, products: Array>) { - const body = { - success: true, - data: { - SearchQueryResult: { - query, - Collection: [ - { - CollectionId: 'offline', - Documentset: { - totalCount: products.length, - Document: products, - }, - }, - ], - }, - }, - }; - return new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: Buffer.from(JSON.stringify(body), 'utf8').toString('base64'), - }), - { headers: { 'Content-Type': 'application/json' } }, - ); -} - beforeEach(() => { + clearSevenElevenReadCache(); mockFetch.mockReset(); vi.stubGlobal('fetch', mockFetch); }); @@ -166,30 +141,13 @@ describe('pickBestSevenElevenProduct', () => { }); describe('searchSevenElevenProductsWithVariants', () => { - it('보정 검색에도 Worker Zyte 키를 전달한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce( - makeZyteProductResponse('핫식스', [ - { - prdNo: '1', - itemCd: '111', - itemOnm: '핫식스', - onlinePrice: 1500, - }, - ]), - ); - - const result = await searchSevenElevenProductsWithVariants('핫식스', { - size: 1, - zyteApiKey: 'worker-key', - }); - - expect(result.products[0].itemCode).toBe('111'); - const zyteHeaders = new Headers(mockFetch.mock.calls[1][1]?.headers); - expect(zyteHeaders.get('Authorization')).toBe( - `Basic ${Buffer.from('worker-key:', 'utf8').toString('base64')}`, - ); + it('보정 검색 차단도 유료 호출 없이 비용 정책을 알린다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); + await expect( + searchSevenElevenProductsWithVariants('핫식스', { size: 1, zyteApiKey: 'worker-key' }), + ).rejects.toThrow('비용 정책'); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls.some(([url]) => new URL(String(url)).hostname === 'api.zyte.com')).toBe(false); }); it('대체 질의 결과를 합쳐 가장 관련도 높은 상품을 앞에 둔다', async () => { diff --git a/tests/services/seveneleven/readCache.test.ts b/tests/services/seveneleven/readCache.test.ts new file mode 100644 index 00000000..77247aca --- /dev/null +++ b/tests/services/seveneleven/readCache.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SEVENELEVEN_API } from '../../../src/services/seveneleven/api.js'; +import { clearSevenElevenReadCache, withSevenElevenReadCache } from '../../../src/services/seveneleven/readCache.js'; + +beforeEach(() => { clearSevenElevenReadCache(); vi.useRealTimers(); }); +const path = SEVENELEVEN_API.SEARCH_GOODS_PATH; +const result = () => ({ success: true, data: { values: ['original'] } }); + +describe('세븐일레븐 공용 읽기 캐시', () => { + it('같은 조회를 합치고 성공 결과를 복제해서 재사용한다', async () => { + let finish!: (value: ReturnType) => void; + const request = vi.fn(() => new Promise>(resolve => { finish = resolve; })); + const first = withSevenElevenReadCache(path, { query: '커피' }, 1000, request); + const second = withSevenElevenReadCache(path, { query: '커피' }, 1000, request); + finish(result()); + const [a, b] = await Promise.all([first, second]); + a.data!.values.push('changed'); + expect(b.data!.values).toEqual(['original']); + expect(await withSevenElevenReadCache(path, { query: '커피' }, 1000, request)).toEqual(result()); + expect(request).toHaveBeenCalledTimes(1); + }); + it('실패한 요청은 저장하지 않고 다음 조회를 허용한다', async () => { + const request = vi.fn().mockRejectedValueOnce(new Error('failed')).mockResolvedValue(result()); + await expect(withSevenElevenReadCache(path, {}, 1000, request)).rejects.toThrow('failed'); + await expect(withSevenElevenReadCache(path, {}, 1000, request)).resolves.toEqual(result()); + expect(request).toHaveBeenCalledTimes(2); + }); + it('서버가 반환한 실패와 데이터 없는 응답을 캐시하지 않는다', async () => { + for (const response of [{ success: false, data: {} }, { success: true }]) { + const request = vi.fn().mockResolvedValue(response); + await withSevenElevenReadCache(path, response, 1000, request); + await withSevenElevenReadCache(path, response, 1000, request); + expect(request).toHaveBeenCalledTimes(2); + } + }); + it('검색어와 제한 시간이 다른 요청을 분리하고 재고는 저장하지 않는다', async () => { + const request = vi.fn().mockResolvedValue(result()); + await withSevenElevenReadCache(path, { query: '커피' }, 1000, request); + await withSevenElevenReadCache(path, { query: '과자' }, 1000, request); + await withSevenElevenReadCache(path, { query: '과자' }, 2000, request); + await withSevenElevenReadCache(SEVENELEVEN_API.REAL_STOCK_MULTI_PATH, {}, 1000, request); + await withSevenElevenReadCache(SEVENELEVEN_API.REAL_STOCK_MULTI_PATH, {}, 1000, request); + expect(request).toHaveBeenCalledTimes(5); + }); + it('상품은 5분, 매장은 30분이 지나면 갱신한다', async () => { + vi.useFakeTimers(); + const request = vi.fn().mockResolvedValue(result()); + for (const [target, ttl] of [[path, 300000], [SEVENELEVEN_API.SEARCH_STORE_PATH, 1800000]] as const) { + await withSevenElevenReadCache(target, {}, 1000, request); + vi.advanceTimersByTime(ttl - 1); + await withSevenElevenReadCache(target, {}, 1000, request); + vi.advanceTimersByTime(1); + await withSevenElevenReadCache(target, {}, 1000, request); + } + expect(request).toHaveBeenCalledTimes(4); + vi.useRealTimers(); + }); + it('성공 캐시 128개 상한을 지키고 오래된 항목을 내보낸다', async () => { + const request = vi.fn().mockResolvedValue(result()); + for (let i = 0; i < 129; i++) await withSevenElevenReadCache(path, { i }, 1000, request); + await withSevenElevenReadCache(path, { i: 0 }, 1000, request); + expect(request).toHaveBeenCalledTimes(130); + }); +}); + +it('진행 중 조회가 128개이면 추가 키는 보관하지 않는다', async () => { + const finishes: Array<(value: ReturnType) => void> = []; + const request = vi.fn(() => new Promise>(resolve => finishes.push(resolve))); + const calls = Array.from({ length: 128 }, (_, i) => withSevenElevenReadCache(path, { i }, 1000, request)); + const extra = vi.fn().mockResolvedValue(result()); + await withSevenElevenReadCache(path, { i: 129 }, 1000, extra); + await withSevenElevenReadCache(path, { i: 129 }, 1000, extra); + expect(extra).toHaveBeenCalledTimes(2); + finishes.forEach(finish => finish(result())); + await Promise.all(calls); +}); + +it('실제 서비스 계층의 연속 검색이 원본 호출을 공유한다', async () => { + const { searchSevenElevenProducts } = await import('../../../src/services/seveneleven/client.js'); + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ success: true, data: { content: [{ itemCd: '8801', itemOnm: '커피' }] } }))); + vi.stubGlobal('fetch', fetchMock); + try { + const first = await searchSevenElevenProducts({ query: '공유 테스트' }); + const second = await searchSevenElevenProducts({ query: '공유 테스트' }); + expect(first).toEqual(second); + expect(fetchMock).toHaveBeenCalledTimes(1); + } finally { vi.unstubAllGlobals(); } +}); + +it('인기 검색어의 label 질의 문자열도 캐시하고 label별로 분리한다', async () => { + const { fetchSevenElevenSearchPopwords } = await import('../../../src/services/seveneleven/client.js'); + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ success: true, data: ['커피'] })))); + vi.stubGlobal('fetch', fetchMock); + try { + await fetchSevenElevenSearchPopwords('goods'); + await fetchSevenElevenSearchPopwords('goods'); + await fetchSevenElevenSearchPopwords('store'); + expect(fetchMock).toHaveBeenCalledTimes(2); + } finally { vi.unstubAllGlobals(); } +}); diff --git a/tests/services/seveneleven/tools/checkInventory.storeFiltering.test.ts b/tests/services/seveneleven/tools/checkInventory.storeFiltering.test.ts index 86c0a83c..d2c566b7 100644 --- a/tests/services/seveneleven/tools/checkInventory.storeFiltering.test.ts +++ b/tests/services/seveneleven/tools/checkInventory.storeFiltering.test.ts @@ -1,3 +1,4 @@ +import { clearSevenElevenReadCache } from '../../../../src/services/seveneleven/readCache.js'; /** * 세븐일레븐 재고 확인 도구 매장 필터링 테스트 */ @@ -8,6 +9,7 @@ import { createCheckInventoryTool } from '../../../../src/services/seveneleven/t const mockFetch = vi.fn(); beforeEach(() => { + clearSevenElevenReadCache(); mockFetch.mockReset(); vi.stubGlobal('fetch', mockFetch); }); diff --git a/tests/services/seveneleven/tools/checkInventory.test.ts b/tests/services/seveneleven/tools/checkInventory.test.ts index 03b2f416..4944d769 100644 --- a/tests/services/seveneleven/tools/checkInventory.test.ts +++ b/tests/services/seveneleven/tools/checkInventory.test.ts @@ -1,3 +1,4 @@ +import { clearSevenElevenReadCache } from '../../../../src/services/seveneleven/readCache.js'; /** * 세븐일레븐 재고 확인 도구 테스트 */ @@ -8,6 +9,7 @@ import { createCheckInventoryTool } from '../../../../src/services/seveneleven/t const mockFetch = vi.fn(); beforeEach(() => { + clearSevenElevenReadCache(); mockFetch.mockReset(); vi.stubGlobal('fetch', mockFetch); }); @@ -117,13 +119,17 @@ describe('createCheckInventoryTool', () => { it('keyword가 없으면 에러를 던진다', async () => { const tool = createCheckInventoryTool(); - await expect(tool.handler({ keyword: '' })).rejects.toThrow('상품 검색어(keyword)를 입력해주세요.'); + await expect(tool.handler({ keyword: '' })).rejects.toThrow( + '상품 검색어(keyword)를 입력해주세요.', + ); }); it('공백만 있는 keyword도 에러를 던진다', async () => { const tool = createCheckInventoryTool(); - await expect(tool.handler({ keyword: ' ' })).rejects.toThrow('상품 검색어(keyword)를 입력해주세요.'); + await expect(tool.handler({ keyword: ' ' })).rejects.toThrow( + '상품 검색어(keyword)를 입력해주세요.', + ); }); it('재고 API 실패 시 상품/매장 정보를 함께 반환한다', async () => { @@ -248,9 +254,7 @@ describe('createCheckInventoryTool', () => { }, ]), ) - .mockResolvedValueOnce( - makeStockProductMetaResponse(), - ) + .mockResolvedValueOnce(makeStockProductMetaResponse()) .mockResolvedValueOnce( makeStockSuccessResponse([ { @@ -276,22 +280,20 @@ describe('createCheckInventoryTool', () => { }); it('상품이 검색되지 않으면 product가 null이다', async () => { - mockFetch - .mockResolvedValueOnce(makeProductResponse([])) - .mockResolvedValueOnce( - makeStoreResponse([ - { - field: { - storeCd: '54928', - storeNm: '안산중앙일번가점', - addr1: '경기 안산시 단원구', - addr2: '', - storeLat: '37.3156', - storeLon: '126.8384', - }, + mockFetch.mockResolvedValueOnce(makeProductResponse([])).mockResolvedValueOnce( + makeStoreResponse([ + { + field: { + storeCd: '54928', + storeNm: '안산중앙일번가점', + addr1: '경기 안산시 단원구', + addr2: '', + storeLat: '37.3156', + storeLon: '126.8384', }, - ]), - ); + }, + ]), + ); const tool = createCheckInventoryTool(); const result = await tool.handler({ keyword: '존재하지않는상품xyz', storeKeyword: '안산' }); @@ -392,9 +394,7 @@ describe('createCheckInventoryTool', () => { }, ]), ) - .mockResolvedValueOnce( - makeStoreResponse([]), - ) + .mockResolvedValueOnce(makeStoreResponse([])) .mockResolvedValueOnce(makeStockProductMetaResponse()); const tool = createCheckInventoryTool(); @@ -477,16 +477,7 @@ describe('createCheckInventoryTool', () => { ); }); - it('주입된 Zyte 키로 차단된 실재고 API를 복구한다', async () => { - const stockPayload = { - success: true, - data: { - smCd: '201051', - storeList: [{ storeCd: '54928', stock: 4, stokMngQty: 0 }], - }, - message: '성공', - code: 200, - }; + it('키가 있어도 실재고 차단 시 유료 호출 없이 조회 불가를 반환한다', async () => { mockFetch .mockResolvedValueOnce( makeProductResponse([ @@ -512,30 +503,15 @@ describe('createCheckInventoryTool', () => { ]), ) .mockResolvedValueOnce(makeStockProductMetaResponse()) - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: Buffer.from(JSON.stringify(stockPayload)).toString('base64'), - }), - ), - ); + .mockResolvedValueOnce(new Response('blocked', { status: 403 })); const result = await createCheckInventoryTool('worker-key').handler({ keyword: '핫식스', storeKeyword: '안산', }); - expect(JSON.parse(result.content[0].text).stockAvailable).toBe(true); - expect(mockFetch).toHaveBeenNthCalledWith( - 5, - 'https://api.zyte.com/v1/extract', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: `Basic ${Buffer.from('worker-key:').toString('base64')}`, - }), - }), - ); + expect(JSON.parse(result.content[0].text).stockAvailable).toBe(false); + expect(mockFetch).toHaveBeenCalledTimes(4); + expect(mockFetch.mock.calls.some(([url]) => new URL(String(url)).hostname === 'api.zyte.com')).toBe(false); }); }); diff --git a/tests/services/seveneleven/tools/getCatalogSnapshot.test.ts b/tests/services/seveneleven/tools/getCatalogSnapshot.test.ts index 2e880622..37be0731 100644 --- a/tests/services/seveneleven/tools/getCatalogSnapshot.test.ts +++ b/tests/services/seveneleven/tools/getCatalogSnapshot.test.ts @@ -1,3 +1,4 @@ +import { clearSevenElevenReadCache } from '../../../../src/services/seveneleven/readCache.js'; /** * 세븐일레븐 카탈로그 스냅샷 도구 테스트 */ @@ -8,6 +9,7 @@ import { createGetCatalogSnapshotTool } from '../../../../src/services/sevenelev const mockFetch = vi.fn(); beforeEach(() => { + clearSevenElevenReadCache(); mockFetch.mockReset(); vi.stubGlobal('fetch', mockFetch); }); @@ -31,7 +33,15 @@ describe('createGetCatalogSnapshotTool', () => { JSON.stringify({ success: true, data: { - content: [{ prdNo: '1', itemCd: '111', itemOnm: '상품A', onlinePrice: 1000, onlineCost: 1200 }], + content: [ + { + prdNo: '1', + itemCd: '111', + itemOnm: '상품A', + onlinePrice: 1000, + onlineCost: 1200, + }, + ], }, }), ), @@ -41,7 +51,15 @@ describe('createGetCatalogSnapshotTool', () => { JSON.stringify({ success: true, data: { - content: [{ prdNo: '2', itemCd: '222', itemOnm: '이슈상품', onlinePrice: 2000, onlineCost: 2500 }], + content: [ + { + prdNo: '2', + itemCd: '222', + itemOnm: '이슈상품', + onlinePrice: 2000, + onlineCost: 2500, + }, + ], }, }), ), @@ -73,36 +91,13 @@ describe('createGetCatalogSnapshotTool', () => { expect(parsed.exhibitions.items[0].productCount).toBe(2); }); - it('주입된 Zyte 키로 차단된 카탈로그 API를 복구한다', async () => { - const payload = { - success: true, - data: { content: [{ prdNo: '1', itemCd: '111', itemOnm: '상품A' }] }, - }; - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: Buffer.from(JSON.stringify(payload)).toString('base64'), - }), - ), - ); - + it('키가 있어도 카탈로그 차단 시 유료 호출 없이 빈 결과를 반환한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); const result = await createGetCatalogSnapshotTool('worker-key').handler({ includeIssues: false, includeExhibition: false, }); - - expect(JSON.parse(result.content[0].text).pages.totalCount).toBe(1); - expect(mockFetch).toHaveBeenNthCalledWith( - 2, - 'https://api.zyte.com/v1/extract', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: `Basic ${Buffer.from('worker-key:').toString('base64')}`, - }), - }), - ); + expect(JSON.parse(result.content[0].text).pages.totalCount).toBe(0); + expect(mockFetch).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/services/seveneleven/tools/getSearchPopwords.test.ts b/tests/services/seveneleven/tools/getSearchPopwords.test.ts index 87fa709d..e6fc4c52 100644 --- a/tests/services/seveneleven/tools/getSearchPopwords.test.ts +++ b/tests/services/seveneleven/tools/getSearchPopwords.test.ts @@ -1,3 +1,4 @@ +import { clearSevenElevenReadCache } from '../../../../src/services/seveneleven/readCache.js'; /** * 세븐일레븐 인기 검색어 도구 테스트 */ @@ -8,6 +9,7 @@ import { createGetSearchPopwordsTool } from '../../../../src/services/seveneleve const mockFetch = vi.fn(); beforeEach(() => { + clearSevenElevenReadCache(); mockFetch.mockReset(); vi.stubGlobal('fetch', mockFetch); }); @@ -55,33 +57,12 @@ describe('createGetSearchPopwordsTool', () => { expect(parsed.note).toContain('찾지 못했습니다'); }); - it('주입된 Zyte 키로 차단된 인기 검색어 API를 복구한다', async () => { - const payload = { - success: true, - data: { list: [{ keyword: '도시락' }] }, - }; - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: Buffer.from(JSON.stringify(payload)).toString('base64'), - }), - ), - ); - - const result = await createGetSearchPopwordsTool('worker-key').handler({ label: 'home' }); - - expect(JSON.parse(result.content[0].text).keywords).toEqual(['도시락']); - expect(mockFetch).toHaveBeenNthCalledWith( - 2, - 'https://api.zyte.com/v1/extract', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: `Basic ${Buffer.from('worker-key:').toString('base64')}`, - }), - }), - ); + it('키가 있어도 차단된 인기 검색어 요청에 유료 호출을 보내지 않는다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); + await expect( + createGetSearchPopwordsTool('worker-key').handler({ label: 'home' }), + ).rejects.toThrow('비용 정책'); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls.some(([url]) => new URL(String(url)).hostname === 'api.zyte.com')).toBe(false); }); }); diff --git a/tests/services/seveneleven/tools/searchProducts.test.ts b/tests/services/seveneleven/tools/searchProducts.test.ts index 7b4f5576..2db8fa30 100644 --- a/tests/services/seveneleven/tools/searchProducts.test.ts +++ b/tests/services/seveneleven/tools/searchProducts.test.ts @@ -1,3 +1,4 @@ +import { clearSevenElevenReadCache } from '../../../../src/services/seveneleven/readCache.js'; /** * 세븐일레븐 상품 검색 도구 테스트 */ @@ -8,6 +9,7 @@ import { createSearchProductsTool } from '../../../../src/services/seveneleven/t const mockFetch = vi.fn(); beforeEach(() => { + clearSevenElevenReadCache(); mockFetch.mockReset(); vi.stubGlobal('fetch', mockFetch); }); @@ -164,48 +166,15 @@ describe('createSearchProductsTool', () => { }); }); - it('주입된 Zyte 키로 차단된 상품 API를 복구한다', async () => { - const zytePayload = { - success: true, - data: { - SearchQueryResult: { - query: '커피', - Collection: [ - { - CollectionId: 'offline', - Documentset: { - totalCount: 1, - Document: [{ prdNo: '1', itemCd: '8801', itemOnm: '아메리카노' }], - }, - }, - ], - }, - }, - }; - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: Buffer.from(JSON.stringify(zytePayload)).toString('base64'), - }), - ), - ); - - const tool = createSearchProductsTool('worker-key'); - const result = await tool.handler({ query: '커피', size: 1 }); - - expect(result.structuredContent).toMatchObject({ query: '커피', count: 1 }); - expect(mockFetch).toHaveBeenNthCalledWith( - 2, - 'https://api.zyte.com/v1/extract', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: `Basic ${Buffer.from('worker-key:').toString('base64')}`, - }), - }), - ); + it('키가 있어도 상품 차단 시 비용 정책을 포함한 degraded 결과를 반환한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); + const result = await createSearchProductsTool('worker-key').handler({ query: '커피', size: 1 }); + expect(result.structuredContent).toMatchObject({ + count: 0, + status: 'degraded', + message: expect.stringContaining('비용 정책'), + }); + expect(mockFetch).toHaveBeenCalledTimes(1); }); it('비 Error 예외도 output schema를 만족하는 degraded 응답으로 변환한다', async () => { @@ -234,7 +203,9 @@ describe('createSearchProductsTool', () => { data: { SearchQueryResult: { query: '후르츠산도', - Collection: [{ CollectionId: 'offline', Documentset: { totalCount: 0, Document: [] } }], + Collection: [ + { CollectionId: 'offline', Documentset: { totalCount: 0, Document: [] } }, + ], }, }, }), @@ -309,7 +280,9 @@ describe('createSearchProductsTool', () => { data: { SearchQueryResult: { query: '후르츠샌드위치', - Collection: [{ CollectionId: 'offline', Documentset: { totalCount: 0, Document: [] } }], + Collection: [ + { CollectionId: 'offline', Documentset: { totalCount: 0, Document: [] } }, + ], }, }, }), @@ -322,7 +295,9 @@ describe('createSearchProductsTool', () => { data: { SearchQueryResult: { query: '후르츠샌드', - Collection: [{ CollectionId: 'offline', Documentset: { totalCount: 0, Document: [] } }], + Collection: [ + { CollectionId: 'offline', Documentset: { totalCount: 0, Document: [] } }, + ], }, }, }), diff --git a/tests/services/seveneleven/tools/searchStores.test.ts b/tests/services/seveneleven/tools/searchStores.test.ts index b6aeb77d..d6bd54da 100644 --- a/tests/services/seveneleven/tools/searchStores.test.ts +++ b/tests/services/seveneleven/tools/searchStores.test.ts @@ -1,3 +1,4 @@ +import { clearSevenElevenReadCache } from '../../../../src/services/seveneleven/readCache.js'; /** * 세븐일레븐 매장 검색 도구 테스트 */ @@ -8,6 +9,7 @@ import { createSearchStoresTool } from '../../../../src/services/seveneleven/too const mockFetch = vi.fn(); beforeEach(() => { + clearSevenElevenReadCache(); mockFetch.mockReset(); vi.stubGlobal('fetch', mockFetch); }); @@ -27,7 +29,9 @@ describe('createSearchStoresTool', () => { it('keyword가 없으면 에러를 던진다', async () => { const tool = createSearchStoresTool(); - await expect(tool.handler({ keyword: '' })).rejects.toThrow('매장 검색어(keyword)를 입력해주세요.'); + await expect(tool.handler({ keyword: '' })).rejects.toThrow( + '매장 검색어(keyword)를 입력해주세요.', + ); }); it('매장 검색 결과를 반환한다', async () => { @@ -92,46 +96,12 @@ describe('createSearchStoresTool', () => { ); }); - it('주입된 Zyte 키로 차단된 매장 API를 복구한다', async () => { - const payload = { - success: true, - data: { - SearchQueryResult: { - query: '강남', - Collection: [ - { - CollectionId: 'store', - Documentset: { - totalCount: 1, - Document: [{ field: { storeCd: '1', storeNm: '강남점', addr1: '서울 강남구' } }], - }, - }, - ], - }, - }, - }; - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: Buffer.from(JSON.stringify(payload)).toString('base64'), - }), - ), - ); - - const result = await createSearchStoresTool('worker-key').handler({ keyword: '강남' }); - - expect(JSON.parse(result.content[0].text).count).toBe(1); - expect(mockFetch).toHaveBeenNthCalledWith( - 2, - 'https://api.zyte.com/v1/extract', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: `Basic ${Buffer.from('worker-key:').toString('base64')}`, - }), - }), + it('키가 있어도 차단된 매장 요청에 유료 호출을 보내지 않는다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); + await expect(createSearchStoresTool('worker-key').handler({ keyword: '강남' })).rejects.toThrow( + '비용 정책', ); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls.some(([url]) => new URL(String(url)).hostname === 'api.zyte.com')).toBe(false); }); }); diff --git a/tests/utils/zyte.test.ts b/tests/utils/zyte.test.ts index bd3626cc..1b6e6d34 100644 --- a/tests/utils/zyte.test.ts +++ b/tests/utils/zyte.test.ts @@ -1,110 +1,93 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { requestByZyte } from '../../src/utils/zyte.js'; +import { decodeBase64, decodeZyteHttpBody, requestByZyte } from '../../src/utils/zyte.js'; const mockFetch = vi.fn(); +const policyMessage = 'Zyte 유료 호출은 비용 정책에 따라 비활성화되어 있습니다.'; beforeEach(() => { mockFetch.mockReset(); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ statusCode: 200 }))); vi.stubGlobal('fetch', mockFetch); }); afterEach(() => { - vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); -describe('requestByZyte', () => { - it('통계 구분용 태그를 Zyte 요청 본문에 포함한다', async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ statusCode: 200, httpResponseBody: 'e30=' })), +describe('requestByZyte 비용 정책', () => { + it('명시적인 키가 있어도 유료 요청을 보내지 않는다', async () => { + await expect(requestByZyte({ apiKey: 'test-key', url: 'https://example.com' })).rejects.toThrow( + policyMessage, ); - - await requestByZyte({ - apiKey: 'test-key', - url: 'https://example.com/api', - tags: { service: 'cgv', operation: 'timetable' }, - }); - - const requestInit = mockFetch.mock.calls[0][1] as RequestInit; - expect(JSON.parse(String(requestInit.body))).toMatchObject({ - tags: { service: 'cgv', operation: 'timetable' }, - }); + expect(mockFetch).not.toHaveBeenCalled(); }); - it('AbortError가 발생하면 한 번 재시도한다', async () => { - mockFetch - .mockRejectedValueOnce(new DOMException('aborted', 'AbortError')) - .mockResolvedValueOnce(new Response(JSON.stringify({ statusCode: 200, httpResponseBody: 'e30=' }))); - - const result = await requestByZyte({ - apiKey: 'test-key', - url: 'https://example.com/api', - timeout: 1000, - retryDelayMs: 0, - }); - - expect(result.statusCode).toBe(200); - expect(mockFetch).toHaveBeenCalledTimes(2); + it('환경 변수에 키가 남아 있어도 유료 요청을 보내지 않는다', async () => { + vi.stubEnv('ZYTE_API_KEY', 'test-env-key'); + await expect(requestByZyte({ url: 'https://example.com' })).rejects.toThrow(policyMessage); + expect(mockFetch).not.toHaveBeenCalled(); }); - it('Zyte API 5xx가 발생하면 한 번 재시도한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response(JSON.stringify({ detail: 'temporary outage' }), { status: 503 })) - .mockResolvedValueOnce(new Response(JSON.stringify({ statusCode: 200, httpResponseBody: 'e30=' }))); - - const result = await requestByZyte({ - apiKey: 'test-key', - url: 'https://example.com/api', - timeout: 1000, - retryDelayMs: 0, - }); - - expect(result.statusCode).toBe(200); - expect(mockFetch).toHaveBeenCalledTimes(2); + it('키가 없어도 설정 안내 대신 비용 정책을 알린다', async () => { + vi.stubEnv('ZYTE_API_KEY', undefined); + await expect(requestByZyte({ url: 'https://example.com' })).rejects.toThrow(policyMessage); + expect(mockFetch).not.toHaveBeenCalled(); }); - it('Zyte API가 평문 520 오류를 반환해도 상태와 본문을 보존한다', async () => { - mockFetch.mockResolvedValueOnce(new Response('error code: 520', { status: 520 })); - + it('키를 조회하기 전에 요청을 차단한다', async () => { + const readKey = vi.fn(() => { + throw new Error('키에 접근했습니다'); + }); await expect( requestByZyte({ - apiKey: 'test-key', - url: 'https://example.com/api', - retries: 0, + url: 'https://example.com', + get apiKey() { + return readKey(); + }, }), - ).rejects.toThrow('Zyte API 호출 실패: 520 error code: 520'); - - expect(mockFetch).toHaveBeenCalledTimes(1); + ).rejects.toThrow(policyMessage); + expect(readKey).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); }); - it('대상 사이트 5xx 응답이면 한 번 재시도한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response(JSON.stringify({ statusCode: 520, httpResponseBody: 'e30=' }))) - .mockResolvedValueOnce(new Response(JSON.stringify({ statusCode: 200, httpResponseBody: 'e30=' }))); - - const result = await requestByZyte({ - apiKey: 'test-key', - url: 'https://example.com/api', - timeout: 1000, - retryDelayMs: 0, - }); + it('동시 요청과 재시도 설정도 유료 요청을 만들지 않는다', async () => { + const results = await Promise.allSettled( + Array.from({ length: 20 }, () => + requestByZyte({ + apiKey: 'test-key', + url: 'https://example.com', + retries: 100, + retryDelayMs: 0, + }), + ), + ); + for (const result of results) { + expect(result).toMatchObject({ status: 'rejected', reason: new Error(policyMessage) }); + } + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); - expect(result.statusCode).toBe(200); - expect(mockFetch).toHaveBeenCalledTimes(2); +describe('기존 응답 디코딩 호환성', () => { + it('UTF-8 본문을 디코딩한다', () => { + const encoded = Buffer.from(JSON.stringify({ name: '커피' })).toString('base64'); + expect(decodeBase64(encoded)).toBe('{"name":"커피"}'); + expect(decodeZyteHttpBody({ httpResponseBody: encoded })).toEqual({ name: '커피' }); }); - it('네트워크 오류가 발생하면 한 번 재시도한다', async () => { - mockFetch - .mockRejectedValueOnce(new TypeError('fetch failed')) - .mockResolvedValueOnce(new Response(JSON.stringify({ statusCode: 200, httpResponseBody: 'e30=' }))); + it('본문 누락은 명확한 오류를 반환한다', () => { + expect(() => decodeZyteHttpBody({})).toThrow('Zyte HTTP 응답 본문이 비어 있습니다.'); + }); - const result = await requestByZyte({ - apiKey: 'test-key', - url: 'https://example.com/api', - timeout: 1000, - retryDelayMs: 0, - }); + it('atob가 없으면 Buffer를 사용한다', () => { + vi.stubGlobal('atob', undefined); + expect(decodeBase64('e30=')).toBe('{}'); + }); - expect(result.statusCode).toBe(200); - expect(mockFetch).toHaveBeenCalledTimes(2); + it('디코딩 수단이 없으면 오류를 반환한다', () => { + vi.stubGlobal('atob', undefined); + vi.stubGlobal('Buffer', undefined); + expect(() => decodeBase64('e30=')).toThrow('Base64 디코딩을 지원하지 않는 런타임입니다.'); }); }); diff --git a/tests/utils/zyteJsonFallback.test.ts b/tests/utils/zyteJsonFallback.test.ts index 92c59d5a..6dc197b9 100644 --- a/tests/utils/zyteJsonFallback.test.ts +++ b/tests/utils/zyteJsonFallback.test.ts @@ -7,23 +7,6 @@ import { fetchJsonWithZyteFallback } from '../../src/utils/zyteJsonFallback.js'; const mockFetch = vi.fn(); -function encodeJson(value: unknown): string { - return Buffer.from(JSON.stringify(value), 'utf8').toString('base64'); -} - -function zyteResponse(value: unknown, statusCode = 200): Response { - return new Response( - JSON.stringify({ - statusCode, - httpResponseBody: encodeJson(value), - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }, - ); -} - beforeEach(() => { mockFetch.mockReset(); vi.stubGlobal('fetch', mockFetch); @@ -36,9 +19,11 @@ afterEach(() => { describe('fetchJsonWithZyteFallback', () => { it('HTML 성공 응답은 유료 재시도 없이 형식 오류를 전달한다', async () => { mockFetch.mockResolvedValueOnce(new Response('page')); - await expect(fetchJsonWithZyteFallback('https://example.com/api', { - zyteApiKey: 'test-key', - })).rejects.toMatchObject({ name: 'UnexpectedHtmlResponseError' }); + await expect( + fetchJsonWithZyteFallback('https://example.com/api', { + zyteApiKey: 'test-key', + }), + ).rejects.toMatchObject({ name: 'UnexpectedHtmlResponseError' }); expect(mockFetch).toHaveBeenCalledTimes(1); }); @@ -59,11 +44,8 @@ describe('fetchJsonWithZyteFallback', () => { expect(mockFetch).toHaveBeenCalledTimes(1); }); - it.each([400, 403, 429])('원본 %s 응답이면 Zyte로 재시도한다', async (status) => { - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status })) - .mockResolvedValueOnce(zyteResponse({ success: true })); - + it.each([400, 403, 429])('원본 %s 응답이어도 유료 요청은 보내지 않는다', async (status) => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status })); await expect( fetchJsonWithZyteFallback('https://example.com/api', { method: 'POST', @@ -72,42 +54,8 @@ describe('fetchJsonWithZyteFallback', () => { zyteApiKey: 'test-key', zyteTags: { service: 'test' }, }), - ).resolves.toEqual({ success: true }); - - expect(mockFetch).toHaveBeenCalledTimes(2); - }); - - it('Zyte 요청에 원본 메서드, 헤더, 본문, 태그를 보존한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce(zyteResponse({ ok: true })); - - await fetchJsonWithZyteFallback('https://example.com/api', { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: '{"query":"커피"}', - zyteApiKey: 'test-key', - zyteTags: { service: 'seveneleven' }, - }); - - const zyteInit = mockFetch.mock.calls[1][1] as RequestInit; - const zytePayload = JSON.parse(String(zyteInit.body)) as Record; - expect(zytePayload).toMatchObject({ - url: 'https://example.com/api', - httpRequestMethod: 'POST', - httpRequestText: '{"query":"커피"}', - httpResponseBody: true, - tags: { service: 'seveneleven' }, - }); - expect(zytePayload.customHttpRequestHeaders).toEqual( - expect.arrayContaining([ - { name: 'accept', value: 'application/json' }, - { name: 'content-type', value: 'application/json' }, - ]), - ); + ).rejects.toThrow('비용 정책'); + expect(mockFetch).toHaveBeenCalledTimes(1); }); it('원본 500 응답은 Zyte로 재시도하지 않는다', async () => { @@ -122,7 +70,7 @@ describe('fetchJsonWithZyteFallback', () => { expect(mockFetch).toHaveBeenCalledTimes(1); }); - it('Zyte가 지원하지 않는 메서드는 차단 응답 이후 명확히 거부한다', async () => { + it('HEAD 요청도 차단 응답 이후 비용 정책을 알린다', async () => { mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); await expect( @@ -130,110 +78,8 @@ describe('fetchJsonWithZyteFallback', () => { method: 'HEAD', zyteApiKey: 'test-key', }), - ).rejects.toThrow('Zyte에서 지원하지 않는 HTTP 메서드입니다: HEAD'); + ).rejects.toThrow('비용 정책'); expect(mockFetch).toHaveBeenCalledTimes(1); }); - - it('Zyte 대상 응답이 성공이 아니면 상태 코드를 포함해 실패한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce(zyteResponse({ error: 'still blocked' }, 403)); - - await expect( - fetchJsonWithZyteFallback('https://example.com/api', { - zyteApiKey: 'test-key', - }), - ).rejects.toThrow('Zyte 대상 응답 실패: 403'); - }); - - it('Zyte 대상 520에서도 유료 폴백은 한 번만 시도한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce(zyteResponse({ error: 'website ban' }, 520)) - .mockResolvedValueOnce(zyteResponse({ error: 'website ban' }, 520)); - - await expect( - fetchJsonWithZyteFallback('https://example.com/api', { - zyteApiKey: 'test-key', - }), - ).rejects.toThrow('Zyte 대상 응답 실패: 520'); - - expect(mockFetch).toHaveBeenCalledTimes(2); - }); - - it('Zyte 대상 상태 코드가 없으면 알 수 없음으로 실패한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce( - new Response(JSON.stringify({ httpResponseBody: encodeJson({}) }), { - headers: { 'Content-Type': 'application/json' }, - }), - ); - - await expect( - fetchJsonWithZyteFallback('https://example.com/api', { - zyteApiKey: 'test-key', - }), - ).rejects.toThrow('Zyte 대상 응답 실패: 알 수 없음'); - }); - - it('Zyte 응답 본문이 없으면 실패한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce( - new Response(JSON.stringify({ statusCode: 200 }), { - headers: { 'Content-Type': 'application/json' }, - }), - ); - - await expect( - fetchJsonWithZyteFallback('https://example.com/api', { - zyteApiKey: 'test-key', - }), - ).rejects.toThrow('Zyte HTTP 응답 본문이 비어 있습니다.'); - }); - - it('Zyte 응답 본문이 JSON이 아니면 실패한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - statusCode: 200, - httpResponseBody: Buffer.from('not-json', 'utf8').toString('base64'), - }), - { headers: { 'Content-Type': 'application/json' } }, - ), - ); - - await expect( - fetchJsonWithZyteFallback('https://example.com/api', { - zyteApiKey: 'test-key', - }), - ).rejects.toThrow(); - }); - - it('Zyte 계정 오류를 호출자에게 전달한다', async () => { - mockFetch - .mockResolvedValueOnce(new Response('blocked', { status: 403 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - status: 403, - detail: 'Your account has been suspended.', - }), - { - status: 403, - headers: { 'Content-Type': 'application/json' }, - }, - ), - ); - - await expect( - fetchJsonWithZyteFallback('https://example.com/api', { - zyteApiKey: 'test-key', - }), - ).rejects.toThrow('Zyte API 호출 실패: 403 Your account has been suspended.'); - }); }); diff --git a/vitest.config.ts b/vitest.config.ts index c0203eca..6a23fe59 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,7 +30,7 @@ export default defineConfig({ reporter: ['text', 'text-summary', 'html', 'json'], // 커버리지 대상 파일 - include: ['src/**/*.ts'], + include: ['src/**/*.ts', 'scripts/relay/oliveyoung.ts', 'scripts/relay/browser.ts'], // 커버리지 제외 파일 exclude: [