diff --git a/docs/superpowers/plans/2026-07-27-convenience-store-recovery.md b/docs/superpowers/plans/2026-07-27-convenience-store-recovery.md new file mode 100644 index 00000000..cc029afb --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-convenience-store-recovery.md @@ -0,0 +1,541 @@ +# Convenience Store Recovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore SevenEleven through a direct-first Zyte fallback, keep CU inventory connected with an explicit degraded response when all known routes are blocked, and cover every convenience-store surface with sequential health checks. + +**Architecture:** Add one shared JSON transport that retries only origin `400`, `403`, and `429` responses through Zyte while preserving method, headers, body, and service tags. Inject the Worker Zyte binding into CU and SevenEleven REST handlers and MCP tool factories. CU converts the currently verified origin/Zyte stock block into typed unavailable data; SevenEleven uses the verified Zyte POST path for normal results. + +**Tech Stack:** TypeScript, Hono, Cloudflare Workers, MCP SDK, Vitest, Zyte Extract API, GitHub Actions + +--- + +## File Map + +- Create `src/utils/zyteJsonFallback.ts`: direct-first JSON transport and Zyte response validation. +- Create `tests/utils/zyteJsonFallback.test.ts`: transport fallback decision and error coverage. +- Modify `src/services/cu/client.ts`: use the transport for JSON calls and expose typed stock availability. +- Modify `src/services/cu/index.ts`: accept Worker bindings and inject them into MCP tools. +- Modify `src/services/cu/tools/findNearbyStores.ts`: inject Zyte key. +- Modify `src/services/cu/tools/checkInventory.ts`: inject Zyte/Maps keys and return unavailable stock without throwing. +- Modify `src/api/handlers.ts`: pass the Zyte key to CU stock and return availability metadata. +- Modify `src/services/seveneleven/client.ts`: use the shared transport and carry `zyteApiKey`. +- Modify `src/services/seveneleven/productKeyword.ts`: carry `zyteApiKey` through keyword variants. +- Modify `src/services/seveneleven/inventory.ts`: use the shared transport for real stock. +- Modify `src/services/seveneleven/index.ts` and `src/services/seveneleven/tools/*.ts`: inject Zyte key into all MCP tools. +- Modify `src/api/sevenelevenHandlers.ts`: pass the Worker key into all REST calls. +- Modify `src/index.ts`: create CU and SevenEleven services with request bindings. +- Modify focused CU, SevenEleven, handler, service, app, health, and smoke tests. +- Modify `src/api/healthCheckDefinitions.ts` and `scripts/ops/cli-smoke.ts`: add missing convenience-store checks. + +### Task 1: Direct-first Zyte JSON transport + +**Files:** +- Create: `src/utils/zyteJsonFallback.ts` +- Create: `tests/utils/zyteJsonFallback.test.ts` + +- [ ] **Step 1: Write failing transport tests** + +Cover direct success, non-block errors, each blocked status, preserved POST body/headers, Zyte target status errors, empty bodies, invalid JSON, and Zyte account errors: + +```ts +it.each([400, 403, 429])('origin %s uses Zyte once', async (status) => { + mockFetch + .mockResolvedValueOnce(new Response('blocked', { status })) + .mockResolvedValueOnce(zyteJsonResponse({ success: true })); + + await expect( + fetchJsonWithZyteFallback('https://example.com/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"query":"coffee"}', + zyteApiKey: 'test-key', + zyteTags: { service: 'test' }, + }), + ).resolves.toEqual({ success: true }); + + expect(mockFetch).toHaveBeenCalledTimes(2); +}); +``` + +- [ ] **Step 2: Run the test and confirm RED** + +Run: + +```bash +npx vitest run tests/utils/zyteJsonFallback.test.ts --maxWorkers=1 --no-file-parallelism +``` + +Expected: FAIL because `src/utils/zyteJsonFallback.ts` does not exist. + +- [ ] **Step 3: Implement the minimal transport** + +Use `HttpError`, `fetchJson`, `requestByZyte`, and `decodeZyteHttpBody`. Only fallback on the blocked status allowlist: + +```ts +const ZYTE_FALLBACK_STATUSES = new Set([400, 403, 429]); + +export interface ZyteJsonFallbackOptions extends FetchOptions { + zyteApiKey?: string; + zyteTags?: Record; +} + +export async function fetchJsonWithZyteFallback( + url: string, + options: ZyteJsonFallbackOptions = {}, +): Promise { + const { zyteApiKey, zyteTags, ...directOptions } = options; + try { + return await fetchJson(url, directOptions); + } catch (error) { + if (!(error instanceof HttpError) || !ZYTE_FALLBACK_STATUSES.has(error.status)) { + throw error; + } + } + + const method = normalizeSupportedMethod(directOptions.method); + const result = await requestByZyte({ + apiKey: zyteApiKey, + url, + method, + timeout: directOptions.timeout, + headers: toZyteHeaders(directOptions.headers), + bodyText: typeof directOptions.body === 'string' ? directOptions.body : undefined, + tags: zyteTags, + }); + assertSuccessfulTarget(result); + return decodeZyteHttpBody(result); +} +``` + +- [ ] **Step 4: Run the test and confirm GREEN** + +Run the Task 1 command again. Expected: all transport tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/zyteJsonFallback.ts tests/utils/zyteJsonFallback.test.ts +git commit -m "feat: 차단 응답용 Zyte JSON 폴백 추가" +``` + +### Task 2: CU REST and MCP degraded stock recovery + +**Files:** +- Modify: `src/services/cu/client.ts` +- Modify: `src/services/cu/index.ts` +- Modify: `src/services/cu/tools/findNearbyStores.ts` +- Modify: `src/services/cu/tools/checkInventory.ts` +- Modify: `src/api/handlers.ts` +- Modify: `src/index.ts` +- Test: `tests/services/cu/client.test.ts` +- Test: `tests/services/cu/index.test.ts` +- Test: `tests/services/cu/tools/checkInventory.test.ts` +- Test: `tests/services/cu/tools/findNearbyStores.test.ts` +- Test: `tests/api/cu-handlers.test.ts` +- Test: `tests/app/app-api-cu.test.ts` + +- [ ] **Step 1: Write failing CU tests** + +Assert that stock origin `403` invokes Zyte, successful Zyte JSON is normalized, a Zyte `520 Website Ban` becomes unavailable data, and the Worker key reaches REST and MCP paths: + +```ts +expect(result).toEqual({ + available: false, + unavailableReason: expect.stringContaining('Website Ban'), + totalCount: 0, + spellModifyYn: 'N', + items: [], +}); +``` + +Also assert direct `200` makes no Zyte call and unrelated `500` still throws. + +- [ ] **Step 2: Run focused CU tests and confirm RED** + +```bash +npx vitest run tests/services/cu/client.test.ts tests/services/cu/index.test.ts tests/services/cu/tools/checkInventory.test.ts tests/services/cu/tools/findNearbyStores.test.ts tests/api/cu-handlers.test.ts tests/app/app-api-cu.test.ts --maxWorkers=1 --no-file-parallelism +``` + +Expected: failures for missing availability data and missing key injection. + +- [ ] **Step 3: Implement CU transport and typed unavailable result** + +Change `requestCuJson` to accept `RequestOptions`, use the shared transport, and tag requests: + +```ts +return fetchJsonWithZyteFallback(`${CU_API.BASE_URL}${path}`, { + method: 'POST', + timeout: options.timeout, + headers: CU_DEFAULT_HEADERS, + body: JSON.stringify(body), + zyteApiKey: options.apiKey, + zyteTags: { service: 'cu' }, +}); +``` + +Extend stock results with: + +```ts +interface CuStockResult { + available: boolean; + unavailableReason: string | null; + totalCount: number; + spellModifyYn: string; + items: CuStockItem[]; +} +``` + +Keep the optional stock-display warmup direct-only because its failure is ignored and a paid fallback cannot share session state with the main request. + +Only convert verified block signatures (`HttpError` 400/403/429 or Zyte 520 Website Ban) to `available: false`; preserve all other errors. + +- [ ] **Step 4: Inject CU bindings into REST and MCP** + +Use service options instead of `process.env`: + +```ts +export interface CuServiceOptions { + zyteApiKey?: string; + googleMapsApiKey?: string; +} + +export function createCuService(options: CuServiceOptions = {}): ServiceProvider { + return new CuService(options); +} +``` + +Pass `c.env.ZYTE_API_KEY` to `fetchCuStock`. Return `inventory.available` and `inventory.unavailableReason` in both REST and MCP data. + +- [ ] **Step 5: Run focused CU tests and confirm GREEN** + +Run the Task 2 command again. Expected: all focused CU tests pass. + +- [ ] **Step 6: Check touched source sizes** + +```bash +npm run check:source-lines +``` + +Expected: every source file remains at or below 450 lines. + +- [ ] **Step 7: Commit** + +```bash +git add src/services/cu src/api/handlers.ts src/index.ts tests/services/cu tests/api/cu-handlers.test.ts tests/app/app-api-cu.test.ts +git commit -m "fix: CU 재고 차단 시 연결 유지" +``` + +### Task 3: SevenEleven client fallback + +**Files:** +- Modify: `src/services/seveneleven/client.ts` +- Modify: `src/services/seveneleven/productKeyword.ts` +- Test: `tests/services/seveneleven/client.test.ts` +- Test: `tests/services/seveneleven/productKeyword.test.ts` + +- [ ] **Step 1: Write failing client tests** + +For product, store, popword, product-meta, and catalog transports, return origin `403` then a Zyte `200` Base64 response. Assert normalized results and the second request body contains the original URL, POST body, and `tags.service === "seveneleven"`. + +- [ ] **Step 2: Run focused client tests and confirm RED** + +```bash +npx vitest run tests/services/seveneleven/client.test.ts tests/services/seveneleven/productKeyword.test.ts --maxWorkers=1 --no-file-parallelism +``` + +Expected: origin `403` errors because no fallback exists. + +- [ ] **Step 3: Implement client fallback and option propagation** + +Export and reuse: + +```ts +export interface SevenElevenRequestOptions { + timeout?: number; + zyteApiKey?: string; +} +``` + +Replace direct JSON calls with: + +```ts +return fetchJsonWithZyteFallback>(url, { + ...SEVENELEVEN_DEFAULT_FETCH_OPTIONS, + method, + retryUnsafeMethods: method === 'POST', + timeout, + headers: SEVENELEVEN_DEFAULT_HEADERS, + body: method === 'POST' ? JSON.stringify(body || {}) : undefined, + zyteApiKey, + zyteTags: { service: 'seveneleven' }, +}); +``` + +Carry `zyteApiKey` through keyword variant searches and catalog requests. + +- [ ] **Step 4: Run focused client tests and confirm GREEN** + +Run the Task 3 command again. Expected: all focused SevenEleven client tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/services/seveneleven/client.ts src/services/seveneleven/productKeyword.ts tests/services/seveneleven/client.test.ts tests/services/seveneleven/productKeyword.test.ts +git commit -m "fix: 세븐일레븐 조회에 Zyte 폴백 적용" +``` + +### Task 4: SevenEleven inventory, REST, and MCP binding injection + +**Files:** +- Modify: `src/services/seveneleven/inventory.ts` +- Modify: `src/services/seveneleven/index.ts` +- Modify: `src/services/seveneleven/tools/checkInventory.ts` +- Modify: `src/services/seveneleven/tools/getCatalogSnapshot.ts` +- Modify: `src/services/seveneleven/tools/getSearchPopwords.ts` +- Modify: `src/services/seveneleven/tools/searchProducts.ts` +- Modify: `src/services/seveneleven/tools/searchStores.ts` +- Modify: `src/api/sevenelevenHandlers.ts` +- Modify: `src/index.ts` +- Test: `tests/services/seveneleven/index.test.ts` +- Test: `tests/services/seveneleven/tools/*.test.ts` +- Test: `tests/api/seveneleven-handlers.test.ts` +- Test: `tests/app/app-api-seveneleven.test.ts` + +- [ ] **Step 1: Write failing binding and inventory tests** + +Assert a configured key is included in real-stock Zyte fallback and every REST/MCP entry point: + +```ts +const service = createSevenElevenService({ zyteApiKey: 'worker-key' }); +await service.getTools().find((tool) => tool.name === 'seveneleven_search_products')!.handler({ + query: '커피', +}); +expect(readZyteRequest(mockFetch)).toMatchObject({ + tags: { service: 'seveneleven' }, +}); +``` + +- [ ] **Step 2: Run focused tests and confirm RED** + +```bash +npx vitest run tests/services/seveneleven/index.test.ts tests/services/seveneleven/tools tests/api/seveneleven-handlers.test.ts tests/app/app-api-seveneleven.test.ts --maxWorkers=1 --no-file-parallelism +``` + +Expected: configured key is not propagated. + +- [ ] **Step 3: Implement inventory fallback** + +Replace `tryStockApi` direct transport with `fetchJsonWithZyteFallback`, passing `options.zyteApiKey` and the service tag. Preserve the existing graceful stock-error behavior for encrypted real-stock failures. + +- [ ] **Step 4: Inject key through REST and MCP** + +Add `SevenElevenServiceOptions`, capture it in the provider, and pass the key to every tool factory without exposing it in tool schemas: + +```ts +export function createSevenElevenService( + options: SevenElevenServiceOptions = {}, +): ServiceProvider { + return new SevenElevenService(options); +} +``` + +Update `src/index.ts` factories: + +```ts +() => createSevenElevenService({ zyteApiKey: bindings?.ZYTE_API_KEY }), +() => createCuService({ + zyteApiKey: bindings?.ZYTE_API_KEY, + googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY, +}), +``` + +- [ ] **Step 5: Run focused tests and confirm GREEN** + +Run the Task 4 command again. Expected: all SevenEleven REST, MCP, and inventory tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/services/seveneleven src/api/sevenelevenHandlers.ts src/index.ts tests/services/seveneleven tests/api/seveneleven-handlers.test.ts tests/app/app-api-seveneleven.test.ts +git commit -m "fix: 세븐일레븐 Worker 키 전달 복구" +``` + +### Task 5: Complete convenience-store health coverage + +**Files:** +- Modify: `src/api/healthCheckDefinitions.ts` +- Modify: `scripts/ops/cli-smoke.ts` +- Test: `tests/api/health-checks.test.ts` +- Test: `tests/app/app-health-checks.test.ts` +- Test: `tests/scripts/cli-smoke.test.ts` + +- [ ] **Step 1: Write failing health-definition tests** + +Require these check IDs: + +```ts +expect(ids).toEqual(expect.arrayContaining([ + 'cu.stores', + 'cu.inventory', + 'gs25.stores', + 'gs25.products', + 'gs25.inventory', + 'seveneleven.stores', + 'seveneleven.products', + 'seveneleven.inventory', + 'seveneleven.popwords', + 'emart24.stores', + 'emart24.products', + 'emart24.inventory', +])); +``` + +Require a CU CLI smoke command and sequential health execution. + +- [ ] **Step 2: Run focused health tests and confirm RED** + +```bash +npx vitest run tests/api/health-checks.test.ts tests/app/app-health-checks.test.ts tests/scripts/cli-smoke.test.ts --maxWorkers=1 --no-file-parallelism +``` + +Expected: missing store/popword definitions and CU CLI smoke scenario. + +- [ ] **Step 3: Add missing definitions and smoke command** + +Use `limit=1`/`size=1`, stable Korean keywords, existing collection keys, and upstream degraded patterns. Add: + +```ts +{ + id: 'seveneleven.stores', + service: 'seveneleven', + target: 'stores', + mode: 'quick', + path: '/api/seveneleven/stores?keyword=%EA%B0%95%EB%82%A8&limit=1', + collectionKey: 'stores', + requiredFields: ['storeCode', 'storeName', 'name'], + degradedFailurePatterns: SEVENELEVEN_UPSTREAM_403_PATTERNS, +} +``` + +Add equivalent Emart24 store and SevenEleven popword checks. Add `cu-stores 강남 --limit 1 --json` to CLI smoke. + +- [ ] **Step 4: Run focused health tests and confirm GREEN** + +Run the Task 5 command again. Expected: all focused health/smoke tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/api/healthCheckDefinitions.ts scripts/ops/cli-smoke.ts tests/api/health-checks.test.ts tests/app/app-health-checks.test.ts tests/scripts/cli-smoke.test.ts +git commit -m "test: 편의점 전체 운영 점검 범위 확장" +``` + +### Task 6: Sequential repository verification + +**Files:** +- Verify only + +- [ ] **Step 1: Run format check** + +```bash +npm run format:check +``` + +- [ ] **Step 2: Run ESLint** + +```bash +npm run lint +``` + +- [ ] **Step 3: Run Biome** + +```bash +npm run lint:biome +``` + +- [ ] **Step 4: Run typecheck** + +```bash +npm run typecheck +``` + +- [ ] **Step 5: Run source line limit** + +```bash +npm run check:source-lines +``` + +- [ ] **Step 6: Run all tests sequentially** + +```bash +npx vitest run --maxWorkers=1 --no-file-parallelism +``` + +- [ ] **Step 7: Run coverage sequentially** + +```bash +npx vitest run --coverage --maxWorkers=1 --no-file-parallelism +``` + +- [ ] **Step 8: Build** + +```bash +npm run build +``` + +- [ ] **Step 9: Inspect the final diff** + +```bash +git diff --check +git status --short +git diff origin/main...HEAD +``` + +Expected: no whitespace errors, no accidental secret material, and only scoped recovery changes. + +### Task 7: PR, CI/CD, and production verification + +**Files:** +- No source changes expected + +- [ ] **Step 1: Push and open PR** + +```bash +git push -u origin fix/convenience-store-recovery +gh pr create --repo hmmhmmhm/daiso-mcp --base main --head fix/convenience-store-recovery +``` + +- [ ] **Step 2: Wait for every required CI job** + +```bash +gh pr checks --repo hmmhmmhm/daiso-mcp --watch +``` + +Expected: all required jobs pass. + +- [ ] **Step 3: Merge and wait for deploy** + +```bash +gh pr merge --repo hmmhmmhm/daiso-mcp --squash --delete-branch +``` + +Watch the deploy workflow until it completes successfully. + +- [ ] **Step 4: Run production checks one at a time** + +Run CU store, CU inventory, GS25 product/store/inventory, SevenEleven product/store/inventory/popword, and Emart24 product/store/inventory sequentially. Expected: + +- CU store: success +- CU inventory: success envelope with either live items or `available: false` +- GS25: success +- SevenEleven: success through direct or Zyte +- Emart24: success + +- [ ] **Step 5: Dispatch full health workflow** + +```bash +gh workflow run health-checks.yml --repo hmmhmmhm/daiso-mcp --ref main +``` + +Expected: convenience checks are `ok`, except CU inventory may be `degraded` with an explicit upstream-unavailable reason; no convenience check is `fail`. diff --git a/docs/superpowers/specs/2026-07-27-convenience-store-recovery-design.md b/docs/superpowers/specs/2026-07-27-convenience-store-recovery-design.md new file mode 100644 index 00000000..32b5f55e --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-convenience-store-recovery-design.md @@ -0,0 +1,120 @@ +# 편의점 Zyte 폴백 및 헬스체크 복구 설계 + +## 배경 + +2026년 7월 27일 운영 점검에서 편의점 연동 상태를 다음과 같이 확인했다. + +- 이마트24의 매장·상품·재고 조회는 정상이다. +- GS25는 활성 Zyte 키 반영 후 매장·상품·재고 조회가 복구됐다. +- CU 매장 검색은 원본 웹 요청이 차단될 때 기존 Zyte 폴백으로 복구되지만, 재고 검색은 원본 JSON API의 `400 Request Blocked`를 그대로 반환한다. 활성 키로 재검증한 결과 Zyte HTTP 요청도 `520 Website Ban`, Zyte 브라우저 내부 요청도 `403`이어서 현재 공개 경로만으로 재고 원문을 복구할 수 없다. +- 세븐일레븐의 매장·상품·재고·인기 검색어 요청은 Imperva `403 Forbidden`을 받으며 Zyte 폴백이 없다. + +운영 비용을 억제하면서 차단된 요청만 복구하고, 편의점의 모든 주요 기능을 정기 헬스체크에서 확인하는 것이 목표다. + +## 범위와 성공 조건 + +### 범위 + +- CU JSON API 요청의 선택적 Zyte 폴백 +- 세븐일레븐 JSON API 요청의 선택적 Zyte 폴백 +- Worker 바인딩의 `ZYTE_API_KEY`를 해당 클라이언트까지 전달 +- 편의점 주요 API를 포괄하는 운영 헬스체크와 CLI 스모크 시나리오 보강 +- 폴백과 오류 전파에 대한 회귀 테스트 + +### 성공 조건 + +- 원본 요청이 성공하면 Zyte를 호출하지 않는다. +- 원본 요청이 `400`, `403`, `429`로 차단될 때만 Zyte를 한 번 시도한다. +- Zyte 응답의 대상 상태 코드가 성공이고 JSON 본문이 유효하면 기존 정규화 로직을 그대로 사용한다. +- Zyte 인증, 계정 정지, 대상 오류 또는 잘못된 JSON은 진단 가능한 오류로 반환한다. +- CU 재고는 직접 호출과 Zyte가 모두 명시적으로 차단될 때 HTTP 500 대신 `available: false`와 차단 원인을 담은 성공 envelope를 반환한다. +- CU 재고와 세븐일레븐 매장·상품·재고·인기 검색어가 운영 헬스체크에 포함된다. +- 기존 이마트24와 GS25 동작 및 직접 호출 우선 정책을 회귀시키지 않는다. +- 모든 소스 파일은 프로젝트 정책인 450줄 이하를 유지한다. + +## 검토한 접근 + +### 1. 계정 복구만 수행 + +GS25와 기존 CU 매장 폴백은 복구되지만 CU 재고와 세븐일레븐은 원본 차단이 계속된다. 코드상 누락을 해결하지 못하므로 채택하지 않는다. + +### 2. 직접 호출 우선, 차단 응답에만 Zyte 폴백 + +정상 원본 요청은 무료 경로를 유지하고, 명시적인 차단 응답만 Zyte로 재시도한다. 비용과 복구 가능성의 균형이 가장 좋아 채택한다. + +### 3. Zyte 우선 호출 + +구현은 단순하지만 모든 요청에 비용이 발생하고, 과거 사용량 급증 문제를 재발시킬 가능성이 높아 채택하지 않는다. + +## 설계 + +### 공통 전송 흐름 + +CU와 세븐일레븐 클라이언트는 기존 헤더, 메서드, 요청 본문을 사용해 원본 API를 먼저 호출한다. + +1. 원본이 성공하면 기존 JSON 파싱 결과를 반환한다. +2. 원본이 `400`, `403`, `429`이면 같은 URL, 메서드, 헤더, 본문을 `requestByZyte`로 전달한다. +3. Zyte의 대상 응답 상태가 `2xx`이고 본문이 있으면 Base64 본문을 JSON으로 디코딩한다. +4. 그 외 원본 오류는 기존 오류를 유지한다. +5. 폴백 자체가 실패하면 Zyte 오류를 숨기지 않고 서비스·원인을 식별할 수 있는 메시지로 전달한다. 단, CU 재고의 확인된 차단 상태는 호출 전체를 실패시키지 않고 degraded 데이터로 변환한다. + +공통화가 두 서비스의 의미를 흐리지 않는 범위에서 작은 전송 유틸리티를 사용한다. 서비스별 요청 형식과 응답 정규화는 각 클라이언트에 남긴다. + +### CU + +- `requestCuJson`이 `RequestOptions.apiKey`를 받고 차단 응답에 Zyte를 시도한다. +- `fetchCuStores`와 본 재고 요청은 같은 옵션을 끝까지 전달한다. +- API 핸들러는 `c.env.ZYTE_API_KEY`를 재고 조회에도 전달한다. +- 선택적 워밍업은 실패해도 무시되는 호출이므로 원본 직결만 사용해 불필요한 Zyte 비용을 막는다. +- 워밍업 요청 실패는 기존처럼 본 요청을 막지 않지만, 본 재고 요청의 폴백 실패는 호출자에게 전달한다. +- 본 재고 요청도 확인된 `400`/`403`/Zyte `520` 차단이면 빈 항목, `available: false`, 비민감 오류 설명을 반환한다. 매장 검색이 가능하면 매장 결과는 계속 제공한다. +- MCP 도구에는 요청 컨텍스트의 Zyte 및 Google Maps 바인딩을 생성자 옵션으로 주입한다. + +### 세븐일레븐 + +- 클라이언트와 재고 모듈의 요청 옵션에 `zyteApiKey`를 추가한다. +- 상품, 매장, 인기 검색어, 상품 메타, 실재고 요청에서 동일한 직접 호출 우선 정책을 사용한다. +- API 핸들러는 Worker 바인딩의 키를 각 서비스 함수에 전달한다. +- MCP 도구에도 같은 Worker 바인딩을 서비스 생성자 옵션으로 주입한다. +- 카탈로그 스냅샷은 현재 여러 선택적 요청을 합치는 기존 특성을 유지하되, 내부 JSON 요청에는 같은 옵션을 적용한다. + +### 운영 헬스체크 + +기존 상품 중심 체크에 누락된 편의점 기능을 추가한다. + +- CU: 매장, 재고 +- GS25: 매장, 상품, 재고 +- 세븐일레븐: 매장, 상품, 재고, 인기 검색어 +- 이마트24: 매장, 상품, 재고 + +카탈로그는 빈 배열도 정상 응답이 될 수 있어 연결성 신호로는 사용하지 않는다. 기존의 알려진 upstream 차단 패턴은 폴백 실패 시 degraded 진단을 위해 유지하되, 정상 폴백은 `pass`로 기록한다. + +CLI 스모크는 최소 한 시나리오만 있던 서비스에 CU를 추가하고, API 헬스체크가 세부 엔드포인트를 담당하도록 역할을 나눈다. 테스트와 운영 호출은 사용자 요청에 따라 병렬화하지 않는다. + +## 오류 및 비용 제어 + +- Zyte 호출은 차단 상태에만 발생한다. +- 연결 오류나 일반 `5xx`는 기존 재시도 정책을 따르며 곧바로 Zyte 비용을 발생시키지 않는다. +- 비밀키는 Worker/GitHub Secret으로만 관리하고 로그, 응답, 저장소에 기록하지 않는다. +- 기존 일일 IP별 검색 제한과 캐시 정책은 변경하지 않는다. +- 헬스체크는 작은 페이지 크기와 고정 검색어를 사용해 요청량을 제한한다. + +## 테스트 전략 + +테스트 주도 방식으로 다음 실패 사례를 먼저 추가한다. + +1. 직접 호출 성공 시 Zyte 미호출 +2. `400`, `403`, `429`에서 Zyte 폴백 성공 +3. Zyte 대상 오류, 빈 본문, 잘못된 JSON 및 계정 오류 전파 +4. CU 재고까지 API 키 전달 및 확인된 차단 시 degraded 응답 +5. 세븐일레븐 상품·매장·재고·인기 검색어까지 API 키 전달 +6. 편의점 전체 헬스체크 정의와 degraded 판정 +7. CLI 스모크의 CU 시나리오 + +관련 테스트 파일을 하나씩 실행한 뒤 전체 단위 테스트, 커버리지, 린트, 타입 검사, 빌드, 소스 줄 수 검사를 순차 실행한다. 배포 후에도 편의점별 엔드포인트를 한 번에 하나씩 검증한다. + +## 배포 및 롤백 + +구현 브랜치에서 검증 후 PR을 만들고 CI가 모두 통과하면 `main`에 병합한다. 기존 CI/CD가 Worker를 배포한 뒤 운영 헬스체크를 실행한다. + +문제가 발생하면 폴백을 추가한 커밋을 되돌릴 수 있다. 직접 호출 경로와 서비스별 정규화는 유지되므로 롤백 시 기존 동작으로 복귀한다. diff --git a/scripts/ops/cli-smoke.ts b/scripts/ops/cli-smoke.ts index 9dd4b447..50cf45a1 100644 --- a/scripts/ops/cli-smoke.ts +++ b/scripts/ops/cli-smoke.ts @@ -5,6 +5,7 @@ import { spawn } from 'node:child_process'; import path from 'node:path'; import { + CU_UPSTREAM_BLOCK_PATTERNS, EMART24_UPSTREAM_403_PATTERNS, SEVENELEVEN_UPSTREAM_403_PATTERNS, } from '../../src/api/healthCheckDefinitions.js'; @@ -20,6 +21,7 @@ type WriteFn = (message: string) => void; type Validator = (stdout: string, stderr: string) => string | null; type SmokeService = | 'daiso' + | 'cu' | 'gs25' | 'seveneleven' | 'emart24' @@ -120,6 +122,13 @@ export const CLI_SMOKE_COMMANDS: CliSmokeCommand[] = [ expectedExitCode: 1, validate: validateStderrContains('알 수 없는 옵션: --store'), }, + { + service: 'cu', + scenario: 'CU 매장 검색', + args: ['cu-stores', '강남', '--limit', '1', '--json'], + degradedFailurePatterns: CU_UPSTREAM_BLOCK_PATTERNS, + validate: (stdout) => validateApiEnvelope(stdout, expectDataField('keyword', '강남')), + }, { service: 'gs25', scenario: 'GS25 상품명 검색', diff --git a/src/api/handlers.ts b/src/api/handlers.ts index 2ecd45d0..1cd50821 100644 --- a/src/api/handlers.ts +++ b/src/api/handlers.ts @@ -293,6 +293,7 @@ export async function handleCuCheckInventory(c: ApiContext) { }, { timeout: 15000, + apiKey: c.env?.ZYTE_API_KEY, }, ); @@ -381,6 +382,8 @@ export async function handleCuCheckInventory(c: ApiContext) { stores: storeResult.stores.slice(0, storeLimit), }, inventory: { + available: stockResult.available, + unavailableReason: stockResult.unavailableReason, totalCount: stockResult.totalCount, spellModifyYn: stockResult.spellModifyYn, items: stockResult.items, diff --git a/src/api/healthCheckDefinitions.ts b/src/api/healthCheckDefinitions.ts index 001e9ea3..87b11b66 100644 --- a/src/api/healthCheckDefinitions.ts +++ b/src/api/healthCheckDefinitions.ts @@ -63,6 +63,16 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [ requiredFields: ['pluCd', 'goodsName', 'itemName', 'name'], degradedFailurePatterns: EMART24_UPSTREAM_403_PATTERNS, }, + { + id: 'emart24.stores', + service: 'emart24', + target: 'stores', + mode: 'quick', + path: '/api/emart24/stores?keyword=%EA%B0%95%EB%82%A8&limit=1', + collectionKey: 'stores', + requiredFields: ['storeCode', 'storeName', 'name'], + degradedFailurePatterns: EMART24_UPSTREAM_403_PATTERNS, + }, { id: 'gs25.products', service: 'gs25', @@ -92,6 +102,24 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [ requiredFields: ['itemCode', 'itemName', 'productNo', 'name'], degradedFailurePatterns: SEVENELEVEN_UPSTREAM_403_PATTERNS, }, + { + id: 'seveneleven.stores', + service: 'seveneleven', + target: 'stores', + mode: 'quick', + path: '/api/seveneleven/stores?keyword=%EA%B0%95%EB%82%A8&limit=1', + collectionKey: 'stores', + requiredFields: ['storeCode', 'storeName', 'name'], + degradedFailurePatterns: SEVENELEVEN_UPSTREAM_403_PATTERNS, + }, + { + id: 'seveneleven.popwords', + service: 'seveneleven', + target: 'popwords', + mode: 'quick', + path: '/api/seveneleven/popwords?label=home', + degradedFailurePatterns: SEVENELEVEN_UPSTREAM_403_PATTERNS, + }, { id: 'lottemart.products', service: 'lottemart', diff --git a/src/api/sevenelevenHandlers.ts b/src/api/sevenelevenHandlers.ts index 46dd35f9..252269c6 100644 --- a/src/api/sevenelevenHandlers.ts +++ b/src/api/sevenelevenHandlers.ts @@ -43,12 +43,15 @@ export async function handleSevenElevenSearchProducts(c: ApiContext) { } try { - const result = await searchSevenElevenProducts({ - query, - page, - size, - sort, - }); + const result = await searchSevenElevenProducts( + { + query, + page, + size, + sort, + }, + { zyteApiKey: c.env?.ZYTE_API_KEY }, + ); return successResponse( c, @@ -85,10 +88,13 @@ export async function handleSevenElevenSearchStores(c: ApiContext) { } try { - const result = await fetchSevenElevenStoresByKeyword({ - keyword, - limit: safeLimit, - }); + const result = await fetchSevenElevenStoresByKeyword( + { + keyword, + limit: safeLimit, + }, + { zyteApiKey: c.env?.ZYTE_API_KEY }, + ); return successResponse( c, @@ -135,6 +141,7 @@ export async function handleSevenElevenCheckInventory(c: ApiContext) { }, { timeout: safeTimeoutMs, + zyteApiKey: c.env?.ZYTE_API_KEY, }, ); @@ -179,7 +186,9 @@ export async function handleSevenElevenGetSearchPopwords(c: ApiContext) { const label = c.req.query('label') || 'home'; try { - const keywords = await fetchSevenElevenSearchPopwords(label); + const keywords = await fetchSevenElevenSearchPopwords(label, { + zyteApiKey: c.env?.ZYTE_API_KEY, + }); return successResponse(c, { label, @@ -210,6 +219,7 @@ export async function handleSevenElevenGetCatalogSnapshot(c: ApiContext) { const result = await fetchSevenElevenCatalogSnapshot({ includeIssues, includeExhibition, + zyteApiKey: c.env?.ZYTE_API_KEY, }); return successResponse(c, { diff --git a/src/index.ts b/src/index.ts index 071cf550..d7f3d001 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,7 +82,7 @@ const createRegistry = (bindings?: AppBindings) => { googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY, zyteApiKey: bindings?.ZYTE_API_KEY, }), - createSevenElevenService, + () => createSevenElevenService({ zyteApiKey: bindings?.ZYTE_API_KEY }), createCompareService, () => createFeedbackService({ @@ -99,7 +99,11 @@ const createRegistry = (bindings?: AppBindings) => { apiKey: bindings?.OPINET_API_KEY, googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY, }), - createCuService, + () => + createCuService({ + zyteApiKey: bindings?.ZYTE_API_KEY, + googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY, + }), createEmart24Service, () => createLotteMartService({ diff --git a/src/services/cu/client.ts b/src/services/cu/client.ts index 2dff2cb5..ae9f4cf7 100644 --- a/src/services/cu/client.ts +++ b/src/services/cu/client.ts @@ -2,8 +2,9 @@ * CU API 클라이언트 */ -import { fetchJson } from '../../utils/http.js'; +import { fetchJson, HttpError } from '../../utils/http.js'; import { decodeBase64, requestByZyte } from '../../utils/zyte.js'; +import { fetchJsonWithZyteFallback } from '../../utils/zyteJsonFallback.js'; import { CU_API } from './api.js'; import type { CuStockItem, CuStockMainResponse, CuStore, CuStoreResponse } from './types.js'; @@ -105,12 +106,18 @@ function toStoreAddress(raw: { return [raw.addrFst || '', raw.addrDetail || ''].join(' ').trim(); } -async function requestCuJson(path: string, body: Record, timeout = 15000): Promise { - return fetchJson(`${CU_API.BASE_URL}${path}`, { +async function requestCuJson( + path: string, + body: Record, + options: RequestOptions = {}, +): Promise { + return fetchJsonWithZyteFallback(`${CU_API.BASE_URL}${path}`, { method: 'POST', - timeout, + timeout: options.timeout, headers: CU_DEFAULT_HEADERS, body: JSON.stringify(body), + zyteApiKey: options.apiKey, + zyteTags: { service: 'cu' }, }); } @@ -339,7 +346,7 @@ export async function fetchCuStores( onItemNo: params.onItemNo || '', }; - const body = await requestCuJson(CU_API.STORE_PATH, payload, timeout); + const body = await requestCuJson(CU_API.STORE_PATH, payload, options); const stores = (body.storeList || []).map((store) => normalizeCuStore(store)); return { @@ -353,8 +360,12 @@ export async function fetchCuStores( * 정책 변경 시 사전 호출이 필요한 경우를 대비한 워밍업 요청입니다. */ export async function primeCuStockDisplay(options: RequestOptions = {}): Promise { - const { timeout = 15000 } = options; - await requestCuJson(CU_API.STOCK_DISPLAY_PATH, {}, timeout); + await fetchJson(`${CU_API.BASE_URL}${CU_API.STOCK_DISPLAY_PATH}`, { + method: 'POST', + timeout: options.timeout, + headers: CU_DEFAULT_HEADERS, + body: '{}', + }); } /** @@ -363,11 +374,15 @@ export async function primeCuStockDisplay(options: RequestOptions = {}): Promise export async function fetchCuStock( params: FetchCuStockParams, options: RequestOptions = {}, -): Promise<{ totalCount: number; spellModifyYn: string; items: CuStockItem[] }> { - const { timeout = 15000 } = options; - +): Promise<{ + available: boolean; + unavailableReason: string | null; + totalCount: number; + spellModifyYn: string; + items: CuStockItem[]; +}> { try { - await primeCuStockDisplay({ timeout }); + await primeCuStockDisplay(options); } catch { // 사전 워밍업 실패는 본 검색으로 재시도합니다. } @@ -381,7 +396,28 @@ export async function fetchCuStock( searchSort: params.searchSort, }; - const body = await requestCuJson(CU_API.STOCK_MAIN_PATH, payload, timeout); + let body: CuStockMainResponse; + try { + body = await requestCuJson(CU_API.STOCK_MAIN_PATH, payload, options); + } catch (error) { + const message = error instanceof Error ? error.message : ''; + const originBlocked = error instanceof HttpError && [400, 403, 429].includes(error.status); + const zyteBlocked = + message.includes('Zyte API 호출 실패: 520') || + message.includes('Zyte 대상 응답 실패: 520'); + if (originBlocked || zyteBlocked) { + return { + available: false, + unavailableReason: originBlocked + ? `CU 재고 API가 차단되었습니다 (${error.status} Request Blocked).` + : 'CU 재고 API가 차단되었습니다 (Zyte Website Ban 520).', + totalCount: 0, + spellModifyYn: 'N', + items: [], + }; + } + throw error; + } const result = body.data?.stockResult?.result; const rows = result?.rows || []; @@ -400,6 +436,8 @@ export async function fetchCuStock( })); return { + available: true, + unavailableReason: null, totalCount: toNumber(result?.total_count) || items.length, spellModifyYn: body.spellModifyYn || 'N', items, diff --git a/src/services/cu/index.ts b/src/services/cu/index.ts index fb89cc0b..fc74b4dd 100644 --- a/src/services/cu/index.ts +++ b/src/services/cu/index.ts @@ -14,16 +14,29 @@ const CU_METADATA: ServiceMetadata = { description: 'CU 매장 탐색 및 상품 재고 조회 서비스', }; +export interface CuServiceOptions { + zyteApiKey?: string; + googleMapsApiKey?: string; +} + class CuService implements ServiceProvider { readonly metadata = CU_METADATA; + constructor(private readonly options: CuServiceOptions = {}) {} + getTools(): ToolRegistration[] { - return [createFindNearbyStoresTool(), createCheckInventoryTool()]; + return [ + createFindNearbyStoresTool(this.options.zyteApiKey), + createCheckInventoryTool({ + zyteApiKey: this.options.zyteApiKey, + googleMapsApiKey: this.options.googleMapsApiKey, + }), + ]; } } -export function createCuService(): ServiceProvider { - return new CuService(); +export function createCuService(options: CuServiceOptions = {}): ServiceProvider { + return new CuService(options); } export * from './types.js'; diff --git a/src/services/cu/tools/checkInventory.ts b/src/services/cu/tools/checkInventory.ts index 94328dba..aa94cde8 100644 --- a/src/services/cu/tools/checkInventory.ts +++ b/src/services/cu/tools/checkInventory.ts @@ -16,6 +16,13 @@ interface CheckInventoryArgs { searchSort?: string; storeLimit?: number; timeoutMs?: number; + zyteApiKey?: string; + googleMapsApiKey?: string; +} + +interface CuInventoryToolOptions { + zyteApiKey?: string; + googleMapsApiKey?: string; } async function checkInventory(args: CheckInventoryArgs): Promise { @@ -29,6 +36,8 @@ async function checkInventory(args: CheckInventoryArgs): Promise store.address.trim().length > 0)?.address || ''; if (firstAddress.length > 0) { - const googleMapsApiKey = process.env.GOOGLE_MAPS_API_KEY; const geocoded = await geocodeCuAddress(firstAddress, { timeout: timeoutMs, - googleMapsApiKey, + googleMapsApiKey: googleMapsApiKey || process.env.GOOGLE_MAPS_API_KEY, }); if (geocoded) { const hasStockSeed = !!firstStockItem?.itemCode; @@ -86,6 +96,7 @@ async function checkInventory(args: CheckInventoryArgs): Promise Promise, + handler: ((args: CheckInventoryArgs) => + checkInventory({ + ...args, + zyteApiKey: options.zyteApiKey ?? args.zyteApiKey, + googleMapsApiKey: options.googleMapsApiKey ?? args.googleMapsApiKey, + })) as (args: unknown) => Promise, }; } diff --git a/src/services/cu/tools/findNearbyStores.ts b/src/services/cu/tools/findNearbyStores.ts index 39e1d022..1fb2d75a 100644 --- a/src/services/cu/tools/findNearbyStores.ts +++ b/src/services/cu/tools/findNearbyStores.ts @@ -12,6 +12,7 @@ interface FindNearbyStoresArgs { keyword?: string; limit?: number; timeoutMs?: number; + zyteApiKey?: string; } async function findNearbyStores(args: FindNearbyStoresArgs): Promise { @@ -21,6 +22,7 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise Promise, + handler: ((args: FindNearbyStoresArgs) => + findNearbyStores({ ...args, zyteApiKey: zyteApiKey ?? args.zyteApiKey })) as ( + args: unknown, + ) => Promise, }; } diff --git a/src/services/seveneleven/client.ts b/src/services/seveneleven/client.ts index aa8eed35..57006ce8 100644 --- a/src/services/seveneleven/client.ts +++ b/src/services/seveneleven/client.ts @@ -3,7 +3,7 @@ */ /* c8 ignore start */ -import { fetchJson } from '../../utils/http.js'; +import { fetchJsonWithZyteFallback } from '../../utils/zyteJsonFallback.js'; import { SEVENELEVEN_API } from './api.js'; import type { SevenElevenApiEnvelope, @@ -16,8 +16,9 @@ import type { SevenElevenStoreSearchResult, } from './types.js'; -interface RequestOptions { +export interface SevenElevenRequestOptions { timeout?: number; + zyteApiKey?: string; } interface SearchProductsParams { @@ -182,24 +183,26 @@ async function requestSevenElevenJson( path: string, method: 'GET' | 'POST', body: unknown, - options: RequestOptions = {}, + options: SevenElevenRequestOptions = {}, ): Promise> { - const { timeout = 15000 } = options; + const { timeout = 15000, zyteApiKey } = options; const url = `${SEVENELEVEN_API.BASE_URL}${path}`; - return fetchJson>(url, { + return fetchJsonWithZyteFallback>(url, { ...SEVENELEVEN_DEFAULT_FETCH_OPTIONS, method, retryUnsafeMethods: method === 'POST', timeout, headers: SEVENELEVEN_DEFAULT_HEADERS, body: method === 'POST' ? JSON.stringify(body || {}) : undefined, + zyteApiKey, + zyteTags: { service: 'seveneleven' }, }); } export async function searchSevenElevenProducts( params: SearchProductsParams, - options: RequestOptions = {}, + options: SevenElevenRequestOptions = {}, ): Promise { const { query, page = 1, size = 20 } = params; const pageNo = Math.max(Math.trunc(page) - 1, 0); @@ -244,7 +247,7 @@ export async function searchSevenElevenProducts( export async function fetchSevenElevenStoresByKeyword( params: SearchStoresParams, - options: RequestOptions = {}, + options: SevenElevenRequestOptions = {}, ): Promise { const { keyword, limit = 20 } = params; const query = normalizeStoreKeyword(keyword) || keyword.trim(); @@ -285,7 +288,7 @@ export async function fetchSevenElevenStoresByKeyword( export async function fetchSevenElevenSearchPopwords( label = 'home', - options: RequestOptions = {}, + options: SevenElevenRequestOptions = {}, ): Promise { const encodedLabel = encodeURIComponent(label); const response = await requestSevenElevenJson( @@ -337,13 +340,13 @@ export async function fetchSevenElevenSearchPopwords( export async function fetchSevenElevenStockProductMeta( itemCode: string, - options: RequestOptions = {}, + options: SevenElevenRequestOptions = {}, ): Promise { const { timeout = 15000 } = options; const encodedItemCode = encodeURIComponent(itemCode.trim()); const url = `${SEVENELEVEN_API.BASE_URL}${SEVENELEVEN_API.PRODUCT_SEARCH_STOCK_PATH}?itemCd=${encodedItemCode}`; - const response = await fetchJson(url, { + const response = await fetchJsonWithZyteFallback(url, { ...SEVENELEVEN_DEFAULT_FETCH_OPTIONS, method: 'GET', timeout, @@ -351,6 +354,8 @@ export async function fetchSevenElevenStockProductMeta( Accept: SEVENELEVEN_DEFAULT_HEADERS.Accept, 'User-Agent': SEVENELEVEN_DEFAULT_HEADERS['User-Agent'], }, + zyteApiKey: options.zyteApiKey, + zyteTags: { service: 'seveneleven' }, }); const productMeta = normalizeStockProductMeta(response); @@ -404,17 +409,24 @@ export async function fetchSevenElevenCatalogSnapshot( includeIssues?: boolean; includeExhibition?: boolean; timeout?: number; + zyteApiKey?: string; } = {}, ): Promise { - const { includeIssues = true, includeExhibition = true, timeout = 15000 } = options; + const { includeIssues = true, includeExhibition = true, timeout = 15000, zyteApiKey } = options; const tasks: Array>> = [ - requestSevenElevenJson(SEVENELEVEN_API.PRODUCT_PAGES_PATH, 'GET', null, { timeout }), + requestSevenElevenJson(SEVENELEVEN_API.PRODUCT_PAGES_PATH, 'GET', null, { timeout, zyteApiKey }), includeIssues - ? requestSevenElevenJson(SEVENELEVEN_API.PRODUCT_ISSUES_PATH, 'GET', null, { timeout }) + ? requestSevenElevenJson(SEVENELEVEN_API.PRODUCT_ISSUES_PATH, 'GET', null, { + timeout, + zyteApiKey, + }) : Promise.resolve({ data: { content: [] } }), includeExhibition - ? requestSevenElevenJson(SEVENELEVEN_API.EXHIBITION_MAIN_PATH, 'GET', null, { timeout }) + ? requestSevenElevenJson(SEVENELEVEN_API.EXHIBITION_MAIN_PATH, 'GET', null, { + timeout, + zyteApiKey, + }) : Promise.resolve({ data: [] }), ]; diff --git a/src/services/seveneleven/index.ts b/src/services/seveneleven/index.ts index 647980f6..76d56bd5 100644 --- a/src/services/seveneleven/index.ts +++ b/src/services/seveneleven/index.ts @@ -17,22 +17,28 @@ const SEVENELEVEN_METADATA: ServiceMetadata = { description: '세븐일레븐 공개 상품/검색/카탈로그 조회 서비스', }; +export interface SevenElevenServiceOptions { + zyteApiKey?: string; +} + class SevenElevenService implements ServiceProvider { readonly metadata = SEVENELEVEN_METADATA; + constructor(private readonly options: SevenElevenServiceOptions = {}) {} + getTools(): ToolRegistration[] { return [ - createSearchProductsTool(), - createSearchStoresTool(), - createCheckInventoryTool(), - createGetSearchPopwordsTool(), - createGetCatalogSnapshotTool(), + createSearchProductsTool(this.options.zyteApiKey), + createSearchStoresTool(this.options.zyteApiKey), + createCheckInventoryTool(this.options.zyteApiKey), + createGetSearchPopwordsTool(this.options.zyteApiKey), + createGetCatalogSnapshotTool(this.options.zyteApiKey), ]; } } -export function createSevenElevenService(): ServiceProvider { - return new SevenElevenService(); +export function createSevenElevenService(options: SevenElevenServiceOptions = {}): ServiceProvider { + return new SevenElevenService(options); } export * from './types.js'; diff --git a/src/services/seveneleven/inventory.ts b/src/services/seveneleven/inventory.ts index 30eb7eee..f2b7fa5c 100644 --- a/src/services/seveneleven/inventory.ts +++ b/src/services/seveneleven/inventory.ts @@ -6,11 +6,13 @@ */ /* c8 ignore start */ -import { fetchJson, HttpError } from '../../utils/http.js'; +import { HttpError } from '../../utils/http.js'; +import { fetchJsonWithZyteFallback } from '../../utils/zyteJsonFallback.js'; import { SEVENELEVEN_API } from './api.js'; import { fetchSevenElevenStockProductMeta, fetchSevenElevenStoresByKeyword, + type SevenElevenRequestOptions, } from './client.js'; import { pickBestSevenElevenProduct, searchSevenElevenProductsWithVariants } from './productKeyword.js'; import type { @@ -22,10 +24,6 @@ import type { SevenElevenStore, } from './types.js'; -interface RequestOptions { - timeout?: number; -} - interface CheckInventoryParams { productKeyword: string; storeKeyword: string; @@ -257,7 +255,7 @@ function filterStoresByKeyword async function tryStockApi( stockProduct: SevenElevenStockProductMeta, stores: SevenElevenStore[], - options: RequestOptions = {}, + options: SevenElevenRequestOptions = {}, ): Promise { const { timeout = 15000 } = options; const url = `${SEVENELEVEN_API.BASE_URL}${SEVENELEVEN_API.REAL_STOCK_MULTI_PATH}/01/stocks`; @@ -274,11 +272,13 @@ async function tryStockApi( } try { - const response = await fetchJson>(url, { + const response = await fetchJsonWithZyteFallback>(url, { method: 'POST', timeout, headers: SEVENELEVEN_DEFAULT_HEADERS, body: JSON.stringify(payload), + zyteApiKey: options.zyteApiKey, + zyteTags: { service: 'seveneleven', operation: 'inventory' }, }); const responseError = normalizeEnvelopeError(response); @@ -305,7 +305,7 @@ async function tryStockApi( export async function checkSevenElevenInventory( params: CheckInventoryParams, - options: RequestOptions = {}, + options: SevenElevenRequestOptions = {}, ): Promise { const { productKeyword, storeKeyword, storeLimit = 20 } = params; diff --git a/src/services/seveneleven/productKeyword.ts b/src/services/seveneleven/productKeyword.ts index 741a1e70..1e7e8ca5 100644 --- a/src/services/seveneleven/productKeyword.ts +++ b/src/services/seveneleven/productKeyword.ts @@ -2,14 +2,16 @@ * 세븐일레븐 상품 검색어 보정 */ -import { searchSevenElevenProducts } from './client.js'; +import { + searchSevenElevenProducts, + type SevenElevenRequestOptions, +} from './client.js'; import type { SevenElevenProduct, SevenElevenSearchResult } from './types.js'; -interface SearchVariantOptions { +interface SearchVariantOptions extends SevenElevenRequestOptions { page?: number; size?: number; sort?: string; - timeout?: number; } const SANDWICH_SUFFIX_FAMILY = ['샌드위치', '샌드', '산도'] as const; @@ -135,7 +137,7 @@ export async function searchSevenElevenProductsWithVariants( query: string, options: SearchVariantOptions = {}, ): Promise { - const { page = 1, size = 20, sort = 'recommend', timeout } = options; + const { page = 1, size = 20, sort = 'recommend', timeout, zyteApiKey } = options; const appliedQueries = buildSevenElevenProductKeywordVariants(query); const seenKeys = new Set(); const collectionIds = new Set(); @@ -151,6 +153,7 @@ export async function searchSevenElevenProductsWithVariants( }, { timeout, + zyteApiKey, }, ); diff --git a/src/services/seveneleven/tools/checkInventory.ts b/src/services/seveneleven/tools/checkInventory.ts index f8a560a2..e02de0ab 100644 --- a/src/services/seveneleven/tools/checkInventory.ts +++ b/src/services/seveneleven/tools/checkInventory.ts @@ -34,7 +34,7 @@ function isEncryptedStockFailure( return codeMatched || messageMatched; } -async function checkInventory(args: CheckInventoryArgs): Promise { +async function checkInventory(args: CheckInventoryArgs, zyteApiKey?: string): Promise { const { keyword, storeKeyword = '', @@ -54,6 +54,7 @@ async function checkInventory(args: CheckInventoryArgs): Promise Promise, + handler: (args: unknown) => checkInventory(args as CheckInventoryArgs, zyteApiKey), }; } /* c8 ignore stop */ diff --git a/src/services/seveneleven/tools/getCatalogSnapshot.ts b/src/services/seveneleven/tools/getCatalogSnapshot.ts index 4bab967f..db6c0552 100644 --- a/src/services/seveneleven/tools/getCatalogSnapshot.ts +++ b/src/services/seveneleven/tools/getCatalogSnapshot.ts @@ -13,13 +13,17 @@ interface GetCatalogSnapshotArgs { timeoutMs?: number; } -async function getCatalogSnapshot(args: GetCatalogSnapshotArgs): Promise { +async function getCatalogSnapshot( + args: GetCatalogSnapshotArgs, + zyteApiKey?: string, +): Promise { const { includeIssues = true, includeExhibition = true, limit = 20, timeoutMs = 15000 } = args; const result = await fetchSevenElevenCatalogSnapshot({ includeIssues, includeExhibition, timeout: timeoutMs, + zyteApiKey, }); return { @@ -54,7 +58,7 @@ async function getCatalogSnapshot(args: GetCatalogSnapshotArgs): Promise Promise, + handler: (args: unknown) => getCatalogSnapshot(args as GetCatalogSnapshotArgs, zyteApiKey), }; } diff --git a/src/services/seveneleven/tools/getSearchPopwords.ts b/src/services/seveneleven/tools/getSearchPopwords.ts index 6eb416a6..1eada050 100644 --- a/src/services/seveneleven/tools/getSearchPopwords.ts +++ b/src/services/seveneleven/tools/getSearchPopwords.ts @@ -11,11 +11,15 @@ interface GetSearchPopwordsArgs { timeoutMs?: number; } -async function getSearchPopwords(args: GetSearchPopwordsArgs): Promise { +async function getSearchPopwords( + args: GetSearchPopwordsArgs, + zyteApiKey?: string, +): Promise { const { label = 'home', timeoutMs = 15000 } = args; const keywords = await fetchSevenElevenSearchPopwords(label, { timeout: timeoutMs, + zyteApiKey, }); return { @@ -41,7 +45,7 @@ async function getSearchPopwords(args: GetSearchPopwordsArgs): Promise Promise, + handler: (args: unknown) => getSearchPopwords(args as GetSearchPopwordsArgs, zyteApiKey), }; } diff --git a/src/services/seveneleven/tools/searchProducts.ts b/src/services/seveneleven/tools/searchProducts.ts index a3ae3dca..422b9c74 100644 --- a/src/services/seveneleven/tools/searchProducts.ts +++ b/src/services/seveneleven/tools/searchProducts.ts @@ -21,7 +21,7 @@ function buildTextResponse(payload: Record): McpToolResponse { }; } -async function searchProducts(args: SearchProductsArgs): Promise { +async function searchProducts(args: SearchProductsArgs, zyteApiKey?: string): Promise { const { query, page = 1, size = 20, sort = 'recommend', timeoutMs = 15000 } = args; if (!query || query.trim().length === 0) { @@ -34,6 +34,7 @@ async function searchProducts(args: SearchProductsArgs): Promise Promise, + handler: (args: unknown) => searchProducts(args as SearchProductsArgs, zyteApiKey), }; } diff --git a/src/services/seveneleven/tools/searchStores.ts b/src/services/seveneleven/tools/searchStores.ts index 4a6cc513..05895221 100644 --- a/src/services/seveneleven/tools/searchStores.ts +++ b/src/services/seveneleven/tools/searchStores.ts @@ -12,7 +12,7 @@ interface SearchStoresArgs { timeoutMs?: number; } -async function searchStores(args: SearchStoresArgs): Promise { +async function searchStores(args: SearchStoresArgs, zyteApiKey?: string): Promise { const { keyword, limit = 20, timeoutMs = 15000 } = args; if (!keyword || keyword.trim().length === 0) { @@ -26,6 +26,7 @@ async function searchStores(args: SearchStoresArgs): Promise { }, { timeout: timeoutMs, + zyteApiKey, }, ); @@ -48,7 +49,7 @@ async function searchStores(args: SearchStoresArgs): Promise { }; } -export function createSearchStoresTool(): ToolRegistration { +export function createSearchStoresTool(zyteApiKey?: string): ToolRegistration { return { name: 'seveneleven_search_stores', metadata: { @@ -60,6 +61,6 @@ export function createSearchStoresTool(): ToolRegistration { timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: searchStores as (args: unknown) => Promise, + handler: (args: unknown) => searchStores(args as SearchStoresArgs, zyteApiKey), }; } diff --git a/src/utils/zyteJsonFallback.ts b/src/utils/zyteJsonFallback.ts new file mode 100644 index 00000000..e00ba4c0 --- /dev/null +++ b/src/utils/zyteJsonFallback.ts @@ -0,0 +1,89 @@ +/** + * 차단 응답에만 Zyte를 사용하는 JSON 전송 유틸리티 + */ + +import { fetchJson, HttpError, type FetchOptions } from './http.js'; +import { + decodeZyteHttpBody, + requestByZyte, + type ZyteExtractOptions, + type ZyteExtractResponse, +} from './zyte.js'; + +export interface ZyteJsonFallbackOptions extends FetchOptions { + zyteApiKey?: string; + zyteTags?: Record; +} + +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; + } + + /* c8 ignore next */ + return typeof process !== 'undefined' && Boolean(process.env?.ZYTE_API_KEY?.trim()); +} + +export async function fetchJsonWithZyteFallback( + url: string, + options: ZyteJsonFallbackOptions = {}, +): Promise { + const { zyteApiKey, zyteTags, ...directOptions } = options; + + try { + return await fetchJson(url, directOptions); + } catch (error) { + if (!(error instanceof HttpError) || !ZYTE_FALLBACK_STATUSES.has(error.status)) { + throw error; + } + if (!hasZyteApiKey(zyteApiKey)) { + throw error; + } + } + + 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); +} diff --git a/tests/api/cu-handlers.test.ts b/tests/api/cu-handlers.test.ts index b9cd953b..b01a7385 100644 --- a/tests/api/cu-handlers.test.ts +++ b/tests/api/cu-handlers.test.ts @@ -157,6 +157,51 @@ 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' }, + ); + 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')}`, + ); + }); + it('CU 재고 검색 결과를 반환한다', async () => { mockFetch .mockResolvedValueOnce( diff --git a/tests/api/health-checks.test.ts b/tests/api/health-checks.test.ts index 53f450b2..79eed195 100644 --- a/tests/api/health-checks.test.ts +++ b/tests/api/health-checks.test.ts @@ -3,6 +3,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { HEALTH_CHECKS } from '../../src/api/healthCheckDefinitions.js'; import { __testOnlyClearHealthCheckCache, runHealthChecks } from '../../src/api/healthChecks.js'; beforeEach(() => { @@ -21,6 +22,27 @@ function jsonResponse(body: unknown, status = 200): Response { } describe('runHealthChecks', () => { + it('편의점 상품·매장·재고 운영 체크 12개를 모두 정의한다', () => { + const convenienceCheckIds = HEALTH_CHECKS.filter((check) => + ['cu', 'gs25', 'seveneleven', 'emart24'].includes(check.service), + ).map((check) => check.id); + + expect(convenienceCheckIds).toEqual([ + 'cu.stores', + 'emart24.products', + 'emart24.stores', + 'gs25.products', + 'gs25.stores', + 'seveneleven.products', + 'seveneleven.stores', + 'seveneleven.popwords', + 'cu.inventory', + 'emart24.inventory', + 'gs25.inventory', + 'seveneleven.inventory', + ]); + }); + it('선택된 체크가 없으면 skipped 상태를 반환한다', async () => { const fetchImpl = vi.fn(); diff --git a/tests/api/seveneleven-handlers.test.ts b/tests/api/seveneleven-handlers.test.ts index 8a7fabd9..973c8b26 100644 --- a/tests/api/seveneleven-handlers.test.ts +++ b/tests/api/seveneleven-handlers.test.ts @@ -22,9 +22,12 @@ afterEach(() => { vi.restoreAllMocks(); }); -function createMockContext(query: Record = {}) { +function createMockContext( + query: Record = {}, + env: Record = {}, +) { return { - env: {}, + env, req: { query: (key: string) => query[key], param: () => undefined, @@ -83,6 +86,55 @@ 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'), + }), + ), + ); + + 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')}`, + }), + }), + ); + }); }); describe('handleSevenElevenSearchStores', () => { diff --git a/tests/scripts/cli-smoke.test.ts b/tests/scripts/cli-smoke.test.ts index a96c307c..3cda7ed9 100644 --- a/tests/scripts/cli-smoke.test.ts +++ b/tests/scripts/cli-smoke.test.ts @@ -12,6 +12,7 @@ describe('runCliSmoke', () => { '상품명만 아는 사용자', '위치를 대강 말하는 사용자', '잘못된 옵션을 입력한 사용자', + 'CU 매장 검색', ]), ); }); @@ -20,6 +21,7 @@ describe('runCliSmoke', () => { expect(CLI_SMOKE_COMMANDS.map((command) => command.service)).toEqual( expect.arrayContaining([ 'daiso', + 'cu', 'gs25', 'seveneleven', 'emart24', @@ -42,6 +44,7 @@ describe('runCliSmoke', () => { const stdoutByCommand: Record = { stores: { success: true, data: { stores: [] } }, + 'cu-stores': { success: true, data: { keyword: '강남' } }, 'gs25-products': { success: true, data: { keyword: '콜라' } }, 'gs25-stores': { success: true, data: { keyword: '강남' } }, 'seveneleven-products': { success: true, data: { query: '커피' } }, diff --git a/tests/services/cu/client.test.ts b/tests/services/cu/client.test.ts index 203f7112..061c7de0 100644 --- a/tests/services/cu/client.test.ts +++ b/tests/services/cu/client.test.ts @@ -12,6 +12,31 @@ 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' } }, + ); +} + +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); @@ -389,6 +414,159 @@ 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 키 없이 원본 재고가 차단되어도 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 + .mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] }))) + .mockResolvedValueOnce(new Response('origin error', { status: 500 })); + + await expect( + fetchCuStock( + { keyword: '과자', limit: 1, offset: 0, searchSort: 'recom' }, + { apiKey: 'test-key' }, + ), + ).rejects.toThrow('API 요청 실패: 500'); + }); + it('재고 검색 결과를 정규화해서 반환한다', async () => { mockFetch .mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] }))) diff --git a/tests/services/cu/tools/checkInventory.test.ts b/tests/services/cu/tools/checkInventory.test.ts index f0604125..5fbf9f14 100644 --- a/tests/services/cu/tools/checkInventory.test.ts +++ b/tests/services/cu/tools/checkInventory.test.ts @@ -17,6 +17,50 @@ afterEach(() => { }); 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('올바른 도구 정의를 반환한다', () => { const tool = createCheckInventoryTool(); diff --git a/tests/services/cu/tools/findNearbyStores.test.ts b/tests/services/cu/tools/findNearbyStores.test.ts index 60a613c4..2825ade8 100644 --- a/tests/services/cu/tools/findNearbyStores.test.ts +++ b/tests/services/cu/tools/findNearbyStores.test.ts @@ -17,6 +17,37 @@ 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('올바른 도구 정의를 반환한다', () => { const tool = createFindNearbyStoresTool(); diff --git a/tests/services/seveneleven/client.test.ts b/tests/services/seveneleven/client.test.ts index 5f8a41c7..7d4c9d02 100644 --- a/tests/services/seveneleven/client.test.ts +++ b/tests/services/seveneleven/client.test.ts @@ -4,12 +4,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + fetchSevenElevenCatalogSnapshot, + fetchSevenElevenSearchPopwords, fetchSevenElevenStockProductMeta, + fetchSevenElevenStoresByKeyword, searchSevenElevenProducts, } from '../../../src/services/seveneleven/client.js'; 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(() => { mockFetch.mockReset(); vi.stubGlobal('fetch', mockFetch); @@ -20,6 +33,133 @@ 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('매장 검색 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('인기 검색어 403을 Zyte로 복구한다', async () => { + mockFetch + .mockResolvedValueOnce(new Response('blocked', { status: 403 })) + .mockResolvedValueOnce( + zyteJsonResponse({ + success: true, + data: { keywords: ['커피', '도시락'] }, + }), + ); + + await expect( + fetchSevenElevenSearchPopwords('home', { zyteApiKey: 'worker-key' }), + ).resolves.toEqual(['커피', '도시락']); + }); + + 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('카탈로그 페이지 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('일시적 GET 실패는 기본 재시도로 복구한다', async () => { mockFetch .mockResolvedValueOnce(new Response('origin timeout', { status: 522, statusText: 'Origin Timeout' })) diff --git a/tests/services/seveneleven/index.test.ts b/tests/services/seveneleven/index.test.ts index aaa9fabc..e5e19f26 100644 --- a/tests/services/seveneleven/index.test.ts +++ b/tests/services/seveneleven/index.test.ts @@ -2,9 +2,20 @@ * 세븐일레븐 서비스 테스트 */ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createSevenElevenService } from '../../../src/services/seveneleven/index.js'; +const mockFetch = vi.fn(); + +beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + describe('createSevenElevenService', () => { it('ServiceProvider 인터페이스를 구현한 객체를 반환한다', () => { const service = createSevenElevenService(); @@ -33,4 +44,49 @@ describe('createSevenElevenService', () => { 'seveneleven_get_catalog_snapshot', ]); }); + + 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'); + 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')}`, + }), + }), + ); + }); }); diff --git a/tests/services/seveneleven/productKeyword.test.ts b/tests/services/seveneleven/productKeyword.test.ts index 881593b4..6e860399 100644 --- a/tests/services/seveneleven/productKeyword.test.ts +++ b/tests/services/seveneleven/productKeyword.test.ts @@ -34,6 +34,33 @@ 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(() => { mockFetch.mockReset(); vi.stubGlobal('fetch', mockFetch); @@ -139,6 +166,32 @@ 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(makeProductResponse('후르츠산도', [])) diff --git a/tests/services/seveneleven/tools/checkInventory.test.ts b/tests/services/seveneleven/tools/checkInventory.test.ts index 95377e77..03b2f416 100644 --- a/tests/services/seveneleven/tools/checkInventory.test.ts +++ b/tests/services/seveneleven/tools/checkInventory.test.ts @@ -476,4 +476,66 @@ describe('createCheckInventoryTool', () => { }), ); }); + + it('주입된 Zyte 키로 차단된 실재고 API를 복구한다', async () => { + const stockPayload = { + success: true, + data: { + smCd: '201051', + storeList: [{ storeCd: '54928', stock: 4, stokMngQty: 0 }], + }, + message: '성공', + code: 200, + }; + mockFetch + .mockResolvedValueOnce( + makeProductResponse([ + { + prdNo: '100', + itemCd: '8801234567890', + itemOnm: '핫식스', + onlinePrice: 1500, + }, + ]), + ) + .mockResolvedValueOnce( + makeStoreResponse([ + { + field: { + storeCd: '54928', + storeNm: '안산중앙일번가점', + addr1: '경기 안산시', + storeLat: '37.3156', + storeLon: '126.8384', + }, + }, + ]), + ) + .mockResolvedValueOnce(makeStockProductMetaResponse()) + .mockResolvedValueOnce(new Response('blocked', { status: 403 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + statusCode: 200, + httpResponseBody: Buffer.from(JSON.stringify(stockPayload)).toString('base64'), + }), + ), + ); + + 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')}`, + }), + }), + ); + }); }); diff --git a/tests/services/seveneleven/tools/getCatalogSnapshot.test.ts b/tests/services/seveneleven/tools/getCatalogSnapshot.test.ts index 1da99647..2e880622 100644 --- a/tests/services/seveneleven/tools/getCatalogSnapshot.test.ts +++ b/tests/services/seveneleven/tools/getCatalogSnapshot.test.ts @@ -72,4 +72,37 @@ describe('createGetCatalogSnapshotTool', () => { expect(parsed.exhibitions.totalCount).toBe(1); 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'), + }), + ), + ); + + 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')}`, + }), + }), + ); + }); }); diff --git a/tests/services/seveneleven/tools/getSearchPopwords.test.ts b/tests/services/seveneleven/tools/getSearchPopwords.test.ts index e9826c62..87fa709d 100644 --- a/tests/services/seveneleven/tools/getSearchPopwords.test.ts +++ b/tests/services/seveneleven/tools/getSearchPopwords.test.ts @@ -54,4 +54,34 @@ describe('createGetSearchPopwordsTool', () => { expect(parsed.count).toBe(0); 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')}`, + }), + }), + ); + }); }); diff --git a/tests/services/seveneleven/tools/searchProducts.test.ts b/tests/services/seveneleven/tools/searchProducts.test.ts index 937e32f9..7b4f5576 100644 --- a/tests/services/seveneleven/tools/searchProducts.test.ts +++ b/tests/services/seveneleven/tools/searchProducts.test.ts @@ -164,6 +164,50 @@ 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('비 Error 예외도 output schema를 만족하는 degraded 응답으로 변환한다', async () => { mockFetch.mockRejectedValue('blocked'); diff --git a/tests/services/seveneleven/tools/searchStores.test.ts b/tests/services/seveneleven/tools/searchStores.test.ts index 3d73bafc..b6aeb77d 100644 --- a/tests/services/seveneleven/tools/searchStores.test.ts +++ b/tests/services/seveneleven/tools/searchStores.test.ts @@ -91,4 +91,47 @@ 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')}`, + }), + }), + ); + }); }); diff --git a/tests/utils/zyteJsonFallback.test.ts b/tests/utils/zyteJsonFallback.test.ts new file mode 100644 index 00000000..cfce2c58 --- /dev/null +++ b/tests/utils/zyteJsonFallback.test.ts @@ -0,0 +1,231 @@ +/** + * 차단 응답용 Zyte JSON 폴백 테스트 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +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); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('fetchJsonWithZyteFallback', () => { + it('원본 요청이 성공하면 Zyte를 호출하지 않는다', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json' }, + }), + ); + + await expect( + fetchJsonWithZyteFallback('https://example.com/api', { + method: 'GET', + zyteApiKey: 'test-key', + }), + ).resolves.toEqual({ success: true }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it.each([400, 403, 429])('원본 %s 응답이면 Zyte로 재시도한다', async (status) => { + mockFetch + .mockResolvedValueOnce(new Response('blocked', { status })) + .mockResolvedValueOnce(zyteResponse({ success: true })); + + await expect( + fetchJsonWithZyteFallback('https://example.com/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"query":"coffee"}', + 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' }, + ]), + ); + }); + + it('원본 500 응답은 Zyte로 재시도하지 않는다', async () => { + mockFetch.mockResolvedValueOnce(new Response('origin error', { status: 500 })); + + await expect( + fetchJsonWithZyteFallback('https://example.com/api', { + zyteApiKey: 'test-key', + }), + ).rejects.toThrow('API 요청 실패: 500'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('Zyte가 지원하지 않는 메서드는 차단 응답 이후 명확히 거부한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); + + await expect( + fetchJsonWithZyteFallback('https://example.com/api', { + method: 'HEAD', + zyteApiKey: 'test-key', + }), + ).rejects.toThrow('Zyte에서 지원하지 않는 HTTP 메서드입니다: HEAD'); + + 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.'); + }); +});