From 463fe35b8e9d48c6cbf56ac78fe17319e428b820 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Tue, 28 Jul 2026 11:24:32 +0900 Subject: [PATCH 1/8] =?UTF-8?q?docs:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=8B=A0=EB=A2=B0=EC=84=B1=20=EB=B3=B5=EA=B5=AC=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...2026-07-28-service-reliability-recovery.md | 114 ++++++++++++++++++ ...-28-service-reliability-recovery-design.md | 77 ++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-28-service-reliability-recovery.md create mode 100644 docs/superpowers/specs/2026-07-28-service-reliability-recovery-design.md diff --git a/docs/superpowers/plans/2026-07-28-service-reliability-recovery.md b/docs/superpowers/plans/2026-07-28-service-reliability-recovery.md new file mode 100644 index 00000000..07fadd4c --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-service-reliability-recovery.md @@ -0,0 +1,114 @@ +# Service Reliability 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:** 운영에서 확인된 다섯 장애와 CGV 이슈 #156을 정직한 오류 계약과 실제 복구 경로로 개선한다. + +**Architecture:** 공용 날짜와 헬스체크 shape를 중앙에서 교정하고, 서비스별 전송 계층은 성공 가능한 경로를 우선 사용한다. 외부 인증·결제·차단으로 복구할 수 없는 경우 빈 성공 대신 서비스별 503 응답을 반환한다. + +**Tech Stack:** TypeScript 6, Hono, Cloudflare Workers, Vitest, Wrangler + +--- + +### Task 1: 한국 날짜 고정 + +**Files:** +- Modify: `src/utils/format.ts` +- Create: `tests/utils/format.test.ts` + +- [ ] `2026-07-27T15:30:00Z`가 `20260728`이 되는 실패 테스트를 추가한다. +- [ ] `npx vitest run tests/utils/format.test.ts --maxWorkers=1 --no-file-parallelism`로 기존 로컬 날짜 구현의 실패를 확인한다. +- [ ] `Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit' })`의 `formatToParts`로 `YYYYMMDD`를 만든다. +- [ ] 같은 단일 테스트를 다시 실행해 통과를 확인한다. + +### Task 2: 헬스체크 shape와 빈 결과 판정 + +**Files:** +- Modify: `src/api/healthCheckTypes.ts` +- Modify: `src/api/healthCheckShape.ts` +- Modify: `src/api/healthCheckDefinitions.ts` +- Modify: `src/api/healthChecks.ts` +- Modify: `tests/api/health-checks.test.ts` + +- [ ] `inventoryStores`, 이마트24 top-level stores, count 미확인, 선택적 빈 결과의 기대 판정을 테스트한다. +- [ ] `npx vitest run tests/api/health-checks.test.ts --maxWorkers=1 --no-file-parallelism`로 실패를 확인한다. +- [ ] `collectionKey: 'inventoryStores'`와 `allowEmpty?: boolean`을 추가한다. +- [ ] `inventory.stores`를 count·sample·shape에 포함하고 빈 필수 컬렉션을 shape 실패로 처리한다. +- [ ] `count === null`은 degraded, `allowEmpty && count === 0`은 skipped로 판정한다. +- [ ] 서비스 정의의 실제 컬렉션과 대표 필드를 교정하고 단일 테스트를 통과시킨다. + +### Task 3: GS25 인증 Secret과 unavailable 계약 + +**Files:** +- Modify: `src/api/response.ts` +- Modify: `src/index.ts` +- Modify: `src/services/gs25/index.ts` +- Modify: `src/services/gs25/client.ts` +- Modify: `src/services/gs25/tools/checkInventory.ts` +- Modify: `src/api/gs25Handlers.ts` +- Modify: `.github/workflows/sync-worker-secrets.yml` +- Modify: `tests/services/gs25/client.test.ts` +- Modify: `tests/api/gs25-handlers.test.ts` + +- [ ] `Api-Key` 헤더 주입과 401 인증 실패의 503 변환 테스트를 추가한다. +- [ ] GS25 클라이언트 테스트와 핸들러 테스트를 각각 단독 실행해 실패를 확인한다. +- [ ] `GS25_API_KEY`를 AppBindings와 서비스 옵션으로 전달하고 stock 요청 헤더에만 추가한다. +- [ ] 401/403 인증 실패를 `Gs25UpstreamUnavailableError`로 정규화하고 핸들러에서 `GS25_UPSTREAM_UNAVAILABLE` 503을 반환한다. +- [ ] MCP 도구와 Worker Secret 동기화 경로를 연결하고 두 단일 테스트를 통과시킨다. + +### Task 4: 롯데마트 전송 복구 + +**Files:** +- Modify: `src/services/lottemart/api.ts` +- Modify: `src/services/lottemart/config.ts` +- Modify: `src/services/lottemart/session.ts` +- Modify: `tests/services/lottemart/session.test.ts` +- Modify: `tests/services/lottemart/debug.test.ts` + +- [ ] 표준 fetch 우선, HTTP origin, 제한된 fallback 순서를 검증하는 실패 테스트를 추가한다. +- [ ] 두 테스트 파일을 각각 단독 실행해 실패를 확인한다. +- [ ] 공식 HTTP base URL을 사용하고 표준 fetch 성공 시 소켓과 Zyte를 호출하지 않도록 한다. +- [ ] 표준 fetch 실패 시에만 남은 시간 예산으로 소켓과 Zyte를 순차 실행한다. +- [ ] 두 단일 테스트를 통과시키고 운영 debug 요청이 제한시간 안에 매장을 반환하는지 확인한다. + +### Task 5: 세븐일레븐 선택적 인기 검색어 + +**Files:** +- Modify: `src/api/healthCheckDefinitions.ts` +- Modify: `tests/api/health-checks.test.ts` +- Modify: `tests/app/app-api-seveneleven.test.ts` + +- [ ] 원본 빈 객체가 `available:false` 응답과 skipped 헬스 상태로 유지되는 테스트를 추가한다. +- [ ] 각 테스트 파일을 단독 실행해 실패를 확인한다. +- [ ] popwords 정의에 `allowEmpty: true`를 적용하고 정상 데이터가 있을 때는 기존 ok 판정을 유지한다. +- [ ] 두 단일 테스트를 통과시킨다. + +### Task 6: CGV 이슈 #156 graceful 처리 + +**Files:** +- Create: `src/services/cgv/errors.ts` +- Modify: `src/services/cgv/transport.ts` +- Modify: `src/api/cgvHandlers.ts` +- Modify: `tests/services/cgv/transport.test.ts` +- Modify: `tests/api/cgv-handlers.test.ts` + +- [ ] 직접 403 뒤 Zyte 403이 발생하면 typed unavailable 오류가 되는 테스트를 추가한다. +- [ ] API가 `CGV_UPSTREAM_UNAVAILABLE` 503을 반환하는 테스트를 추가한다. +- [ ] 두 테스트를 각각 단독 실행해 실패를 확인한다. +- [ ] `CgvUpstreamUnavailableError`와 판별 함수를 추가하고 결제 상세를 일반 안내로 정규화한다. +- [ ] 세 CGV 핸들러가 typed 오류에만 503을 반환하도록 최소 수정한다. +- [ ] 두 단일 테스트를 통과시킨다. + +### Task 7: 전체 검증과 배포 + +**Files:** +- Verify all changed files + +- [ ] `npm run format:check`, `npm run lint`, `npm run lint:biome`, `npm run typecheck`, `npm run check:source-lines`를 차례로 실행한다. +- [ ] `npx vitest run --maxWorkers=1 --no-file-parallelism`로 전체 테스트를 단일 워커로 실행한다. +- [ ] `npx vitest run --coverage --maxWorkers=1 --no-file-parallelism`로 100% 커버리지를 확인한다. +- [ ] `npm run build`와 `npm audit`를 순차 실행한다. +- [ ] 변경사항을 자체 리뷰하고 커밋한 뒤 브랜치를 push하여 PR을 만든다. +- [ ] PR CI를 확인하고 `main`에 병합한 뒤 Deploy 완료를 확인한다. +- [ ] 운영 API에서 다이소·편의점·마트·영화관·오피넷·장소·비교 기능을 순차 재검증한다. +- [ ] CGV 이슈 #156에 수정·배포·운영 확인 결과를 답변하고 필요하면 종료한다. diff --git a/docs/superpowers/specs/2026-07-28-service-reliability-recovery-design.md b/docs/superpowers/specs/2026-07-28-service-reliability-recovery-design.md new file mode 100644 index 00000000..8b721f42 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-service-reliability-recovery-design.md @@ -0,0 +1,77 @@ +# 서비스 신뢰성 복구 설계 + +## 목표 + +운영 점검에서 확인된 영화관 기본 날짜, 헬스체크 오판, GS25 재고 인증 실패, +롯데마트 매장 검색 지연, 세븐일레븐 인기 검색어 공백과 GitHub 이슈 #156의 +CGV Zyte 장애 처리를 함께 개선한다. + +## 검토한 접근 + +1. 빈 결과를 계속 성공으로 반환한다. + 구현은 단순하지만 실제 장애와 정상적인 검색 결과 0건을 구분할 수 없다. +2. 모든 외부 오류를 즉시 실패로 바꾼다. + 관측은 정확해지지만 선택적 데이터와 복구 가능한 경로까지 중단한다. +3. 실제 대체 경로를 먼저 사용하고, 복구 불가능한 경우에만 명시적인 + `upstream unavailable` 상태를 반환한다. + +세 번째 접근을 사용한다. 실제 데이터가 있는 경우에는 기존 성공 계약을 유지하고, +인증·결제·차단 문제를 빈 성공으로 위장하지 않는다. + +## 설계 + +### 한국 날짜 + +공용 `toYyyymmdd`가 런타임의 로컬 시간대에 의존하지 않도록 +`Asia/Seoul` 달력 날짜를 `Intl.DateTimeFormat`으로 계산한다. CGV, 메가박스, +롯데시네마의 API와 MCP 도구는 이미 공용 함수를 사용하므로 한 번의 수정으로 +같은 동작을 얻는다. + +### 헬스체크 + +재고 응답별 실제 컬렉션을 구분한다. + +- CU: `data.inventory.items` +- GS25·세븐일레븐: `data.inventory.stores` +- 이마트24: `data.stores` +- 올리브영: `data.inventory.products` + +필수 컬렉션을 찾지 못해 개수를 계산할 수 없는 경우도 `degraded`로 판정한다. +빈 컬렉션의 대표 필드 검사를 자동 통과시키지 않는다. 세븐일레븐 인기 검색어처럼 +공식 API가 선택적 빈 데이터를 반환하는 체크는 `allowEmpty`로 표시하고 +`skipped`로 구분한다. + +### GS25 재고 + +공식 앱이 사용하는 `Api-Key`를 `GS25_API_KEY` Secret으로만 주입한다. 키는 +소스·로그·오류 메시지에 기록하지 않는다. 401/403 또는 인증 오류 envelope를 +감지하면 빈 매장 목록 대신 `GS25_UPSTREAM_UNAVAILABLE` 503을 반환한다. +MCP 서비스에도 같은 Secret을 전달한다. Secret을 확보할 수 없는 배포에서도 +거짓 재고 0건은 반환하지 않는다. + +### 롯데마트 매장 + +공식 매장 안내가 가리키는 HTTP origin을 사용한다. Cloudflare 소켓을 먼저 +시도해 전체 요청 시간을 소모하지 않고, 표준 fetch를 우선한다. 표준 fetch가 +실패할 때만 제한된 소켓·Zyte 경로를 순차적으로 사용한다. 각 단계는 하나의 +총 요청 예산을 나눠 사용해 운영 요청이 45초 이상 멈추지 않도록 한다. + +### 세븐일레븐 인기 검색어 + +공식 원본이 `200 {"data":{}}`를 반환하므로 인기 데이터를 임의 생성하지 않는다. +API와 MCP 응답의 `available:false` 계약을 유지하고, 헬스체크에서 선택적 데이터 +미제공으로 정확히 분류한다. + +### CGV 이슈 #156 + +직접 CGV 호출을 계속 우선하고 403일 때만 Zyte를 사용한다. Zyte 결제 정지, +차단, 키 누락 등으로 우회까지 실패하면 결제 상세를 노출하지 않고 +`CGV_UPSTREAM_UNAVAILABLE` 503과 재시도 안내를 반환한다. 배포 후 CGV 극장과 +시간표를 운영 환경에서 확인하고 이슈 #156에 결과를 답변한다. + +## 테스트와 배포 + +각 수정은 실패하는 단일 회귀 테스트를 먼저 실행한 다음 최소 구현으로 통과시킨다. +마지막에 포맷, ESLint, Biome, 타입 검사, 450줄 제한, 전체 테스트, 100% 커버리지, +빌드를 순차 실행한다. PR을 `main`에 병합하고 Deploy·CI·Coverage·CodeQL을 확인한 +뒤 운영 API 전체 스모크를 다시 수행한다. From fb0c69e7c8381610c1f123725a0ca11a2024848c Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Tue, 28 Jul 2026 11:25:34 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20=EC=98=81=ED=99=94=EA=B4=80=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EB=82=A0=EC=A7=9C=EB=A5=BC=20=ED=95=9C?= =?UTF-8?q?=EA=B5=AD=20=EC=8B=9C=EA=B0=84=EC=9C=BC=EB=A1=9C=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/format.ts | 13 ++++++++++--- tests/utils/format.test.ts | 13 +++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 tests/utils/format.test.ts diff --git a/src/utils/format.ts b/src/utils/format.ts index 55ab2306..3d9a02da 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -32,8 +32,15 @@ export function formatTime(raw: string | undefined): string { } export function toYyyymmdd(value: Date = new Date()): string { - const year = value.getFullYear(); - const month = `${value.getMonth() + 1}`.padStart(2, '0'); - const day = `${value.getDate()}`.padStart(2, '0'); + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Seoul', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(value); + const byType = Object.fromEntries(parts.map((part) => [part.type, part.value])); + const year = byType.year; + const month = byType.month; + const day = byType.day; return `${year}${month}${day}`; } diff --git a/tests/utils/format.test.ts b/tests/utils/format.test.ts new file mode 100644 index 00000000..1bffc309 --- /dev/null +++ b/tests/utils/format.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { formatTime, toNumber, toYyyymmdd } from '../../src/utils/format.js'; + +describe('format utilities', () => { + it('한국 자정 이후에는 UTC 전날이 아닌 한국 날짜를 반환한다', () => { + expect(toYyyymmdd(new Date('2026-07-27T15:30:00.000Z'))).toBe('20260728'); + }); + + it('숫자와 시간의 기존 정규화를 유지한다', () => { + expect(toNumber('12')).toBe(12); + expect(formatTime('0930')).toBe('09:30'); + }); +}); From 0ccf4f8bc510eef2ca7df9012224542961b0390f Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Tue, 28 Jul 2026 11:29:25 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20=ED=97=AC=EC=8A=A4=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=20=EC=9E=AC=EA=B3=A0=20=EC=9D=91=EB=8B=B5=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=20=EC=A0=95=ED=99=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/healthCheckDefinitions.ts | 23 ++-- src/api/healthCheckShape.ts | 40 ++++-- src/api/healthCheckTypes.ts | 4 +- src/api/healthChecks.ts | 80 +++++++++--- tests/api/health-checks.test.ts | 189 ++++++++++++++++++++++++---- tests/app/app-health-checks.test.ts | 116 ++++++++++++++--- 6 files changed, 363 insertions(+), 89 deletions(-) diff --git a/src/api/healthCheckDefinitions.ts b/src/api/healthCheckDefinitions.ts index 87b11b66..220951aa 100644 --- a/src/api/healthCheckDefinitions.ts +++ b/src/api/healthCheckDefinitions.ts @@ -10,15 +10,9 @@ export const GS25_CLOUDFRONT_403_PATTERNS = [ '403 ERROR', ]; -export const EMART24_UPSTREAM_403_PATTERNS = [ - '403 Forbidden', - '403 Forbidden', -]; +export const EMART24_UPSTREAM_403_PATTERNS = ['403 Forbidden', '403 Forbidden']; -export const CU_UPSTREAM_BLOCK_PATTERNS = [ - '400 Bad Request', - 'Request Blocked', -]; +export const CU_UPSTREAM_BLOCK_PATTERNS = ['400 Bad Request', 'Request Blocked']; export const SEVENELEVEN_UPSTREAM_403_PATTERNS = [ '403 Forbidden', @@ -119,6 +113,7 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [ mode: 'quick', path: '/api/seveneleven/popwords?label=home', degradedFailurePatterns: SEVENELEVEN_UPSTREAM_403_PATTERNS, + allowEmpty: true, }, { id: 'lottemart.products', @@ -182,8 +177,8 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [ target: 'inventory', mode: 'deep', path: '/api/emart24/inventory?keyword=%EC%BB%A4%ED%94%BC&storeKeyword=%EA%B0%95%EB%82%A8&limit=1', - collectionKey: 'inventoryItems', - requiredFields: ['pluCd', 'goodsName', 'itemName', 'name'], + collectionKey: 'stores', + requiredFields: ['storeCode', 'storeName', 'name'], degradedFailurePatterns: EMART24_UPSTREAM_403_PATTERNS, }, { @@ -192,8 +187,8 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [ target: 'inventory', mode: 'deep', path: '/api/gs25/inventory?keyword=%EC%BD%9C%EB%9D%BC&storeKeyword=%EA%B0%95%EB%82%A8&limit=1', - collectionKey: 'inventoryItems', - requiredFields: ['itemCode', 'itemName', 'name'], + collectionKey: 'inventoryStores', + requiredFields: ['storeCode', 'storeName', 'name'], degradedFailurePatterns: [ '401 Unauthorized', '인증키가 제공되지 않음', @@ -206,8 +201,8 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [ target: 'inventory', mode: 'deep', path: '/api/seveneleven/inventory?keyword=%EC%BB%A4%ED%94%BC&storeKeyword=%EA%B0%95%EB%82%A8&size=1', - collectionKey: 'inventoryItems', - requiredFields: ['itemCode', 'itemName', 'productNo', 'name'], + collectionKey: 'inventoryStores', + requiredFields: ['storeCode', 'storeName', 'name'], degradedFailurePatterns: SEVENELEVEN_UPSTREAM_403_PATTERNS, }, { diff --git a/src/api/healthCheckShape.ts b/src/api/healthCheckShape.ts index ca6cf187..85f64d9b 100644 --- a/src/api/healthCheckShape.ts +++ b/src/api/healthCheckShape.ts @@ -23,7 +23,7 @@ export function toCount(data: unknown): number | null { if (record.inventory && typeof record.inventory === 'object') { const inventory = record.inventory as Record; - for (const key of ['products', 'items']) { + for (const key of ['products', 'items', 'stores']) { const value = inventory[key]; if (Array.isArray(value)) { return value.length; @@ -46,7 +46,15 @@ export function toFirstName(data: unknown): string | undefined { continue; } const item = value[0] as Record; - for (const nameKey of ['productName', 'itemName', 'goodsName', 'name', 'storeName', 'theaterName', 'movieName']) { + for (const nameKey of [ + 'productName', + 'itemName', + 'goodsName', + 'name', + 'storeName', + 'theaterName', + 'movieName', + ]) { if (typeof item[nameKey] === 'string' && item[nameKey].trim().length > 0) { return item[nameKey].trim(); } @@ -55,13 +63,13 @@ export function toFirstName(data: unknown): string | undefined { if (record.inventory && typeof record.inventory === 'object') { const inventory = record.inventory as Record; - for (const key of ['products', 'items']) { + for (const key of ['products', 'items', 'stores']) { const value = inventory[key]; if (!Array.isArray(value) || !value[0] || typeof value[0] !== 'object') { continue; } const item = value[0] as Record; - for (const nameKey of ['productName', 'itemName', 'goodsName', 'name']) { + for (const nameKey of ['productName', 'itemName', 'goodsName', 'name', 'storeName']) { if (typeof item[nameKey] === 'string' && item[nameKey].trim().length > 0) { return item[nameKey].trim(); } @@ -72,18 +80,30 @@ export function toFirstName(data: unknown): string | undefined { return undefined; } -function getCollectionItems(data: unknown, collectionKey?: HealthCheckDefinition['collectionKey']): unknown[] { +function getCollectionItems( + data: unknown, + collectionKey?: HealthCheckDefinition['collectionKey'], +): unknown[] { if (!data || typeof data !== 'object') { return []; } const record = data as Record; - if (collectionKey === 'inventoryProducts' || collectionKey === 'inventoryItems') { + if ( + collectionKey === 'inventoryProducts' || + collectionKey === 'inventoryItems' || + collectionKey === 'inventoryStores' + ) { const inventory = record.inventory; if (!inventory || typeof inventory !== 'object') { return []; } - const key = collectionKey === 'inventoryProducts' ? 'products' : 'items'; + const key = + collectionKey === 'inventoryProducts' + ? 'products' + : collectionKey === 'inventoryStores' + ? 'stores' + : 'items'; const value = (inventory as Record)[key]; return Array.isArray(value) ? value : []; } @@ -114,7 +134,7 @@ export function hasRequiredRepresentativeFields( const items = getCollectionItems(data, collectionKey); if (items.length === 0) { - return true; + return false; } const first = items[0]; @@ -125,6 +145,8 @@ export function hasRequiredRepresentativeFields( const record = first as Record; return requiredFields.some((field) => { const value = record[field]; - return typeof value === 'string' ? value.trim().length > 0 : value !== undefined && value !== null; + return typeof value === 'string' + ? value.trim().length > 0 + : value !== undefined && value !== null; }); } diff --git a/src/api/healthCheckTypes.ts b/src/api/healthCheckTypes.ts index 2e0b3e0d..80a347ee 100644 --- a/src/api/healthCheckTypes.ts +++ b/src/api/healthCheckTypes.ts @@ -19,10 +19,12 @@ export interface HealthCheckDefinition { | 'movies' | 'showtimes' | 'inventoryProducts' - | 'inventoryItems'; + | 'inventoryItems' + | 'inventoryStores'; requiredFields?: string[]; timeoutMs?: number; degradedFailurePatterns?: string[]; + allowEmpty?: boolean; } export interface HealthCheckResult { diff --git a/src/api/healthChecks.ts b/src/api/healthChecks.ts index 38845ae5..086e3013 100644 --- a/src/api/healthChecks.ts +++ b/src/api/healthChecks.ts @@ -78,7 +78,9 @@ function createCacheKey(params: HealthCheckCacheKeyParams): string { ].join('|'); } -function selectChecks(params: Pick): HealthCheckDefinition[] { +function selectChecks( + params: Pick, +): HealthCheckDefinition[] { const mode = params.mode || 'quick'; return HEALTH_CHECKS.filter((check) => { if (mode !== 'full' && check.mode !== mode) { @@ -126,6 +128,9 @@ function aggregateStatus(checks: HealthCheckResult[]): HealthCheckStatus { if (checks.some((check) => check.status === 'degraded')) { return 'degraded'; } + if (checks.every((check) => check.status === 'skipped')) { + return 'skipped'; + } return 'ok'; } @@ -146,7 +151,10 @@ function shouldDegradeCliContractPath(path: string, message: string): boolean { return false; } -function resolveCheckTimeoutMs(check: Pick, timeoutMs: number): number { +function resolveCheckTimeoutMs( + check: Pick, + timeoutMs: number, +): number { if (check.timeoutMs === undefined) { return timeoutMs; } @@ -160,7 +168,12 @@ function resolveCliContractTimeoutMs(path: string, timeoutMs: number): number { return timeoutMs; } -function buildCheckUrl(baseUrl: string, check: HealthCheckDefinition, timeoutMs: number, cacheBustValue?: number): string { +function buildCheckUrl( + baseUrl: string, + check: HealthCheckDefinition, + timeoutMs: number, + cacheBustValue?: number, +): string { const url = new URL(check.path, baseUrl); url.searchParams.set('timeoutMs', String(timeoutMs)); if (typeof cacheBustValue === 'number') { @@ -210,9 +223,12 @@ async function runCliContractCheck( const checkTimeoutMs = resolveCliContractTimeoutMs(path, params.timeoutMs); const syntheticCheck = { ...check, path }; try { - const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, syntheticCheck, checkTimeoutMs, cacheBustValue), { - signal: AbortSignal.timeout(checkTimeoutMs), - }); + const response = await params.fetchImpl( + buildCheckUrl(params.baseUrl, syntheticCheck, checkTimeoutMs, cacheBustValue), + { + signal: AbortSignal.timeout(checkTimeoutMs), + }, + ); const body = (await response.json().catch(() => ({}))) as { success?: boolean; status?: string; @@ -275,9 +291,12 @@ async function runSingleCheck( const cacheBustValue = params.cacheBust ? startedAt : undefined; try { - const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, check, timeoutMs, cacheBustValue), { - signal: AbortSignal.timeout(timeoutMs), - }); + const response = await params.fetchImpl( + buildCheckUrl(params.baseUrl, check, timeoutMs, cacheBustValue), + { + signal: AbortSignal.timeout(timeoutMs), + }, + ); const body = (await response.json().catch(() => ({}))) as { success?: boolean; data?: unknown; @@ -300,19 +319,31 @@ async function runSingleCheck( } const count = typeof body.meta?.total === 'number' ? body.meta.total : toCount(body.data); - const shapeOk = hasRequiredRepresentativeFields(body.data, check.collectionKey, check.requiredFields); + const shapeOk = hasRequiredRepresentativeFields( + body.data, + check.collectionKey, + check.requiredFields, + ); const slowThresholdMs = params.slowThresholdMs || DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS; const slow = slowThresholdMs > 0 && durationMs > slowThresholdMs; - const status: HealthCheckStatus = count === 0 || !shapeOk || slow ? 'degraded' : 'ok'; + const optionalEmpty = check.allowEmpty === true && count === 0; + const status: HealthCheckStatus = slow + ? 'degraded' + : optionalEmpty + ? 'skipped' + : count === null || count === 0 || !shapeOk + ? 'degraded' + : 'ok'; const first = params.includeSamples ? toFirstName(body.data) : undefined; - const message = - slow - ? `slow response: ${durationMs}ms > ${slowThresholdMs}ms` - : !shapeOk && count !== 0 - ? `response missing required fields: ${check.requiredFields!.join(', ')}` + const message = slow + ? `slow response: ${durationMs}ms > ${slowThresholdMs}ms` + : optionalEmpty + ? 'optional data unavailable' : count === null - ? 'response ok' - : `${count} item(s) returned`; + ? 'response count unavailable' + : !shapeOk && count !== 0 + ? `response missing required fields: ${check.requiredFields!.join(', ')}` + : `${count} item(s) returned`; return { id: check.id, @@ -345,7 +376,13 @@ export async function runHealthChecks(params: RunHealthChecksParams): Promise 0 ? Math.trunc(params.slowThresholdMs) : DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS; - const cacheKey = createCacheKey({ ...params, mode, timeoutMs, slowThresholdMs, baseUrl: params.baseUrl }); + const cacheKey = createCacheKey({ + ...params, + mode, + timeoutMs, + slowThresholdMs, + baseUrl: params.baseUrl, + }); const cached = healthCheckCache.get(cacheKey); const startedAt = now(); @@ -356,7 +393,10 @@ export async function runHealthChecks(params: RunHealthChecksParams): Promise + const checks = await mapWithConcurrency( + selectChecks(params), + DEFAULT_HEALTH_CHECK_CONCURRENCY, + (check) => runSingleCheck(check, { baseUrl: params.baseUrl, fetchImpl, diff --git a/tests/api/health-checks.test.ts b/tests/api/health-checks.test.ts index 79eed195..6eb5f47a 100644 --- a/tests/api/health-checks.test.ts +++ b/tests/api/health-checks.test.ts @@ -98,22 +98,46 @@ describe('runHealthChecks', () => { expect.stringContaining('/api/lottemart/products?'), expect.any(Object), ); - const oliveyoungCall = fetchImpl.mock.calls.find((call) => String(call[0]).includes('/api/oliveyoung/products?')); + const oliveyoungCall = fetchImpl.mock.calls.find((call) => + String(call[0]).includes('/api/oliveyoung/products?'), + ); expect(String(oliveyoungCall?.[0])).toContain('timeoutMs=5000'); }); it('full 모드에서 quick과 deep 체크를 함께 실행한다', async () => { + const representative = { + id: 'A1', + name: '상품', + productName: '상품', + itemCode: 'A1', + itemName: '상품', + goodsNumber: 'A1', + goodsName: '상품', + pluCd: 'A1', + productNo: 'A1', + storeCode: 'S1', + storeName: '매장', + theaterCode: 'T1', + theaterName: '극장', + }; const fetchImpl = vi.fn((input: RequestInfo | URL) => Promise.resolve( String(input).includes('/health') ? jsonResponse({ status: 'ok' }) - : String(input).includes('/inventory') - ? jsonResponse({ - success: true, - data: { inventory: { products: [{ name: '상품' }], items: [{ name: '상품' }] } }, - meta: { total: 1 }, - }) - : jsonResponse({ success: true, data: { products: [{ name: '상품' }] }, meta: { total: 1 } }), + : jsonResponse({ + success: true, + data: { + products: [representative], + stores: [representative], + theaters: [representative], + inventory: { + products: [representative], + items: [representative], + stores: [representative], + }, + }, + meta: { total: 1 }, + }), ), ); @@ -142,7 +166,11 @@ describe('runHealthChecks', () => { return String(input).includes('/health') ? jsonResponse({ status: 'ok' }) - : jsonResponse({ success: true, data: { products: [{ name: '상품' }] }, meta: { total: 1 } }); + : jsonResponse({ + success: true, + data: { products: [{ name: '상품' }] }, + meta: { total: 1 }, + }); }); const result = await runHealthChecks({ @@ -281,6 +309,64 @@ describe('runHealthChecks', () => { expect(String(fetchImpl.mock.calls[0][0])).toContain('storeCheck=false'); }); + it('GS25 inventory stores 응답에서 개수와 매장 이름을 읽는다', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce( + jsonResponse({ + success: true, + data: { + inventory: { + stores: [{ storeCode: 'G1', storeName: '강남점' }], + }, + }, + }), + ); + + const result = await runHealthChecks({ + baseUrl: 'https://example.com', + check: 'gs25.inventory', + mode: 'deep', + fetchImpl, + now: () => 1000, + fresh: true, + includeSamples: true, + }); + + expect(result.checks[0]).toEqual( + expect.objectContaining({ + status: 'ok', + message: '1 item(s) returned', + sample: { first: '강남점' }, + }), + ); + }); + + it('이마트24 inventory의 top-level stores 응답을 읽는다', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce( + jsonResponse({ + success: true, + data: { + stores: [{ storeCode: 'E1', storeName: '강남점' }], + }, + }), + ); + + const result = await runHealthChecks({ + baseUrl: 'https://example.com', + check: 'emart24.inventory', + mode: 'deep', + fetchImpl, + now: () => 1000, + fresh: true, + }); + + expect(result.checks[0]).toEqual( + expect.objectContaining({ + status: 'ok', + message: '1 item(s) returned', + }), + ); + }); + it('CU 재고 조회 Request Blocked 400은 degraded로 처리한다', async () => { const fetchImpl = vi.fn().mockResolvedValueOnce( jsonResponse( @@ -315,7 +401,7 @@ describe('runHealthChecks', () => { ); }); - it('inventory 컬렉션 값이 배열이 아니면 빈 컬렉션으로 처리한다', async () => { + it('필수 inventory 컬렉션 값이 배열이 아니면 degraded로 처리한다', async () => { const fetchImpl = vi.fn().mockResolvedValueOnce( jsonResponse({ success: true, @@ -338,14 +424,18 @@ describe('runHealthChecks', () => { expect(result.checks[0]).toEqual( expect.objectContaining({ - status: 'ok', - message: 'response ok', + status: 'degraded', + message: 'response count unavailable', }), ); }); it('느린 성공 응답은 degraded로 표시한다', async () => { - const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse({ success: true, data: { products: [{ name: '상품' }] } })); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ success: true, data: { products: [{ name: '상품' }] } }), + ); const timestamps = [0, 1000, 7000, 7000]; const result = await runHealthChecks({ @@ -368,7 +458,11 @@ describe('runHealthChecks', () => { }); it('cacheBust가 켜지면 체크 URL에 캐시 우회 파라미터를 붙인다', async () => { - const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse({ success: true, data: { products: [{ name: '상품' }] } })); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ success: true, data: { products: [{ name: '상품' }] } }), + ); await runHealthChecks({ baseUrl: 'https://example.com', @@ -466,7 +560,9 @@ describe('runHealthChecks', () => { if (this !== globalThis) { throw new Error('invalid fetch this'); } - return Promise.resolve(jsonResponse({ success: true, data: { products: [{ name: '상품' }] } })); + return Promise.resolve( + jsonResponse({ success: true, data: { products: [{ name: '상품' }] } }), + ); }); vi.stubGlobal('fetch', globalFetch); @@ -482,7 +578,9 @@ describe('runHealthChecks', () => { }); it('빈 결과는 degraded 상태로 집계한다', async () => { - const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse({ success: true, data: { products: [] } })); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ success: true, data: { products: [] } })); const result = await runHealthChecks({ baseUrl: 'https://example.com', @@ -501,8 +599,10 @@ describe('runHealthChecks', () => { ); }); - it('카운트를 알 수 없는 성공 응답은 ok로 처리한다', async () => { - const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse({ success: true, data: { pong: true } })); + it('카운트를 알 수 없는 성공 응답은 degraded로 처리한다', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ success: true, data: { pong: true } })); const result = await runHealthChecks({ baseUrl: 'https://example.com', @@ -512,12 +612,38 @@ describe('runHealthChecks', () => { fresh: true, }); - expect(result.status).toBe('ok'); - expect(result.checks[0].message).toBe('response ok'); + expect(result.status).toBe('degraded'); + expect(result.checks[0].message).toBe('response count unavailable'); + }); + + it('선택적 인기 검색어가 비면 skipped로 처리한다', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ success: true, data: { available: false, count: 0, keywords: [] } }), + ); + + const result = await runHealthChecks({ + baseUrl: 'https://example.com', + check: 'seveneleven.popwords', + fetchImpl, + now: () => 1000, + fresh: true, + }); + + expect(result.status).toBe('skipped'); + expect(result.checks[0]).toEqual( + expect.objectContaining({ + status: 'skipped', + message: 'optional data unavailable', + }), + ); }); it('data.count 값을 결과 개수로 사용한다', async () => { - const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse({ success: true, data: { count: 2 } })); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ success: true, data: { count: 2 } })); const result = await runHealthChecks({ baseUrl: 'https://example.com', @@ -527,7 +653,12 @@ describe('runHealthChecks', () => { fresh: true, }); - expect(result.checks[0].message).toBe('2 item(s) returned'); + expect(result.checks[0]).toEqual( + expect.objectContaining({ + status: 'degraded', + message: expect.stringContaining('response missing required fields'), + }), + ); }); it('대표 필드가 숫자로 내려와도 shape를 통과한다', async () => { @@ -572,7 +703,9 @@ describe('runHealthChecks', () => { it('대표 컬렉션 필드가 바뀌면 degraded 메시지를 반환한다', async () => { const fetchImpl = vi .fn() - .mockResolvedValueOnce(jsonResponse({ success: true, data: { products: [{ unexpected: 'value' }] } })); + .mockResolvedValueOnce( + jsonResponse({ success: true, data: { products: [{ unexpected: 'value' }] } }), + ); const result = await runHealthChecks({ baseUrl: 'https://example.com', @@ -857,7 +990,9 @@ describe('runHealthChecks', () => { }); it('timeoutMs를 기본값과 최대값으로 보정한다', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ success: true, data: { products: [{ name: '상품' }] } })); + const fetchImpl = vi + .fn() + .mockResolvedValue(jsonResponse({ success: true, data: { products: [{ name: '상품' }] } })); await runHealthChecks({ baseUrl: 'https://example.com', @@ -881,7 +1016,11 @@ describe('runHealthChecks', () => { }); it('timeoutMs가 1보다 작으면 기본값으로 보정한다', async () => { - const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse({ success: true, data: { products: [{ name: '상품' }] } })); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ success: true, data: { products: [{ name: '상품' }] } }), + ); await runHealthChecks({ baseUrl: 'https://example.com', diff --git a/tests/app/app-health-checks.test.ts b/tests/app/app-health-checks.test.ts index 45a103ce..1d6d2f7f 100644 --- a/tests/app/app-health-checks.test.ts +++ b/tests/app/app-health-checks.test.ts @@ -114,7 +114,9 @@ describe('GET /api/health/checks', () => { ); expect(res.status).toBe(200); - expect(String(mockFetch.mock.calls[0][0])).toMatch(/^https:\/\/daiso-mcp\.example\.workers\.dev\/api\/lottemart\/products/); + expect(String(mockFetch.mock.calls[0][0])).toMatch( + /^https:\/\/daiso-mcp\.example\.workers\.dev\/api\/lottemart\/products/, + ); }); it('기본 internal transport는 같은 앱으로 내부 체크를 dispatch한다', async () => { @@ -164,7 +166,9 @@ describe('GET /api/health/checks', () => { meta: { total: 1 }, }), ) - .mockResolvedValueOnce(jsonResponse({ success: false, error: { message: 'upstream fail' } }, 500)); + .mockResolvedValueOnce( + jsonResponse({ success: false, error: { message: 'upstream fail' } }, 500), + ); const res = await app.request( '/api/health/checks?service=gs25&fresh=true&transport=network', @@ -179,7 +183,10 @@ describe('GET /api/health/checks', () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.status).toBe('fail'); - expect(data.checks.map((check: { id: string }) => check.id)).toEqual(['gs25.products', 'gs25.stores']); + expect(data.checks.map((check: { id: string }) => check.id)).toEqual([ + 'gs25.products', + 'gs25.stores', + ]); expect(data.checks.map((check: { status: string }) => check.status)).toEqual(['ok', 'fail']); }); @@ -221,7 +228,9 @@ describe('GET /api/health/checks', () => { jsonResponse( { success: false, - error: { message: 'API 요청 실패: 403 Forbidden - 403 Forbidden' }, + error: { + message: 'API 요청 실패: 403 Forbidden - 403 Forbidden', + }, }, 502, ), @@ -326,7 +335,9 @@ describe('GET /api/health/checks', () => { jsonResponse( { success: false, - error: { message: 'API 요청 실패: 403 Forbidden - 403 Forbidden' }, + error: { + message: 'API 요청 실패: 403 Forbidden - 403 Forbidden', + }, }, 502, ), @@ -361,13 +372,20 @@ describe('GET /api/health/checks', () => { ? jsonResponse( { success: false, - error: { message: 'API 요청 실패: 403 Forbidden - 403 Forbidden' }, + error: { + message: + 'API 요청 실패: 403 Forbidden - 403 Forbidden', + }, }, 502, ) : String(input).includes('/health') ? jsonResponse({ status: 'ok' }) - : jsonResponse({ success: true, data: { products: [{ name: '상품' }] }, meta: { total: 1 } }), + : jsonResponse({ + success: true, + data: { products: [{ name: '상품' }] }, + meta: { total: 1 }, + }), ), ); @@ -409,7 +427,11 @@ describe('GET /api/health/checks', () => { ) : String(input).includes('/health') ? jsonResponse({ status: 'ok' }) - : jsonResponse({ success: true, data: { products: [{ name: '상품' }] }, meta: { total: 1 } }), + : jsonResponse({ + success: true, + data: { products: [{ name: '상품' }] }, + meta: { total: 1 }, + }), ), ); @@ -476,8 +498,16 @@ describe('GET /api/health/checks', () => { }; const env = { HEALTH_CHECK_SECRET: 'test-secret', HEALTH_CHECK_TRANSPORT: 'network' }; - const first = await app.request('/api/health/checks?check=daiso.products&fresh=true', requestInit, env); - const second = await app.request('/api/health/checks?check=daiso.products&fresh=true', requestInit, env); + const first = await app.request( + '/api/health/checks?check=daiso.products&fresh=true', + requestInit, + env, + ); + const second = await app.request( + '/api/health/checks?check=daiso.products&fresh=true', + requestInit, + env, + ); expect(first.status).toBe(200); expect(second.status).toBe(200); @@ -502,8 +532,16 @@ describe('GET /api/health/checks', () => { }; const env = { HEALTH_CHECK_SECRET: 'test-secret', HEALTH_CHECK_TRANSPORT: 'network' }; - const first = await app.request('/api/health/checks?check=daiso.products&fresh=true', requestInit, env); - const second = await app.request('/api/health/checks?check=daiso.products&fresh=true', requestInit, env); + const first = await app.request( + '/api/health/checks?check=daiso.products&fresh=true', + requestInit, + env, + ); + const second = await app.request( + '/api/health/checks?check=daiso.products&fresh=true', + requestInit, + env, + ); expect(first.status).toBe(200); expect(second.status).toBe(200); @@ -531,11 +569,37 @@ describe('GET /api/health/checks', () => { }); it('deep 모드와 y 플래그를 파싱하고 CLI 계약 체크를 실행한다', async () => { + const representative = { + id: '1', + code: '1', + name: '상품', + productCode: 'P1', + productName: '상품', + branchCode: 'B1', + branchName: '강남점', + storeCode: 'S1', + storeName: '강남점', + theaterCode: 'T1', + theaterName: '강남점', + }; mockFetch.mockImplementation((input: RequestInfo | URL) => Promise.resolve( String(input).includes('/health') ? jsonResponse({ status: 'ok' }) - : jsonResponse({ success: true, data: { products: [{ name: '상품' }] }, meta: { total: 1 } }), + : jsonResponse({ + success: true, + data: { + products: [representative], + stores: [representative], + theaters: [representative], + inventory: { + products: [representative], + items: [representative], + stores: [representative], + }, + }, + meta: { total: 1 }, + }), ), ); @@ -572,7 +636,11 @@ describe('GET /api/health/checks', () => { Promise.resolve( String(input).includes('/health') ? jsonResponse({ status: 'ok' }) - : jsonResponse({ success: true, data: { products: [{ name: '상품' }] }, meta: { total: 1 } }), + : jsonResponse({ + success: true, + data: { products: [{ name: '상품' }] }, + meta: { total: 1 }, + }), ), ); @@ -591,11 +659,15 @@ describe('GET /api/health/checks', () => { const data = await res.json(); expect(data.filters.mode).toBe('full'); expect(mockFetch).toHaveBeenCalled(); - expect(String(mockFetch.mock.calls[0][0])).toMatch(/^https:\/\/daiso-mcp\.example\.workers\.dev\//); - expect(String(mockFetch.mock.calls[0][0])).toContain('_healthCheck='); - expect(new Headers((mockFetch.mock.calls[0][1] as RequestInit | undefined)?.headers).get('x-health-check-key')).toBe( - 'test-secret', + expect(String(mockFetch.mock.calls[0][0])).toMatch( + /^https:\/\/daiso-mcp\.example\.workers\.dev\//, ); + expect(String(mockFetch.mock.calls[0][0])).toContain('_healthCheck='); + expect( + new Headers((mockFetch.mock.calls[0][1] as RequestInit | undefined)?.headers).get( + 'x-health-check-key', + ), + ).toBe('test-secret'); }); it('baseUrl 쿼리가 있으면 헬스 체크 기준 URL로 우선 사용한다', async () => { @@ -621,9 +693,13 @@ describe('GET /api/health/checks', () => { ); expect(res.status).toBe(200); - expect(String(mockFetch.mock.calls[0][0])).toMatch(/^https:\/\/probe\.example\.com\/api\/daiso\/products/); + expect(String(mockFetch.mock.calls[0][0])).toMatch( + /^https:\/\/probe\.example\.com\/api\/daiso\/products/, + ); expect( - new Headers((mockFetch.mock.calls[0][1] as RequestInit | undefined)?.headers).get('x-health-check-key'), + new Headers((mockFetch.mock.calls[0][1] as RequestInit | undefined)?.headers).get( + 'x-health-check-key', + ), ).toBeNull(); }); From 3b537d5d08e4efd72a25fc318aca3b1b0d0508f8 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Tue, 28 Jul 2026 17:08:28 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20GS25=20=EC=9E=AC=EA=B3=A0=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=9E=A5=EC=95=A0=EB=A5=BC=20=EB=AA=85?= =?UTF-8?q?=EC=8B=9C=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/sync-worker-secrets.yml | 2 + src/api/gs25Handlers.ts | 10 +++ src/api/response.ts | 1 + src/index.ts | 1 + src/services/gs25/client.ts | 46 +++-------- src/services/gs25/errors.ts | 16 ++++ src/services/gs25/index.ts | 13 ++- src/services/gs25/storeStockTransport.ts | 71 ++++++++++++++++ src/services/gs25/tools/checkInventory.ts | 8 ++ src/services/gs25/tools/findNearbyStores.ts | 6 ++ tests/api/gs25-handlers.test.ts | 91 +++++++++++++++++---- tests/services/gs25/client.test.ts | 51 +++++++++--- 12 files changed, 254 insertions(+), 62 deletions(-) create mode 100644 src/services/gs25/errors.ts create mode 100644 src/services/gs25/storeStockTransport.ts diff --git a/.github/workflows/sync-worker-secrets.yml b/.github/workflows/sync-worker-secrets.yml index 3466bd4e..55919a43 100644 --- a/.github/workflows/sync-worker-secrets.yml +++ b/.github/workflows/sync-worker-secrets.yml @@ -28,6 +28,7 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} ZYTE_API_KEY: ${{ secrets.ZYTE_API_KEY }} + GS25_API_KEY: ${{ secrets.GS25_API_KEY }} GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} NAVER_CLIENT_ID: ${{ secrets.NAVER_CLIENT_ID }} NAVER_CLIENT_SECRET: ${{ secrets.NAVER_CLIENT_SECRET }} @@ -48,6 +49,7 @@ jobs: } put_secret_if_set ZYTE_API_KEY "$ZYTE_API_KEY" + put_secret_if_set GS25_API_KEY "$GS25_API_KEY" put_secret_if_set GOOGLE_MAPS_API_KEY "$GOOGLE_MAPS_API_KEY" put_secret_if_set NAVER_CLIENT_ID "$NAVER_CLIENT_ID" put_secret_if_set NAVER_CLIENT_SECRET "$NAVER_CLIENT_SECRET" diff --git a/src/api/gs25Handlers.ts b/src/api/gs25Handlers.ts index 7e74758a..fd9e82db 100644 --- a/src/api/gs25Handlers.ts +++ b/src/api/gs25Handlers.ts @@ -14,6 +14,7 @@ import { selectGs25StoresForKeyword, sortGs25Stores, } from '../services/gs25/client.js'; +import { isGs25UpstreamUnavailableError } from '../services/gs25/errors.js'; const GS25_FALLBACK_STORE_LOOKUP_ITEM_CODE = '8801117752804'; @@ -62,6 +63,7 @@ export async function handleGs25FindStores(c: ApiContext) { { timeout: 20000, zyteApiKey: c.env?.ZYTE_API_KEY, + apiKey: c.env?.GS25_API_KEY, }, ); let fallbackUsed = false; @@ -84,6 +86,7 @@ export async function handleGs25FindStores(c: ApiContext) { { timeout: 20000, zyteApiKey: c.env?.ZYTE_API_KEY, + apiKey: c.env?.GS25_API_KEY, }, ); @@ -242,6 +245,7 @@ export async function handleGs25CheckInventory(c: ApiContext) { { timeout: 20000, zyteApiKey: c.env?.ZYTE_API_KEY, + apiKey: c.env?.GS25_API_KEY, }, ); @@ -284,6 +288,7 @@ export async function handleGs25CheckInventory(c: ApiContext) { { timeout: 20000, zyteApiKey: c.env?.ZYTE_API_KEY, + apiKey: c.env?.GS25_API_KEY, }, ); } else { @@ -309,6 +314,7 @@ export async function handleGs25CheckInventory(c: ApiContext) { { timeout: 20000, zyteApiKey: c.env?.ZYTE_API_KEY, + apiKey: c.env?.GS25_API_KEY, }, ); } else { @@ -324,6 +330,7 @@ export async function handleGs25CheckInventory(c: ApiContext) { { timeout: 20000, zyteApiKey: c.env?.ZYTE_API_KEY, + apiKey: c.env?.GS25_API_KEY, }, ); } @@ -378,6 +385,9 @@ export async function handleGs25CheckInventory(c: ApiContext) { }, }); } catch (error) { + if (isGs25UpstreamUnavailableError(error)) { + return errorResponse(c, 'GS25_UPSTREAM_UNAVAILABLE', error.message, 503); + } const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'GS25_INVENTORY_CHECK_FAILED', message, 500); } diff --git a/src/api/response.ts b/src/api/response.ts index aeea8f9f..3d0ed1c2 100644 --- a/src/api/response.ts +++ b/src/api/response.ts @@ -8,6 +8,7 @@ import { toStandardErrorDiagnostics } from '../core/errors.js'; export interface AppBindings { DAILY_RATE_LIMITER?: DurableObjectNamespace; ZYTE_API_KEY?: string; + GS25_API_KEY?: string; GOOGLE_MAPS_API_KEY?: string; NAVER_CLIENT_ID?: string; NAVER_CLIENT_SECRET?: string; diff --git a/src/index.ts b/src/index.ts index d7f3d001..d13f3b11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -81,6 +81,7 @@ const createRegistry = (bindings?: AppBindings) => { createGs25Service({ googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY, zyteApiKey: bindings?.ZYTE_API_KEY, + apiKey: bindings?.GS25_API_KEY, }), () => createSevenElevenService({ zyteApiKey: bindings?.ZYTE_API_KEY }), createCompareService, diff --git a/src/services/gs25/client.ts b/src/services/gs25/client.ts index b4d89edd..f6bf0979 100644 --- a/src/services/gs25/client.ts +++ b/src/services/gs25/client.ts @@ -4,9 +4,9 @@ /* c8 ignore start */ import { fetchJson, fetchWithTimeout, HttpError } from '../../utils/http.js'; -import { decodeZyteHttpBody, requestByZyte } from '../../utils/zyte.js'; import { GS25_API } from './api.js'; import { normalizeStore, toNumber } from './storeUtils.js'; +import { fetchGs25StoreStockResponse } from './storeStockTransport.js'; import type { Gs25Store, Gs25StoreStockResponse } from './types.js'; export { @@ -27,6 +27,7 @@ interface RequestOptions { timeout?: number; googleMapsApiKey?: string; zyteApiKey?: string; + apiKey?: string; } interface FetchGs25StoresParams { @@ -142,36 +143,6 @@ function normalizeGs25WebStore(raw: Gs25WebLocationStore): Gs25Store { }; } -async function fetchGs25StoreStock( - url: string, - options: RequestOptions, -): Promise { - try { - return await fetchJson(url, { - ...GS25_DEFAULT_FETCH_OPTIONS, - method: 'GET', - timeout: options.timeout, - headers: GS25_DEFAULT_HEADERS, - }); - } catch (error) { - const zyteApiKey = options.zyteApiKey?.trim(); - if (!(error instanceof HttpError) || error.status !== 403 || !zyteApiKey) { - throw error; - } - - const result = await requestByZyte({ - apiKey: zyteApiKey, - url, - method: 'GET', - timeout: options.timeout, - retries: 1, - headers: Object.entries(GS25_DEFAULT_HEADERS).map(([name, value]) => ({ name, value })), - tags: { service: 'gs25' }, - }); - return decodeZyteHttpBody(result); - } -} - function buildCacheKey( params: Required> & Pick, @@ -407,10 +378,15 @@ export async function fetchGs25Stores( endpoint.searchParams.set('isGs25DlvyStoreSelected', 'N'); } - const body = await fetchGs25StoreStock(endpoint.toString(), { - timeout, - zyteApiKey: options.zyteApiKey, - }); + const body = await fetchGs25StoreStockResponse( + endpoint.toString(), + { + timeout, + zyteApiKey: options.zyteApiKey, + apiKey: options.apiKey, + }, + GS25_DEFAULT_HEADERS, + ); const stores = (body.stores || []) .map(normalizeStore) diff --git a/src/services/gs25/errors.ts b/src/services/gs25/errors.ts new file mode 100644 index 00000000..dcd00a4c --- /dev/null +++ b/src/services/gs25/errors.ts @@ -0,0 +1,16 @@ +/** + * GS25 원본 서비스 장애 오류 + */ + +export class Gs25UpstreamUnavailableError extends Error { + constructor() { + super('GS25 재고 서비스 인증을 사용할 수 없습니다. 잠시 후 다시 시도해주세요.'); + this.name = 'Gs25UpstreamUnavailableError'; + } +} + +export function isGs25UpstreamUnavailableError( + error: unknown, +): error is Gs25UpstreamUnavailableError { + return error instanceof Gs25UpstreamUnavailableError; +} diff --git a/src/services/gs25/index.ts b/src/services/gs25/index.ts index 6508836d..2310f2b3 100644 --- a/src/services/gs25/index.ts +++ b/src/services/gs25/index.ts @@ -18,6 +18,7 @@ const GS25_METADATA: ServiceMetadata = { interface Gs25ServiceOptions { googleMapsApiKey?: string; zyteApiKey?: string; + apiKey?: string; } class Gs25Service implements ServiceProvider { @@ -27,9 +28,17 @@ class Gs25Service implements ServiceProvider { getTools(): ToolRegistration[] { return [ - createFindNearbyStoresTool(this.options.googleMapsApiKey, this.options.zyteApiKey), + createFindNearbyStoresTool( + this.options.googleMapsApiKey, + this.options.zyteApiKey, + this.options.apiKey, + ), createSearchProductsTool(this.options.zyteApiKey), - createCheckInventoryTool(this.options.googleMapsApiKey, this.options.zyteApiKey), + createCheckInventoryTool( + this.options.googleMapsApiKey, + this.options.zyteApiKey, + this.options.apiKey, + ), ]; } } diff --git a/src/services/gs25/storeStockTransport.ts b/src/services/gs25/storeStockTransport.ts new file mode 100644 index 00000000..38790370 --- /dev/null +++ b/src/services/gs25/storeStockTransport.ts @@ -0,0 +1,71 @@ +/** + * GS25 재고 원본 요청과 Zyte 대체 경로 + */ + +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'; + +interface StoreStockTransportOptions { + timeout?: number; + zyteApiKey?: string; + apiKey?: string; +} + +function withApiKey(headers: Record, apiKey?: string): Record { + const normalizedApiKey = apiKey?.trim(); + return normalizedApiKey ? { ...headers, 'Api-Key': normalizedApiKey } : headers; +} + +function isAuthenticationStatus(status?: number): boolean { + return status === 401 || status === 403; +} + +export async function fetchGs25StoreStockResponse( + url: string, + options: StoreStockTransportOptions, + headers: Record, +): Promise { + const requestHeaders = withApiKey(headers, options.apiKey); + + try { + return await fetchJson(url, { + method: 'GET', + timeout: options.timeout, + retries: 1, + retryDelayMs: 250, + headers: requestHeaders, + }); + } catch (error) { + if (!(error instanceof HttpError) || !isAuthenticationStatus(error.status)) { + 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(); + } + } +} diff --git a/src/services/gs25/tools/checkInventory.ts b/src/services/gs25/tools/checkInventory.ts index bdc80aea..f828e346 100644 --- a/src/services/gs25/tools/checkInventory.ts +++ b/src/services/gs25/tools/checkInventory.ts @@ -30,6 +30,7 @@ interface CheckInventoryArgs { timeoutMs?: number; googleMapsApiKey?: string; zyteApiKey?: string; + apiKey?: string; } async function checkInventory(args: CheckInventoryArgs): Promise { @@ -44,6 +45,7 @@ async function checkInventory(args: CheckInventoryArgs): Promise Promise, }; } diff --git a/src/services/gs25/tools/findNearbyStores.ts b/src/services/gs25/tools/findNearbyStores.ts index 3dea4f4f..a8db74e2 100644 --- a/src/services/gs25/tools/findNearbyStores.ts +++ b/src/services/gs25/tools/findNearbyStores.ts @@ -28,6 +28,7 @@ interface FindNearbyStoresArgs { timeoutMs?: number; googleMapsApiKey?: string; zyteApiKey?: string; + apiKey?: string; } async function findNearbyStores(args: FindNearbyStoresArgs): Promise { @@ -40,6 +41,7 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise Promise, }; } diff --git a/tests/api/gs25-handlers.test.ts b/tests/api/gs25-handlers.test.ts index b1154805..286369b3 100644 --- a/tests/api/gs25-handlers.test.ts +++ b/tests/api/gs25-handlers.test.ts @@ -61,14 +61,11 @@ describe('handleGs25FindStores', () => { mockFetch .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))) .mockResolvedValueOnce( - new Response( - '
', - { - headers: { - 'Set-Cookie': 'JSESSIONID=session-id; Path=/; HttpOnly', - }, + new Response('
', { + headers: { + 'Set-Cookie': 'JSESSIONID=session-id; Path=/; HttpOnly', }, - ), + }), ) .mockResolvedValueOnce( new Response( @@ -117,7 +114,11 @@ describe('handleGs25FindStores', () => { ), ) .mockResolvedValueOnce( - new Response(JSON.stringify({ stores: [{ storeCode: '1', storeName: '강남역점', storeAddress: '서울 강남구' }] })), + new Response( + JSON.stringify({ + stores: [{ storeCode: '1', storeName: '강남역점', storeAddress: '서울 강남구' }], + }), + ), ); await handleGs25FindStores(ctx); @@ -277,6 +278,41 @@ describe('handleGs25SearchProducts', () => { }); describe('handleGs25CheckInventory', () => { + it('GS25 재고 인증이 거부되면 명시적인 503을 반환한다', async () => { + mockFetch.mockResolvedValueOnce( + new Response('authentication required', { status: 401, statusText: 'Unauthorized' }), + ); + + const ctx = createMockContext({ itemCode: '123' }); + await handleGs25CheckInventory(ctx); + + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: { + code: 'GS25_UPSTREAM_UNAVAILABLE', + message: 'GS25 재고 서비스 인증을 사용할 수 없습니다. 잠시 후 다시 시도해주세요.', + }, + }), + 503, + ); + }); + + it('Worker의 GS25 API 키를 재고 요청에 전달한다', async () => { + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))); + + const ctx = createMockContext({ itemCode: '123' }); + (ctx as { env: Record }).env = { GS25_API_KEY: 'test-gs25-key' }; + await handleGs25CheckInventory(ctx); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ 'Api-Key': 'test-gs25-key' }), + }), + ); + }); + it('keyword가 없으면 에러를 반환한다', async () => { const ctx = createMockContext({}); await handleGs25CheckInventory(ctx); @@ -358,7 +394,9 @@ describe('handleGs25CheckInventory', () => { new Response( JSON.stringify({ SearchQueryResult: { - Collection: [{ Documentset: { Document: [{ field: { itemCode: '123', itemName: '오감자' } }] } }], + Collection: [ + { Documentset: { Document: [{ field: { itemCode: '123', itemName: '오감자' } }] } }, + ], }, }), ), @@ -397,7 +435,9 @@ describe('handleGs25CheckInventory', () => { const zyteBody = Buffer.from( JSON.stringify({ SearchQueryResult: { - Collection: [{ Documentset: { Document: [{ field: { itemCode: '123', itemName: '오감자' } }] } }], + Collection: [ + { Documentset: { Document: [{ field: { itemCode: '123', itemName: '오감자' } }] } }, + ], }, }), 'utf8', @@ -455,7 +495,9 @@ describe('handleGs25CheckInventory', () => { new Response( JSON.stringify({ SearchQueryResult: { - Collection: [{ Documentset: { Document: [{ field: { itemCode: '123', itemName: '오감자' } }] } }], + Collection: [ + { Documentset: { Document: [{ field: { itemCode: '123', itemName: '오감자' } }] } }, + ], }, }), ), @@ -463,7 +505,14 @@ describe('handleGs25CheckInventory', () => { .mockResolvedValueOnce( new Response( JSON.stringify({ - stores: [{ storeCode: '1', storeName: '강남역점', searchItemName: '오감자', realStockQuantity: 1 }], + stores: [ + { + storeCode: '1', + storeName: '강남역점', + searchItemName: '오감자', + realStockQuantity: 1, + }, + ], }), ), ); @@ -487,7 +536,11 @@ describe('handleGs25CheckInventory', () => { mockFetch // 1. storeKeyword 기준 매장 조회 (지오코딩 주소 획득용) .mockResolvedValueOnce( - new Response(JSON.stringify({ stores: [{ storeCode: 'B', storeName: '강남역점', storeAddress: '서울 강남구' }] })), + new Response( + JSON.stringify({ + stores: [{ storeCode: 'B', storeName: '강남역점', storeAddress: '서울 강남구' }], + }), + ), ) // 2. 지오코딩 실패 .mockResolvedValueOnce(new Response(JSON.stringify({ status: 'ZERO_RESULTS', results: [] }))) @@ -496,14 +549,22 @@ describe('handleGs25CheckInventory', () => { new Response( JSON.stringify({ SearchQueryResult: { - Collection: [{ Documentset: { Document: [{ field: { itemCode: '123', itemName: '오감자' } }] } }], + Collection: [ + { Documentset: { Document: [{ field: { itemCode: '123', itemName: '오감자' } }] } }, + ], }, }), ), ) // 4. store/stock API (기본 좌표 사용) .mockResolvedValueOnce( - new Response(JSON.stringify({ stores: [{ storeCode: '1', storeName: '강남역점', searchItemName: '', realStockQuantity: 0 }] })), + new Response( + JSON.stringify({ + stores: [ + { storeCode: '1', storeName: '강남역점', searchItemName: '', realStockQuantity: 0 }, + ], + }), + ), ); await handleGs25CheckInventory(ctx); diff --git a/tests/services/gs25/client.test.ts b/tests/services/gs25/client.test.ts index cbb31fdc..39e95afe 100644 --- a/tests/services/gs25/client.test.ts +++ b/tests/services/gs25/client.test.ts @@ -15,6 +15,7 @@ import { geocodeGs25Address, sortGs25Stores, } from '../../../src/services/gs25/client.js'; +import { Gs25UpstreamUnavailableError } from '../../../src/services/gs25/errors.js'; import { normalizeStore, toNumber } from '../../../src/services/gs25/storeUtils.js'; const mockFetch = vi.fn(); @@ -79,11 +80,39 @@ describe('fetchGs25Stores', () => { new Response('forbidden', { status: 403, statusText: 'Forbidden' }), ); - await expect(fetchGs25Stores({ useCache: false })).rejects.toThrow( - 'API 요청 실패: 403 Forbidden - forbidden', + await expect(fetchGs25Stores({ useCache: false })).rejects.toBeInstanceOf( + Gs25UpstreamUnavailableError, ); }); + it('GS25 API 키가 있으면 store/stock 요청에만 전달한다', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ stores: [{ storeCode: 'VE463', storeName: '강남역점' }] })), + ); + + await fetchGs25Stores({ useCache: false }, { apiKey: 'test-gs25-key' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + 'Api-Key': 'test-gs25-key', + }), + }), + ); + }); + + it('store/stock 인증 거부를 명시적인 upstream unavailable 오류로 변환한다', async () => { + mockFetch.mockResolvedValueOnce( + new Response('authentication required', { status: 401, statusText: 'Unauthorized' }), + ); + + await expect(fetchGs25Stores({ useCache: false })).rejects.toMatchObject({ + name: 'Gs25UpstreamUnavailableError', + message: expect.not.stringContaining('authentication required'), + }); + }); + it('GS25 매장 목록을 정규화해 반환한다', async () => { mockFetch.mockResolvedValue( new Response( @@ -194,7 +223,12 @@ describe('fetchGs25Stores', () => { it('기본 store/stock 요청에는 차단되는 페이지 파라미터를 넣지 않는다', async () => { mockFetch.mockResolvedValue(new Response(JSON.stringify({ stores: [] }))); - await fetchGs25Stores({ itemCode: '8801056038861', latitude: 37.5, longitude: 127, useCache: false }); + await fetchGs25Stores({ + itemCode: '8801056038861', + latitude: 37.5, + longitude: 127, + useCache: false, + }); const calledUrl = new URL(String(mockFetch.mock.calls[0][0])); expect(calledUrl.searchParams.has('pageNumber')).toBe(false); @@ -217,14 +251,11 @@ describe('fetchGs25WebStores', () => { it('GS25 웹 매장 검색 응답을 정규화한다', async () => { mockFetch .mockResolvedValueOnce( - new Response( - '
', - { - headers: { - 'Set-Cookie': 'JSESSIONID=session-id; Path=/; HttpOnly', - }, + new Response('
', { + headers: { + 'Set-Cookie': 'JSESSIONID=session-id; Path=/; HttpOnly', }, - ), + }), ) .mockResolvedValueOnce( new Response( From 37ac437ea6034a87bba657623e821bbb66212c29 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Tue, 28 Jul 2026 17:10:37 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=EB=A1=AF=EB=8D=B0=EB=A7=88=ED=8A=B8?= =?UTF-8?q?=20=EC=A7=81=EC=A0=91=20=EC=A0=84=EC=86=A1=EC=9D=84=20=EC=9A=B0?= =?UTF-8?q?=EC=84=A0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/openapiSpecPathsLotteMart.ts | 12 +- src/services/lottemart/config.ts | 2 +- src/services/lottemart/session.ts | 73 ++++++-- tests/api/lottemart-handlers.test.ts | 35 +++- tests/services/lottemart/debug.test.ts | 19 +- tests/services/lottemart/session.test.ts | 214 +++++++++++++++++------ 6 files changed, 271 insertions(+), 84 deletions(-) diff --git a/src/pages/openapiSpecPathsLotteMart.ts b/src/pages/openapiSpecPathsLotteMart.ts index c70baef1..1d248f28 100644 --- a/src/pages/openapiSpecPathsLotteMart.ts +++ b/src/pages/openapiSpecPathsLotteMart.ts @@ -30,7 +30,15 @@ export const OPENAPI_PATHS_LOTTEMART = { description: '브랜드 변형 필터', schema: { type: 'string', - enum: ['lottemart', 'toysrus', 'max', 'bottlebunker', 'mealguru', 'grandgrocery', 'other'], + enum: [ + 'lottemart', + 'toysrus', + 'max', + 'bottlebunker', + 'mealguru', + 'grandgrocery', + 'other', + ], }, }, { @@ -124,7 +132,7 @@ export const OPENAPI_PATHS_LOTTEMART = { in: 'query', required: false, description: '요청 제한 시간(ms)', - schema: { type: 'integer', default: 45000, minimum: 1 }, + schema: { type: 'integer', default: 15000, minimum: 1 }, }, ], responses: { diff --git a/src/services/lottemart/config.ts b/src/services/lottemart/config.ts index 24485f64..a281b40e 100644 --- a/src/services/lottemart/config.ts +++ b/src/services/lottemart/config.ts @@ -2,4 +2,4 @@ * 롯데마트 서비스 공통 설정 */ -export const DEFAULT_LOTTEMART_TIMEOUT_MS = 45000; +export const DEFAULT_LOTTEMART_TIMEOUT_MS = 15000; diff --git a/src/services/lottemart/session.ts b/src/services/lottemart/session.ts index a568fd02..d032bcb0 100644 --- a/src/services/lottemart/session.ts +++ b/src/services/lottemart/session.ts @@ -18,6 +18,7 @@ export { } from './socketTransport.js'; const SESSION_CACHE_TTL_MS = 5 * 60 * 1000; +const FALLBACK_TIMEOUT_MS = 5000; let sessionCache: { expiresAt: number; cookie: string } | null = null; function extractSessionCookie(response: Response): string { @@ -87,22 +88,32 @@ async function fetchLotteMartResponse( timeout: number, sessionCookie: string, ): Promise { - const socketResponse = await fetchLotteMartSocketResponse(url, init, sessionCookie, timeout); - if (socketResponse) { - return socketResponse; - } + const headers = withLotteMartSessionCookie( + { + Accept: 'text/html, */*; q=0.01', + ...init.headers, + }, + sessionCookie, + ); - return fetchWithTimeout(url, { - ...init, - timeout, - headers: withLotteMartSessionCookie( - { - Accept: 'text/html, */*; q=0.01', - ...init.headers, - }, + try { + return await fetchWithTimeout(url, { + ...init, + timeout, + headers, + }); + } catch (directError) { + const socketResponse = await fetchLotteMartSocketResponse( + url, + init, sessionCookie, - ), - }); + Math.min(timeout, FALLBACK_TIMEOUT_MS), + ).catch(() => null); + if (socketResponse) { + return socketResponse; + } + throw directError; + } } function toZyteHeaders(headers: Headers): Array<{ name: string; value: string }> { @@ -162,7 +173,9 @@ export async function probeLotteMartRequest( success: response.ok, status: response.status, statusText: response.statusText, - error: response.ok ? null : new HttpError(response.status, response.statusText, bodyText).message, + error: response.ok + ? null + : new HttpError(response.status, response.statusText, bodyText).message, bodyPreview: toBodyPreview(bodyText), sessionCookie: extractSessionCookie(response) || null, }); @@ -180,7 +193,13 @@ export async function probeLotteMartRequest( if (zyteApiKey) { try { - const bodyText = await fetchLotteMartHtmlByZyte(url, init, timeout, sessionCookie, zyteApiKey); + const bodyText = await fetchLotteMartHtmlByZyte( + url, + init, + timeout, + sessionCookie, + zyteApiKey, + ); attempts.push({ used: 'zyte', success: true, @@ -237,7 +256,11 @@ export async function fetchLotteMartHtml( throw new HttpError(response.status, response.statusText, bodyText); } - if (bodyText.trim().length === 0 && sessionCookie.trim().length === 0 && cachedCookie.length > 0) { + if ( + bodyText.trim().length === 0 && + sessionCookie.trim().length === 0 && + cachedCookie.length > 0 + ) { const retried = await fetchLotteMartResponse(url, init, timeout, cachedCookie); cacheSessionCookieFromResponse(retried); const retriedBodyText = await retried.text(); @@ -251,7 +274,13 @@ export async function fetchLotteMartHtml( } catch (error) { if (zyteApiKey && error instanceof Error && !error.message.includes('Zyte')) { const fallbackCookie = await getCachedLotteMartSessionCookie(timeout); - return fetchLotteMartHtmlByZyte(url, init, timeout, fallbackCookie || sessionCookie, zyteApiKey); + return fetchLotteMartHtmlByZyte( + url, + init, + Math.min(timeout, FALLBACK_TIMEOUT_MS), + fallbackCookie || sessionCookie, + zyteApiKey, + ); } throw error; @@ -265,7 +294,13 @@ export async function fetchLotteMartPageWithSession( sessionCookie: string, zyteApiKey?: string, ): Promise { - return fetchLotteMartHtml(new URL(path, LOTTEMART_API.BASE_URL).toString(), init, timeout, sessionCookie, zyteApiKey); + return fetchLotteMartHtml( + new URL(path, LOTTEMART_API.BASE_URL).toString(), + init, + timeout, + sessionCookie, + zyteApiKey, + ); } export function __testOnlyClearLotteMartSessionCache(): void { diff --git a/tests/api/lottemart-handlers.test.ts b/tests/api/lottemart-handlers.test.ts index 1e2ec8ea..b653b769 100644 --- a/tests/api/lottemart-handlers.test.ts +++ b/tests/api/lottemart-handlers.test.ts @@ -13,7 +13,8 @@ import { } from '../../src/api/lottemartHandlers.js'; const mockFetch = vi.fn(); -const createSessionResponse = () => new Response('', { headers: { 'set-cookie': 'ASPSESSIONID=TEST; path=/' } }); +const createSessionResponse = () => + new Response('', { headers: { 'set-cookie': 'ASPSESSIONID=TEST; path=/' } }); beforeEach(() => { mockFetch.mockReset(); @@ -130,7 +131,10 @@ describe('handleLotteMartFindStores', () => { expect(ctx.json).toHaveBeenCalledWith( expect.objectContaining({ success: false, - error: { code: 'LOTTEMART_STORE_SEARCH_FAILED', message: '지원하지 않는 지역입니다: 잘못된지역' }, + error: { + code: 'LOTTEMART_STORE_SEARCH_FAILED', + message: '지원하지 않는 지역입니다: 잘못된지역', + }, }), 500, ); @@ -176,7 +180,10 @@ describe('handleLotteMartSearchProducts', () => { expect(ctx.json).toHaveBeenCalledWith( expect.objectContaining({ success: false, - error: { code: 'MISSING_STORE', message: 'storeCode 또는 storeName 중 하나를 입력해주세요.' }, + error: { + code: 'MISSING_STORE', + message: 'storeCode 또는 storeName 중 하나를 입력해주세요.', + }, }), 400, ); @@ -260,7 +267,12 @@ describe('handleLotteMartSearchProducts', () => { }); it('지원하지 않는 source는 400을 반환한다', async () => { - const ctx = createMockContext({ area: '서울', storeCode: '2301', keyword: '콜라', source: 'bad' }); + const ctx = createMockContext({ + area: '서울', + storeCode: '2301', + keyword: '콜라', + source: 'bad', + }); await handleLotteMartSearchProducts(ctx); expect(ctx.json).toHaveBeenCalledWith( @@ -285,13 +297,18 @@ describe('handleLotteMartSearchProducts', () => { products: [], }); - const ctx = createMockContext({ area: '서울', storeCode: '2301', keyword: '콜라', timeoutMs: '0' }); + const ctx = createMockContext({ + area: '서울', + storeCode: '2301', + keyword: '콜라', + timeoutMs: '0', + }); await handleLotteMartSearchProducts(ctx); expect(searchSpy).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ - timeout: 45000, + timeout: 15000, }), ); }); @@ -375,7 +392,7 @@ describe('handleLotteMartDebug', () => { method: 'POST', url: 'https://company.lottemart.com/mobiledowa/market/search_shop.asp', bodyText: 'm_area=4401', - timeout: 45000, + timeout: 15000, hasZyteApiKey: false, }, attempts: [ @@ -431,7 +448,9 @@ describe('handleLotteMartDebug', () => { }); it('예외 발생 시 debug 에러를 반환한다', async () => { - vi.spyOn(lotteMartDebug, 'probeLotteMartUpstream').mockRejectedValueOnce(new Error('debug fail')); + vi.spyOn(lotteMartDebug, 'probeLotteMartUpstream').mockRejectedValueOnce( + new Error('debug fail'), + ); const ctx = createMockContext({ target: 'stores' }); await handleLotteMartDebug(ctx); diff --git a/tests/services/lottemart/debug.test.ts b/tests/services/lottemart/debug.test.ts index 088e3cea..bd58c991 100644 --- a/tests/services/lottemart/debug.test.ts +++ b/tests/services/lottemart/debug.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; import * as session from '../../../src/services/lottemart/session.js'; -import { buildLotteMartDebugRequest, probeLotteMartUpstream } from '../../../src/services/lottemart/debug.js'; +import { + buildLotteMartDebugRequest, + probeLotteMartUpstream, +} from '../../../src/services/lottemart/debug.js'; describe('buildLotteMartDebugRequest', () => { it('market-options 요청을 만든다', () => { @@ -26,7 +29,9 @@ describe('buildLotteMartDebugRequest', () => { }); expect(request.method).toBe('POST'); - expect(request.bodyText).toBe('m_area=%EC%84%9C%EC%9A%B8&m_market=2301&m_schWord=%EA%B0%95%EB%B3%80'); + expect(request.bodyText).toBe( + 'm_area=%EC%84%9C%EC%9A%B8&m_market=2301&m_schWord=%EA%B0%95%EB%B3%80', + ); }); it('products 요청을 만든다', () => { @@ -38,7 +43,9 @@ describe('buildLotteMartDebugRequest', () => { }); expect(request.method).toBe('POST'); - expect(request.bodyText).toBe('p_area=%EA%B2%BD%EA%B8%B0&p_market=2415&p_schWord=%ED%95%AB%EC%8B%9D%EC%8A%A4'); + expect(request.bodyText).toBe( + 'p_area=%EA%B2%BD%EA%B8%B0&p_market=2415&p_schWord=%ED%95%AB%EC%8B%9D%EC%8A%A4', + ); }); it('product-page 요청을 만든다', () => { @@ -73,7 +80,9 @@ describe('buildLotteMartDebugRequest', () => { expect(marketOptions.url).toContain('p_area=%EA%B2%BD%EA%B8%B0'); expect(marketOptions.url).toContain('p_type=1'); expect(stores.bodyText).toBe('m_area=%EA%B2%BD%EA%B8%B0'); - expect(products.bodyText).toBe('p_area=%EA%B2%BD%EA%B8%B0&p_market=2415&p_schWord=%ED%95%AB%EC%8B%9D%EC%8A%A4'); + expect(products.bodyText).toBe( + 'p_area=%EA%B2%BD%EA%B8%B0&p_market=2415&p_schWord=%ED%95%AB%EC%8B%9D%EC%8A%A4', + ); expect(productPage.url).toContain('p_market=2415'); expect(productPage.url).toContain('p_schWord=%ED%95%AB%EC%8B%9D%EC%8A%A4'); expect(productPage.url).toContain('page=2'); @@ -140,7 +149,7 @@ describe('probeLotteMartUpstream', () => { 'X-Requested-With': 'XMLHttpRequest', }), }), - 45000, + 15000, '', undefined, ); diff --git a/tests/services/lottemart/session.test.ts b/tests/services/lottemart/session.test.ts index 512c124d..eca8b0e8 100644 --- a/tests/services/lottemart/session.test.ts +++ b/tests/services/lottemart/session.test.ts @@ -62,7 +62,9 @@ describe('lottemart session helpers', () => { it('빈 바디와 세션 쿠키를 받으면 같은 요청을 한 번 더 시도한다', async () => { mockFetch - .mockResolvedValueOnce(new Response('', { headers: { 'set-cookie': 'ASPSESSIONID=A; path=/' } })) + .mockResolvedValueOnce( + new Response('', { headers: { 'set-cookie': 'ASPSESSIONID=A; path=/' } }), + ) .mockResolvedValueOnce(new Response('ok')); await expect( @@ -83,7 +85,9 @@ describe('lottemart session helpers', () => { }); it('정상 HTML 응답의 세션 쿠키를 캐시에 반영한다', async () => { - mockFetch.mockResolvedValue(new Response('ok', { headers: { 'set-cookie': 'ASPSESSIONID=B; path=/' } })); + mockFetch.mockResolvedValue( + new Response('ok', { headers: { 'set-cookie': 'ASPSESSIONID=B; path=/' } }), + ); await expect( fetchLotteMartHtml( @@ -100,15 +104,29 @@ describe('lottemart session helpers', () => { }); it('강제 새로고침이면 캐시를 비운다', async () => { - mockFetch.mockResolvedValue(new Response('ok', { headers: { 'set-cookie': 'ASPSESSIONID=C; path=/' } })); - await fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''); + mockFetch.mockResolvedValue( + new Response('ok', { headers: { 'set-cookie': 'ASPSESSIONID=C; path=/' } }), + ); + await fetchLotteMartHtml( + 'https://company.lottemart.com/mobiledowa/test', + { method: 'GET' }, + 1000, + '', + ); await expect(getCachedLotteMartSessionCookie(1000, true)).resolves.toBe(''); }); it('getFreshLotteMartSessionCookie는 캐시를 비운 뒤 빈 문자열을 반환한다', async () => { - mockFetch.mockResolvedValue(new Response('ok', { headers: { 'set-cookie': 'ASPSESSIONID=C; path=/' } })); - await fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''); + mockFetch.mockResolvedValue( + new Response('ok', { headers: { 'set-cookie': 'ASPSESSIONID=C; path=/' } }), + ); + await fetchLotteMartHtml( + 'https://company.lottemart.com/mobiledowa/test', + { method: 'GET' }, + 1000, + '', + ); await expect(getFreshLotteMartSessionCookie(1000)).resolves.toBe(''); }); @@ -119,15 +137,24 @@ describe('lottemart session helpers', () => { headers.getSetCookie = () => ['ASPSESSIONID=D; path=/', 'other=value; path=/']; mockFetch.mockResolvedValue(response); - await fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''); + await fetchLotteMartHtml( + 'https://company.lottemart.com/mobiledowa/test', + { method: 'GET' }, + 1000, + '', + ); await expect(getCachedLotteMartSessionCookie(1000)).resolves.toBe('ASPSESSIONID=D'); }); it('소켓 raw 응답에 HTTP 헤더 경계가 없으면 fallback 가능하도록 null을 반환한다', async () => { expect(__testOnlyCreateLotteMartSocketResponse(new TextEncoder().encode(''))).toBeNull(); - expect(__testOnlyCreateLotteMartSocketResponse(new TextEncoder().encode('not-http'))).toBeNull(); - expect(__testOnlyCreateLotteMartSocketResponse(new TextEncoder().encode('not-http\r\n\r\nbody'))).toBeNull(); + expect( + __testOnlyCreateLotteMartSocketResponse(new TextEncoder().encode('not-http')), + ).toBeNull(); + expect( + __testOnlyCreateLotteMartSocketResponse(new TextEncoder().encode('not-http\r\n\r\nbody')), + ).toBeNull(); }); it('소켓 raw HTTP 응답을 Response로 변환한다', async () => { @@ -174,7 +201,9 @@ describe('lottemart session helpers', () => { }); it('소켓 응답을 정상 HTTP Response로 변환한다', async () => { - const raw = new TextEncoder().encode('HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nsocket ok'); + const raw = new TextEncoder().encode( + 'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nsocket ok', + ); const connect = vi.fn(() => ({ readable: new ReadableStream({ start(controller) { @@ -198,14 +227,43 @@ describe('lottemart session helpers', () => { await expect(response?.text()).resolves.toBe('socket ok'); }); - it('소켓 전송이 응답하면 일반 fetch 없이 해당 응답을 사용한다', async () => { + it('일반 fetch가 응답하면 소켓 전송을 사용하지 않는다', async () => { + mockFetch.mockResolvedValueOnce(new Response('fetch direct')); socketMocks.fetchResponse.mockResolvedValueOnce(new Response('socket direct')); await expect( - fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''), - ).resolves.toBe('socket direct'); + fetchLotteMartHtml( + 'https://company.lottemart.com/mobiledowa/test', + { method: 'GET' }, + 1000, + '', + ), + ).resolves.toBe('fetch direct'); - expect(mockFetch).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledOnce(); + expect(socketMocks.fetchResponse).not.toHaveBeenCalled(); + }); + + it('일반 fetch가 실패하면 짧은 제한 시간으로 소켓 전송을 시도한다', async () => { + mockFetch.mockRejectedValueOnce(new Error('direct unavailable')); + socketMocks.fetchResponse.mockResolvedValueOnce(new Response('socket fallback')); + + await expect( + fetchLotteMartHtml( + 'https://company.lottemart.com/mobiledowa/test', + { method: 'GET' }, + 20000, + '', + ), + ).resolves.toBe('socket fallback'); + + expect(mockFetch).toHaveBeenCalledOnce(); + expect(socketMocks.fetchResponse).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + '', + 5000, + ); }); it('소켓 쓰기나 닫기가 멈추면 null을 반환한다', async () => { @@ -282,7 +340,12 @@ describe('lottemart session helpers', () => { text: async () => 'ok', } as Response); - await fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''); + await fetchLotteMartHtml( + 'https://company.lottemart.com/mobiledowa/test', + { method: 'GET' }, + 1000, + '', + ); await expect(getCachedLotteMartSessionCookie(1000)).resolves.toBe('ASPSESSIONID=E'); }); @@ -298,7 +361,12 @@ describe('lottemart session helpers', () => { text: async () => 'ok', } as Response); - await fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''); + await fetchLotteMartHtml( + 'https://company.lottemart.com/mobiledowa/test', + { method: 'GET' }, + 1000, + '', + ); await expect(getCachedLotteMartSessionCookie(1000)).resolves.toBe(''); }); @@ -346,17 +414,31 @@ describe('lottemart session helpers', () => { mockFetch.mockResolvedValue(new Response('boom', { status: 500, statusText: 'Server Error' })); await expect( - fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''), + fetchLotteMartHtml( + 'https://company.lottemart.com/mobiledowa/test', + { method: 'GET' }, + 1000, + '', + ), ).rejects.toThrow('API 요청 실패: 500 Server Error'); }); it('재시도 응답이 실패면 HttpError를 던진다', async () => { mockFetch - .mockResolvedValueOnce(new Response('', { headers: { 'set-cookie': 'ASPSESSIONID=E; path=/' } })) - .mockResolvedValueOnce(new Response('retry boom', { status: 500, statusText: 'Server Error' })); + .mockResolvedValueOnce( + new Response('', { headers: { 'set-cookie': 'ASPSESSIONID=E; path=/' } }), + ) + .mockResolvedValueOnce( + new Response('retry boom', { status: 500, statusText: 'Server Error' }), + ); await expect( - fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''), + fetchLotteMartHtml( + 'https://company.lottemart.com/mobiledowa/test', + { method: 'GET' }, + 1000, + '', + ), ).rejects.toThrow('API 요청 실패: 500 Server Error'); }); @@ -376,7 +458,12 @@ describe('lottemart session helpers', () => { .mockResolvedValueOnce(new Response('retry ok')); await expect( - fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''), + fetchLotteMartHtml( + 'https://company.lottemart.com/mobiledowa/test', + { method: 'GET' }, + 1000, + '', + ), ).resolves.toBe('retry ok'); const retriedHeaders = mockFetch.mock.calls[1]?.[1]?.headers as Headers; @@ -396,10 +483,17 @@ describe('lottemart session helpers', () => { throw new Error('broken stream'); }, } as Response) - .mockResolvedValueOnce(new Response('retry boom', { status: 500, statusText: 'Server Error' })); + .mockResolvedValueOnce( + new Response('retry boom', { status: 500, statusText: 'Server Error' }), + ); await expect( - fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''), + fetchLotteMartHtml( + 'https://company.lottemart.com/mobiledowa/test', + { method: 'GET' }, + 1000, + '', + ), ).rejects.toThrow('API 요청 실패: 500 Server Error'); }); @@ -417,7 +511,12 @@ describe('lottemart session helpers', () => { } as Response); await expect( - fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, 'ASPSESSIONID=EXIST'), + fetchLotteMartHtml( + 'https://company.lottemart.com/mobiledowa/test', + { method: 'GET' }, + 1000, + 'ASPSESSIONID=EXIST', + ), ).rejects.toThrow('broken stream'); }); @@ -466,7 +565,9 @@ describe('lottemart session helpers', () => { }; 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' }]), + expect.arrayContaining([ + { name: 'content-type', value: 'application/x-www-form-urlencoded' }, + ]), ); }); @@ -523,15 +624,13 @@ describe('lottemart session helpers', () => { }); it('Zyte 응답 본문이 비어 있으면 에러를 던진다', async () => { - mockFetch - .mockRejectedValueOnce(new Error('The operation was aborted')) - .mockResolvedValueOnce( - new Response(JSON.stringify({ statusCode: 200 }), { - headers: { - 'Content-Type': 'application/json', - }, - }), - ); + mockFetch.mockRejectedValueOnce(new Error('The operation was aborted')).mockResolvedValueOnce( + new Response(JSON.stringify({ statusCode: 200 }), { + headers: { + 'Content-Type': 'application/json', + }, + }), + ); await expect( fetchLotteMartHtml( @@ -551,14 +650,21 @@ describe('lottemart session helpers', () => { fetchLotteMartPageWithSession('/mobiledowa/search_shop.asp', { method: 'POST' }, 1000, ''), ).resolves.toBe('page ok'); - expect(String(mockFetch.mock.calls[0]?.[0])).toBe('https://company.lottemart.com/mobiledowa/search_shop.asp'); + expect(String(mockFetch.mock.calls[0]?.[0])).toBe( + 'https://company.lottemart.com/mobiledowa/search_shop.asp', + ); }); it('probeLotteMartRequest는 direct 결과를 요약한다', async () => { mockFetch.mockResolvedValue(new Response('ok body', { status: 200, statusText: 'OK' })); await expect( - probeLotteMartRequest('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''), + probeLotteMartRequest( + 'https://company.lottemart.com/mobiledowa/test', + { method: 'GET' }, + 1000, + '', + ), ).resolves.toEqual([ expect.objectContaining({ used: 'direct', @@ -574,7 +680,12 @@ describe('lottemart session helpers', () => { mockFetch.mockResolvedValue(new Response('', { status: 500, statusText: 'Server Error' })); await expect( - probeLotteMartRequest('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''), + probeLotteMartRequest( + 'https://company.lottemart.com/mobiledowa/test', + { method: 'GET' }, + 1000, + '', + ), ).resolves.toEqual([ expect.objectContaining({ used: 'direct', @@ -589,7 +700,12 @@ describe('lottemart session helpers', () => { mockFetch.mockRejectedValue('network unavailable'); await expect( - probeLotteMartRequest('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''), + probeLotteMartRequest( + 'https://company.lottemart.com/mobiledowa/test', + { method: 'GET' }, + 1000, + '', + ), ).resolves.toEqual([ expect.objectContaining({ used: 'direct', @@ -638,7 +754,9 @@ describe('lottemart session helpers', () => { }); it('probeLotteMartRequest는 Error가 아닌 Zyte 실패도 기본 메시지로 요약한다', async () => { - mockFetch.mockResolvedValueOnce(new Response('direct ok')).mockRejectedValueOnce('zyte unavailable'); + mockFetch + .mockResolvedValueOnce(new Response('direct ok')) + .mockRejectedValueOnce('zyte unavailable'); const result = await probeLotteMartRequest( 'https://company.lottemart.com/mobiledowa/test', @@ -658,16 +776,14 @@ describe('lottemart session helpers', () => { }); 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 }), { - status: 520, - headers: { - 'Content-Type': 'application/json', - }, - }), - ); + mockFetch.mockRejectedValueOnce(new Error('The operation was aborted')).mockResolvedValueOnce( + new Response(JSON.stringify({ title: 'Website Ban', detail: 'ban', status: 520 }), { + status: 520, + headers: { + 'Content-Type': 'application/json', + }, + }), + ); const result = await probeLotteMartRequest( 'https://company.lottemart.com/mobiledowa/test', From 4eac4eb6e7f1330e06789780c48a6c4683d68c0e Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Tue, 28 Jul 2026 17:12:45 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20CGV=20=EC=97=85=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A6=BC=20=EC=9E=A5=EC=95=A0=EB=A5=BC=20503=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EA=B5=AC=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/cgvHandlers.ts | 27 +++++++++-- src/services/cgv/errors.ts | 16 +++++++ src/services/cgv/transport.ts | 26 +++++++++-- tests/api/cgv-handlers.test.ts | 67 ++++++++++++++++++++++++++-- tests/services/cgv/client.test.ts | 18 ++++---- tests/services/cgv/transport.test.ts | 46 +++++++++++++++++-- 6 files changed, 177 insertions(+), 23 deletions(-) create mode 100644 src/services/cgv/errors.ts diff --git a/src/api/cgvHandlers.ts b/src/api/cgvHandlers.ts index a60f602f..58326b76 100644 --- a/src/api/cgvHandlers.ts +++ b/src/api/cgvHandlers.ts @@ -2,7 +2,13 @@ * CGV GET API 핸들러 */ -import { fetchCgvMovies, fetchCgvTheaters, fetchCgvTimetable, toYyyymmdd } from '../services/cgv/client.js'; +import { + fetchCgvMovies, + fetchCgvTheaters, + fetchCgvTimetable, + toYyyymmdd, +} from '../services/cgv/client.js'; +import { isCgvUpstreamUnavailableError } from '../services/cgv/errors.js'; import { fetchCgvNearbyTheaters, resolveCgvNearestTheater } from '../services/cgv/location.js'; import { filterAndSortTimetable } from '../services/cgv/timetable.js'; import { type ApiContext, errorResponse, successResponse } from './response.js'; @@ -75,6 +81,9 @@ export async function handleCgvFindTheaters(c: ApiContext) { { total: sliced.length, pageSize: limit }, ); } catch (error) { + if (isCgvUpstreamUnavailableError(error)) { + return errorResponse(c, 'CGV_UPSTREAM_UNAVAILABLE', error.message, 503); + } const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'CGV_THEATER_SEARCH_FAILED', message, 500); } @@ -95,7 +104,10 @@ export async function handleCgvSearchMovies(c: ApiContext) { try { let resolvedTheater = null; - if (!theaterCode && (keyword || typeof latitude === 'number' || typeof longitude === 'number')) { + if ( + !theaterCode && + (keyword || typeof latitude === 'number' || typeof longitude === 'number') + ) { const resolved = await resolveCgvNearestTheater( { playDate, @@ -141,6 +153,9 @@ export async function handleCgvSearchMovies(c: ApiContext) { { total: movies.length }, ); } catch (error) { + if (isCgvUpstreamUnavailableError(error)) { + return errorResponse(c, 'CGV_UPSTREAM_UNAVAILABLE', error.message, 503); + } const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'CGV_MOVIE_SEARCH_FAILED', message, 500); } @@ -163,7 +178,10 @@ export async function handleCgvGetTimetable(c: ApiContext) { try { let resolvedTheater = null; - if (!theaterCode && (keyword || typeof latitude === 'number' || typeof longitude === 'number')) { + if ( + !theaterCode && + (keyword || typeof latitude === 'number' || typeof longitude === 'number') + ) { const resolved = await resolveCgvNearestTheater( { playDate, @@ -218,6 +236,9 @@ export async function handleCgvGetTimetable(c: ApiContext) { { total: filtered.length, pageSize: limit }, ); } catch (error) { + if (isCgvUpstreamUnavailableError(error)) { + return errorResponse(c, 'CGV_UPSTREAM_UNAVAILABLE', error.message, 503); + } const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'CGV_TIMETABLE_FETCH_FAILED', message, 500); } diff --git a/src/services/cgv/errors.ts b/src/services/cgv/errors.ts new file mode 100644 index 00000000..3046e10d --- /dev/null +++ b/src/services/cgv/errors.ts @@ -0,0 +1,16 @@ +/** + * CGV 원본 및 대체 전송 경로를 사용할 수 없는 상태 + */ + +export class CgvUpstreamUnavailableError extends Error { + constructor() { + super('CGV 원본 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.'); + this.name = 'CgvUpstreamUnavailableError'; + } +} + +export function isCgvUpstreamUnavailableError( + error: unknown, +): error is CgvUpstreamUnavailableError { + return error instanceof CgvUpstreamUnavailableError; +} diff --git a/src/services/cgv/transport.ts b/src/services/cgv/transport.ts index 4d082786..7d891078 100644 --- a/src/services/cgv/transport.ts +++ b/src/services/cgv/transport.ts @@ -7,6 +7,7 @@ import { createTimeoutController } from '../../utils/http.js'; import { decodeZyteHttpBody, requestByZyte } from '../../utils/zyte.js'; import { CGV_API } from './api.js'; +import { CgvUpstreamUnavailableError } from './errors.js'; function toBase64(bytes: Uint8Array): string { if (typeof Buffer !== 'undefined') { @@ -25,7 +26,11 @@ function toBase64(bytes: Uint8Array): string { throw new Error('Base64 인코딩을 지원하지 않는 런타임입니다.'); } -async function createSignature(pathname: string, bodyText: string, timestamp: string): Promise { +async function createSignature( + pathname: string, + bodyText: string, + timestamp: string, +): Promise { const payload = `${timestamp}|${pathname}|${bodyText}`; const encoder = new TextEncoder(); @@ -73,6 +78,9 @@ async function requestByZyteCgv( tags: { service: 'cgv' }, }); + if (result.statusCode === 401 || result.statusCode === 403) { + throw new CgvUpstreamUnavailableError(); + } return decodeZyteHttpBody(result); } @@ -103,8 +111,20 @@ export async function requestCgv( return await parseJsonResponse(response); } - if (response.status === 403 && zyteApiKey) { - return await requestByZyteCgv(path, searchParams, timeout, zyteApiKey); + 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 Error(`CGV API 호출 실패: ${response.status}`); diff --git a/tests/api/cgv-handlers.test.ts b/tests/api/cgv-handlers.test.ts index 0486720b..73f4e372 100644 --- a/tests/api/cgv-handlers.test.ts +++ b/tests/api/cgv-handlers.test.ts @@ -35,6 +35,24 @@ function createMockContext(query: Record = {}, env: Record { + it('CGV 원본과 Zyte를 사용할 수 없으면 명시적인 503을 반환한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('forbidden', { status: 403 })); + + const ctx = createMockContext({ playDate: '20260304' }); + await handleCgvFindTheaters(ctx); + + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: { + code: 'CGV_UPSTREAM_UNAVAILABLE', + message: 'CGV 원본 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.', + }, + }), + 503, + ); + }); + it('CGV 극장 목록을 반환한다', async () => { mockFetch.mockResolvedValue( new Response( @@ -137,7 +155,10 @@ describe('handleCgvFindTheaters', () => { ), ); - const ctx = createMockContext({ playDate: '20260315', keyword: '안산 중앙역' }, { GOOGLE_MAPS_API_KEY: 'test-google-key' }); + const ctx = createMockContext( + { playDate: '20260315', keyword: '안산 중앙역' }, + { GOOGLE_MAPS_API_KEY: 'test-google-key' }, + ); await handleCgvFindTheaters(ctx); const payload = (ctx.json as ReturnType).mock.calls[0][0] as { @@ -255,6 +276,21 @@ describe('handleCgvFindTheaters', () => { }); describe('handleCgvSearchMovies', () => { + it('CGV 원본과 Zyte를 사용할 수 없으면 명시적인 503을 반환한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('forbidden', { status: 403 })); + + const ctx = createMockContext({ playDate: '20260304', theaterCode: '0056' }); + await handleCgvSearchMovies(ctx); + + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ code: 'CGV_UPSTREAM_UNAVAILABLE' }), + }), + 503, + ); + }); + it('CGV 영화 목록을 반환한다', async () => { mockFetch.mockResolvedValue( new Response( @@ -370,7 +406,10 @@ describe('handleCgvSearchMovies', () => { ), ); - const ctx = createMockContext({ playDate: '20260315', keyword: '안산 중앙역' }, { GOOGLE_MAPS_API_KEY: 'test-google-key' }); + const ctx = createMockContext( + { playDate: '20260315', keyword: '안산 중앙역' }, + { GOOGLE_MAPS_API_KEY: 'test-google-key' }, + ); await handleCgvSearchMovies(ctx); const payload = (ctx.json as ReturnType).mock.calls[0][0] as { @@ -446,6 +485,25 @@ describe('handleCgvSearchMovies', () => { }); describe('handleCgvGetTimetable', () => { + it('CGV 원본과 Zyte를 사용할 수 없으면 명시적인 503을 반환한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('forbidden', { status: 403 })); + + const ctx = createMockContext({ + playDate: '20260304', + theaterCode: '0056', + movieCode: '30000985', + }); + await handleCgvGetTimetable(ctx); + + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ code: 'CGV_UPSTREAM_UNAVAILABLE' }), + }), + 503, + ); + }); + it('CGV 시간표를 반환한다', async () => { mockFetch.mockResolvedValue( new Response( @@ -735,7 +793,10 @@ describe('handleCgvGetTimetable', () => { ), ); - const ctx = createMockContext({ playDate: '20260315', keyword: '안산 중앙역' }, { GOOGLE_MAPS_API_KEY: 'test-google-key' }); + const ctx = createMockContext( + { playDate: '20260315', keyword: '안산 중앙역' }, + { GOOGLE_MAPS_API_KEY: 'test-google-key' }, + ); await handleCgvGetTimetable(ctx); const payload = (ctx.json as ReturnType).mock.calls[0][0] as { diff --git a/tests/services/cgv/client.test.ts b/tests/services/cgv/client.test.ts index 3d82dba9..50c80b87 100644 --- a/tests/services/cgv/client.test.ts +++ b/tests/services/cgv/client.test.ts @@ -9,6 +9,7 @@ import { fetchCgvTimetable, toYyyymmdd, } from '../../../src/services/cgv/client.js'; +import { CgvUpstreamUnavailableError } from '../../../src/services/cgv/errors.js'; const mockFetch = vi.fn(); @@ -171,9 +172,7 @@ describe('fetchCgvMovies', () => { JSON.stringify({ statusCode: 0, statusMessage: '조회 되었습니다.', - data: [ - { regnGrpCd: '01', siteList: [{ siteNo: '0056', siteNm: '강남' }] }, - ], + data: [{ regnGrpCd: '01', siteList: [{ siteNo: '0056', siteNm: '강남' }] }], }), ), ) @@ -606,8 +605,8 @@ describe('fetchCgvTimetable', () => { ), ); - await expect(fetchCgvTheaters({ zyteApiKey: 'test-key' })).rejects.toThrow( - 'Zyte API 호출 실패: 400 zyte fail', + await expect(fetchCgvTheaters({ zyteApiKey: 'test-key' })).rejects.toBeInstanceOf( + CgvUpstreamUnavailableError, ); }); @@ -623,8 +622,8 @@ describe('fetchCgvTimetable', () => { ), ); - await expect(fetchCgvTheaters({ zyteApiKey: 'test-key' })).rejects.toThrow( - 'Zyte HTTP 응답 본문이 비어 있습니다.', + await expect(fetchCgvTheaters({ zyteApiKey: 'test-key' })).rejects.toBeInstanceOf( + CgvUpstreamUnavailableError, ); }); @@ -634,8 +633,8 @@ describe('fetchCgvTimetable', () => { mockFetch.mockResolvedValue(new Response('forbidden', { status: 403 })); - await expect(fetchCgvTheaters({ zyteApiKey: ' ' })).rejects.toThrow( - 'ZYTE_API_KEY가 설정되지 않았습니다.', + await expect(fetchCgvTheaters({ zyteApiKey: ' ' })).rejects.toBeInstanceOf( + CgvUpstreamUnavailableError, ); process.env.ZYTE_API_KEY = original; @@ -938,7 +937,6 @@ describe('fetchCgvTimetable', () => { const result = await fetchCgvTimetable({ playDate: '20260304' }); expect(result).toEqual([]); }); - }); describe('toYyyymmdd', () => { diff --git a/tests/services/cgv/transport.test.ts b/tests/services/cgv/transport.test.ts index fab0067e..63922c34 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 { CgvUpstreamUnavailableError } from '../../../src/services/cgv/errors.js'; import { requestCgv } from '../../../src/services/cgv/transport.js'; const mockFetch = vi.fn(); @@ -19,7 +20,9 @@ afterEach(() => { describe('requestCgv', () => { it('정상 응답을 JSON으로 파싱한다', async () => { - mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ statusCode: 0, data: [] }), { status: 200 })); + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ statusCode: 0, data: [] }), { status: 200 }), + ); const result = await requestCgv<{ statusCode: number; data: unknown[] }>( '/cnm/atkt/searchRegnList', @@ -48,9 +51,10 @@ describe('requestCgv', () => { }); it('403 + zyteApiKey면 Zyte fallback을 사용한다', async () => { - const body = Buffer.from(JSON.stringify({ statusCode: 0, data: [{ siteNo: '0056' }] }), 'utf8').toString( - 'base64', - ); + const body = Buffer.from( + JSON.stringify({ statusCode: 0, data: [{ siteNo: '0056' }] }), + 'utf8', + ).toString('base64'); mockFetch .mockResolvedValueOnce(new Response('forbidden', { status: 403 })) @@ -75,6 +79,40 @@ describe('requestCgv', () => { expect(String(mockFetch.mock.calls[1][0])).toContain('https://api.zyte.com/v1/extract'); }); + it('직접 요청이 403이고 Zyte 키가 없으면 명시적인 upstream unavailable 오류를 던진다', async () => { + mockFetch.mockResolvedValueOnce(new Response('forbidden', { status: 403 })); + + await expect( + requestCgv('/cnm/atkt/searchRegnList', new URLSearchParams({ coCd: 'A420' }), 1000), + ).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')); From 37005879f864341d2f055a37fbb101b3f6d1506d Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Tue, 28 Jul 2026 17:14:05 +0900 Subject: [PATCH 7/8] =?UTF-8?q?chore:=20GS25=20=EB=AF=B8=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/gs25/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/gs25/client.ts b/src/services/gs25/client.ts index f6bf0979..14f18bbf 100644 --- a/src/services/gs25/client.ts +++ b/src/services/gs25/client.ts @@ -7,7 +7,7 @@ import { fetchJson, fetchWithTimeout, HttpError } from '../../utils/http.js'; import { GS25_API } from './api.js'; import { normalizeStore, toNumber } from './storeUtils.js'; import { fetchGs25StoreStockResponse } from './storeStockTransport.js'; -import type { Gs25Store, Gs25StoreStockResponse } from './types.js'; +import type { Gs25Store } from './types.js'; export { fetchGs25NormalizedKeyword, From 12673363a4bb0a28459e14a4d8b7bb703fbb2583 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Tue, 28 Jul 2026 17:17:21 +0900 Subject: [PATCH 8/8] =?UTF-8?q?test:=20=EC=97=85=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A6=BC=20=EC=9E=A5=EC=95=A0=20=EB=B6=84=EA=B8=B0=20=EC=BB=A4?= =?UTF-8?q?=EB=B2=84=EB=A6=AC=EC=A7=80=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/services/cgv/transport.test.ts | 34 ++++++++++++++++++++++++ tests/services/gs25/client.test.ts | 29 ++++++++++++++++++++ tests/services/lottemart/session.test.ts | 14 ++++++++++ 3 files changed, 77 insertions(+) diff --git a/tests/services/cgv/transport.test.ts b/tests/services/cgv/transport.test.ts index 63922c34..f6e2e389 100644 --- a/tests/services/cgv/transport.test.ts +++ b/tests/services/cgv/transport.test.ts @@ -87,6 +87,40 @@ describe('requestCgv', () => { ).rejects.toBeInstanceOf(CgvUpstreamUnavailableError); }); + it('직접 요청이 401이어도 명시적인 upstream unavailable 오류를 던진다', async () => { + mockFetch.mockResolvedValueOnce(new Response('unauthorized', { status: 401 })); + + await expect( + requestCgv('/cnm/atkt/searchRegnList', new URLSearchParams({ coCd: 'A420' }), 1000), + ).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 })) diff --git a/tests/services/gs25/client.test.ts b/tests/services/gs25/client.test.ts index 39e95afe..944ba477 100644 --- a/tests/services/gs25/client.test.ts +++ b/tests/services/gs25/client.test.ts @@ -75,6 +75,35 @@ describe('fetchGs25Stores', () => { ); }); + 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); + }); + it('store/stock 403이어도 Zyte 키가 없으면 원본 에러를 반환한다', async () => { mockFetch.mockResolvedValueOnce( new Response('forbidden', { status: 403, statusText: 'Forbidden' }), diff --git a/tests/services/lottemart/session.test.ts b/tests/services/lottemart/session.test.ts index eca8b0e8..157b4dd8 100644 --- a/tests/services/lottemart/session.test.ts +++ b/tests/services/lottemart/session.test.ts @@ -266,6 +266,20 @@ describe('lottemart session helpers', () => { ); }); + it('일반 fetch와 소켓 전송이 모두 실패하면 원래 fetch 오류를 유지한다', async () => { + mockFetch.mockRejectedValueOnce(new Error('direct unavailable')); + socketMocks.fetchResponse.mockRejectedValueOnce(new Error('socket unavailable')); + + await expect( + fetchLotteMartHtml( + 'https://company.lottemart.com/mobiledowa/test', + { method: 'GET' }, + 1000, + '', + ), + ).rejects.toThrow('direct unavailable'); + }); + it('소켓 쓰기나 닫기가 멈추면 null을 반환한다', async () => { const never = new Promise(() => {}); const writableWithStalledWrite = new WritableStream({