diff --git a/docs/convenience-relay.md b/docs/convenience-relay.md index 41ed9e48..d20db4cc 100644 --- a/docs/convenience-relay.md +++ b/docs/convenience-relay.md @@ -4,7 +4,7 @@ GS25 세션 만료·인증 장애를 복구할 때는 [정상 로그인 복구 운영 절차](gs25-auth-recovery-runbook.md)부터 읽으세요. 티켓·네이버 로그인·GS 토큰 저장·재기동·가상기기 종료 후 검증과 재현용 참조 코드를 포함합니다. -CU PocketCU, 세븐일레븐 공식 앱 API와 GS25 상품·재고 API를 현재 Mac의 브라우저 없는 HTTP 중계로 호출합니다. 정상 API 응답 계약은 유지합니다. Worker에 설정이 없으면 기존 직접 호출을 사용하며, URL·토큰 또는 Access 인증의 부분 설정은 오류를 반환합니다. +CU PocketCU, 세븐일레븐 공식 앱 API와 GS25 상품·재고 API와 이마트24 공식 웹 API를 현재 Mac의 브라우저 없는 HTTP 중계로 호출합니다. 정상 API 응답 계약은 유지합니다. Worker에 설정이 없으면 기존 직접 호출을 사용하며, URL·토큰 또는 Access 인증의 부분 설정은 오류를 반환합니다. ## 실행과 설정 @@ -15,7 +15,7 @@ CONVENIENCE_RELAY_TOKEN=your_convenience_token CONVENIENCE_RELAY_STATE_DIR=/absolute/path/to/convenience-state ``` -상태 경로는 Dtryx 상태 경로와 다른 디렉터리여야 합니다. 이 디렉터리의 `quota.json`에는 재시작해도 유지되는 호출 원장이, `logs/`에는 입력·토큰·키를 제외한 제한된 관측 로그가 저장됩니다. CU·GS25·세븐일레븐이 공유하는 편의점 예산은 분당 690회, 하루 최대 10,000,000회이며 Dtryx·올리브영 예산과 공유하지 않습니다. +상태 경로는 Dtryx 상태 경로와 다른 디렉터리여야 합니다. 이 디렉터리의 `quota.json`에는 재시작해도 유지되는 호출 원장이, `logs/`에는 입력·토큰·키를 제외한 제한된 관측 로그가 저장됩니다. CU·GS25·세븐일레븐·이마트24가 공유하는 편의점 예산은 분당 690회, 하루 최대 10,000,000회이며 Dtryx·올리브영 예산과 공유하지 않습니다. Worker에는 아래 바인딩을 설정합니다. URL은 중계 호스트의 기본 URL이며 `/v1/convenience`를 붙이지 않습니다. HTTPS 또는 로컬 루프백 HTTP만 허용합니다. REST와 MCP가 동일한 설정을 사용합니다. @@ -38,19 +38,23 @@ Access 토큰 만료1분 전 또는 재고401/403 응답 시 정상 `tokenReissu 모든 조회는 `Authorization: Bearer `를 요구합니다. 아래 작업에 JSON POST만 허용하며 임의 URL·헤더·외부 경로 전달은 거절합니다. -| 작업 | 원본 조회 | 입력 | -| --- | --- | --- | -| `cu-prime` | 재고 검색 초기화 | `{}` | -| `cu-stock` | 상품 재고 검색 | 기존 PocketCU 검색 본문 | -| `cu-store` | 매장별 수량 | 기존 PocketCU 매장 본문 | -| `seven-goods` | 상품 검색 | `query`, `pageNo`, `pageSize` | -| `seven-store` | 매장 검색 | `collection: "store"`, `query`, `sort: "Date/desc"`, `listCount` | -| `seven-popwords` | 인기 검색어 | `label` | -| `seven-stock-meta` | 재고 상품 메타정보 | `itemCd` | -| `seven-stock` | 매장별 실재고 | `smCd`, `stokMngCd`, `stokMngQty`, `stockApplicationRate`, `storeList` | -| `seven-pages`, `seven-issues`, `seven-exhibitions` | 카탈로그 | `{}` | -| `gs25-products` | 상품 검색 | `query` | -| `gs25-stock` | 매장·재고 | 기존 BFF 조회 파라미터를 문자열 JSON으로 전달, 선택 `apiKey` | +| 작업 | 원본 조회 | 입력 | +| -------------------------------------------------- | ------------------ | ---------------------------------------------------------------------- | +| `cu-prime` | 재고 검색 초기화 | `{}` | +| `cu-stock` | 상품 재고 검색 | 기존 PocketCU 검색 본문 | +| `cu-store` | 매장별 수량 | 기존 PocketCU 매장 본문 | +| `seven-goods` | 상품 검색 | `query`, `pageNo`, `pageSize` | +| `seven-store` | 매장 검색 | `collection: "store"`, `query`, `sort: "Date/desc"`, `listCount` | +| `seven-popwords` | 인기 검색어 | `label` | +| `seven-stock-meta` | 재고 상품 메타정보 | `itemCd` | +| `seven-stock` | 매장별 실재고 | `smCd`, `stokMngCd`, `stokMngQty`, `stockApplicationRate`, `storeList` | +| `seven-pages`, `seven-issues`, `seven-exhibitions` | 카탈로그 | `{}` | +| `gs25-products` | 상품 검색 | `query` | +| `gs25-stock` | 매장·재고 | 기존 BFF 조회 파라미터를 문자열 JSON으로 전달, 선택 `apiKey` | +| `emart24-products` | 상품 검색 | `currentPage`, `pageCnt`, `sortType`, `saleProductYn`, `searchWord` | +| `emart24-stores` | 매장 검색 | `page`, 선택 `search`, `AREA1`, `AREA2`, `SVR_24: "1"` | +| `emart24-stock` | 매장별 수량 | `searchPluCode`, 쉼표로 연결한 `bizNoArr` | +| `emart24-store-detail` | 매장 상세 | `bizNo` | 인증된 GET `/v1/convenience/health`는 실행·대기 수와 원장·로그 상태를 반환합니다. 공개 Worker `/health`의 `config.convenienceRelay`는 설정 여부만 반환합니다. @@ -75,3 +79,11 @@ CU 매장 키워드만 입력한 조회는 공식 웹에서 해당 지역 점포 이 응답은 공개 API에서 HTTP 502를 유지하고, 오류 문구를 `정상적인 점포 조회 요청이 아닙니다. 일시적인 외부 서비스 오류입니다. 잠시 후 재시도해주세요.`로 표시합니다. API와 MCP의 공통 `diagnostics`에는 원본 `upstreamStatus: 400`, `upstreamCode: 501`, `upstreamMessage`가 남습니다. 확인된 문구만 전달하며 응답 본문 전체나 알 수 없는 오류 문구는 공개하지 않습니다. 이 요청 거부는 `retryable: false`로 표시해 자동 재시도를 하지 않습니다. 안내 문구는 정책이 일시적이라는 확정 진단이 아닙니다. 중계 내부에서는 이 확인된 거부를 HTTP 200의 `success: false` 실패 envelope로 전달해 중간 연결 구간의 5xx 오류 페이지 처리와 분리합니다. Worker는 이를 성공으로 반환하지 않고 HTTP 502로 변환합니다. 실패는 캐시하지 않으며 원본 호출 로그에도 오류로 남습니다. 구버전 중계의 HTTP 502 진단도 계속 읽을 수 있습니다. + +## 이마트24 조회 경로 + +이마트24는 기존 공식 도메인과 고정 경로를 그대로 사용합니다. 상품 검색은 `application/x-www-form-urlencoded` 폼, 나머지는 GET이며 기존 `Accept`·`X-Requested-With` 헤더를 유지합니다. 직접 조회와 Mac HTTP 중계는 같은 입력·응답 검증기를 사용하고 임의 URL·헤더·경로를 받지 않습니다. Worker의 사전 점검이 직접 경로를 정상으로 확인했으면 최대 3초의 직접 조회를 먼저 시도합니다. 실패하면 남은 요청 기한 안에서 기존 인증된 편의점 중계를 사용하고 해당 작업의 직접 실패는 기존 5분 재검사 정책을 따릅니다. 설정이 없는 로컬 CLI는 종전 직접 조회를 유지합니다. + +Mac 중계의 이마트24 재고 응답은 30초, 상품·매장 검색·매장 상세는 5분 동안 캐시합니다. 공개 REST의 기존 엣지 캐시는 상품 10분·매장 30분·재고 5분을 유지합니다. 정상 운영 인증과 `x-health-check-force-fresh: true`를 함께 보낸 새 조회는 엣지 캐시와 모든 재고 단계의 중계 캐시를 우회합니다. 비교 조회의 이마트24 검색에도 같은 새 조회 설정을 전달합니다. 인증되지 않은 요청은 새 조회 헤더만으로 캐시를 우회할 수 없습니다. + +공식 재고의 `storeGoodsQty: null`은 빈 목록으로 처리합니다. 수량 0은 확인된 0이며 누락·null·빈 문자열·-1은 미확인으로 유지합니다. 실패 JSON·403 HTML·잘못된 수량을 성공이나 품절로 바꾸지 않습니다. 매장 검색의 정상 `error: 0`과 결과 없음 `error: 1`은 기존 키워드 대체 조회 동작을 유지합니다. diff --git a/docs/relay-observability.md b/docs/relay-observability.md index 0fa0f427..e85e9ff6 100644 --- a/docs/relay-observability.md +++ b/docs/relay-observability.md @@ -47,7 +47,7 @@ rg --fixed-strings '확인할-request-id' "$OY_RELAY_STATE_DIR/logs" "$DTRYX_REL 인증과 payload 검증 뒤 공통 릴레이 캐시를 확인한다. REST와 MCP가 같은 조건을 요청하면 공유된다. 상품 검색/매장은 5분, 상품 ID는 10분, 재고는 1분 TTL이다. 최대 256항목 및 키·값 직렬화 16MiB, 오래된 항목부터 퇴출한다. 성공만 저장하며 동일 진행 요청은 합친다. 탭이나 브라우저를 추가하지 않는다. -캐시 적중은 quota를 소비하지 않는다. `x-relay-cache: hit/miss/coalesced` 및 health.cache로 확인한다. 서로 다른 검색이 대부분이면 절감 효과는 작다. 현재 OY quota는 분당 300회·UTC 일일 300,000회다. 소비자별 신규 원본 작업은 분당 240회이며, 사용한 원장은 유지한다. REST 캐시도 별도로 존재하며 products5분/stores24시간/inventory10분이므로 외부 REST 응답의 최신성은 두 계층의 영향을 받는다. `_healthCheck`는 REST 캐시를 새로 조회하지만 공통 릴레이 캐시는 계속 적용된다. +캐시 적중은 quota를 소비하지 않는다. `x-relay-cache: hit/miss/coalesced` 및 health.cache로 확인한다. 서로 다른 검색이 대부분이면 절감 효과는 작다. 현재 OY quota는 분당 300회·UTC 일일 300,000회다. 소비자별 신규 원본 작업은 분당 240회이며, 사용한 원장은 유지한다. REST 캐시도 별도로 존재하며 products5분/stores24시간/inventory10분이므로 외부 REST 응답의 최신성은 두 계층의 영향을 받는다. 공개 REST 요청의 `_healthCheck`는 REST 캐시 키만 바꾸며 공통 릴레이 캐시는 계속 적용된다. 운영 인증이 유효한 `x-health-check-force-fresh: true` 요청은 REST 캐시와 OY 내부 캐시를 우회하고, 릴레이에도 `x-relay-cache-bypass: true`를 전달하여 저장 응답과 진행 중인 동일 요청을 재사용하지 않는다. 인증 없는 헤더나 `_healthCheck`만으로 이 우회를 활성화할 수 없다. ## 과거 OY 요청 집중 보호 (1.2.7) @@ -64,7 +64,7 @@ health.cache의 `hits`, `misses`, `expired`, `evicted`는 프로세스 시작 ## GitHub Health Checks 판정과 보존 -quick/full 모두 새 검사 요약을 요청한다. 실패 후 한 번 재시도할 때는 REST 캐시도 우회한다. 공통 OY 릴레이 캐시는 유지한다. HTTP 실패 후 이전 성공 파일을 재사용하지 않으며, 비정상 JSON·빈 검사 목록·모순된 요약은 실패로 처리한다. 각 요청은 최대 240초, 전체 job은 최대 10분이다. 시도별 원본 JSON과 오류 요약은 Actions artifact로 7일 보관한다. +quick/full 모두 운영 인증과 `fresh=true`, `x-health-check-force-fresh: true`를 함께 보내 새 검사 요약을 요청한다. 허용된 원점의 검사 대상에는 운영 인증과 fresh 헤더가 전달되며 REST·OY 내부·공통 릴레이 캐시를 우회한다. 실패 직후 전체 검사를 반복하지 않고, 검증된 분당 중계 한도 429만 만료 후 해당 검사 ID를 한 번 fresh로 재확인한다. HTTP 실패 후 이전 성공 파일을 재사용하지 않으며, 비정상 JSON·빈 검사 목록·모순된 요약은 실패로 처리한다. 각 요청은 최대 240초, 전체 job은 최대 10분이다. 시도별 원본 JSON과 오류 요약은 Actions artifact로 7일 보관한다. CGV는 `CGV_UPSTREAM_UNAVAILABLE`, API HTTP 503, 원본 HTTP 403이 모두 확인될 때만 `degraded`로 분류한다. 이는 해당 기능이 제한된 상태이며 복구를 뜻하지 않는다. 인증 오류, 타임아웃, JSON 파싱 실패 등은 계속 `fail`이다. 선택 데이터가 없는 `skipped` 검사는 정상 요약에 포함될 수 있다. Actions 성공 여부와 별도로 요약의 저하 서비스 목록을 확인해야 한다. 유료 Zyte 재시도는 활성화하지 않는다. diff --git a/scripts/relay/convenience.ts b/scripts/relay/convenience.ts index 4a6e5622..fb3f2737 100644 --- a/scripts/relay/convenience.ts +++ b/scripts/relay/convenience.ts @@ -1,3 +1,4 @@ +import { emart24DirectRouteSpecs, isEmart24Operation } from '../../src/services/emart24/relaySpecs.js'; import { parseSevenStockFailure } from '../../src/utils/sevenStockFailure.js'; import { CU_DEFAULT_HEADERS } from '../../src/services/cu/client.js'; import { GS25_DEFAULT_HEADERS } from '../../src/services/gs25/client.js'; @@ -266,15 +267,27 @@ export function createConvenienceRelay( return createHttpRelay(token, { ...options, cacheTtl: (operation) => - ['cu-store', 'seven-stock', 'gs25-stock'].includes(operation) ? 30000 : 300000, + ['cu-store', 'seven-stock', 'gs25-stock', 'emart24-stock'].includes(operation) ? 30000 : 300000, prefix: '/v1/convenience/', - operations: Object.keys(paths), + operations: [...Object.keys(paths), ...Object.keys(emart24DirectRouteSpecs)], validate(value, operation) { - const parsed = schemas[operation as Operation].safeParse(value); + const parsed = (isEmart24Operation(operation) ? emart24DirectRouteSpecs[operation].input : schemas[operation as Operation]).safeParse(value); if (!parsed.success) throw new RelayError(400); return parsed.data; }, async upstream(operation, params, signal, fetcher) { + if (isEmart24Operation(operation)) { + const spec = emart24DirectRouteSpecs[operation]; + const { url, init } = spec.build(params); + const response = await abortable(fetcher(url, { ...init, redirect: 'manual', signal }), signal); + if (response.status !== 200) { + void response.body?.cancel().catch(() => undefined); + throw new RelayError(502); + } + const data = await readJson(response.body, 2 * 1024 * 1024, signal, 502); + if (!spec.response.safeParse(data).success) throw new RelayError(502); + return data; + } const [base, path, method, schema] = paths[operation as Operation]; const url = new URL(path, base); const body = { ...(params as Record) }; diff --git a/src/api/compareHandlers.ts b/src/api/compareHandlers.ts index d9843bdc..26ee8019 100644 --- a/src/api/compareHandlers.ts +++ b/src/api/compareHandlers.ts @@ -1,3 +1,4 @@ +import { isFreshOperationalRequest } from './operationalFreshness.js'; import { compareProducts, parseCompareServices } from '../services/compare/client.js'; import { type ApiContext, errorResponse, successResponse } from './response.js'; import { convenienceTransportFromBindings } from '../utils/convenienceTransport.js'; @@ -12,11 +13,17 @@ export async function handleCompareProducts(c: ApiContext) { } try { - const result = await compareProducts({ - keyword, - limit, - services, - }, convenienceTransportFromBindings(c.env)); + const result = await compareProducts( + { + keyword, + limit, + services, + }, + { + ...convenienceTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), + }, + ); return successResponse(c, result, { total: result.resultCount, diff --git a/src/api/emart24Handlers.ts b/src/api/emart24Handlers.ts index bd148f9d..6aad671e 100644 --- a/src/api/emart24Handlers.ts +++ b/src/api/emart24Handlers.ts @@ -1,9 +1,15 @@ +import { convenienceTransportFromBindings } from '../utils/convenienceTransport.js'; +import { isFreshOperationalRequest } from './operationalFreshness.js'; /** * 이마트24 GET API 핸들러 */ import { type ApiContext, errorResponse, successResponse } from './response.js'; -import { calculateDistanceM, fetchEmart24Stores, searchEmart24Products } from '../services/emart24/client.js'; +import { + calculateDistanceM, + fetchEmart24Stores, + searchEmart24Products, +} from '../services/emart24/client.js'; import { lookupEmart24Inventory } from '../services/emart24/inventoryLookup.js'; /** @@ -18,7 +24,8 @@ export async function handleEmart24FindStores(c: ApiContext) { const page = parseInt(c.req.query('page') || '1', 10); const limit = parseInt(c.req.query('limit') || '20', 10); const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10); - const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000; + const timeoutMs = + Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000; const rawLat = c.req.query('lat'); const rawLng = c.req.query('lng'); const parsedLat = rawLat ? parseFloat(rawLat) : undefined; @@ -36,13 +43,20 @@ export async function handleEmart24FindStores(c: ApiContext) { service24h, }, { + ...convenienceTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), timeout: timeoutMs, }, ); const stores = result.stores .map((store) => { - if (typeof lat !== 'number' || typeof lng !== 'number' || store.latitude === 0 || store.longitude === 0) { + if ( + typeof lat !== 'number' || + typeof lng !== 'number' || + store.latitude === 0 || + store.longitude === 0 + ) { return store; } @@ -51,13 +65,19 @@ export async function handleEmart24FindStores(c: ApiContext) { distanceM: calculateDistanceM(lat, lng, store.latitude, store.longitude), }; }) - .sort((a, b) => (a.distanceM ?? Number.MAX_SAFE_INTEGER) - (b.distanceM ?? Number.MAX_SAFE_INTEGER)) + .sort( + (a, b) => + (a.distanceM ?? Number.MAX_SAFE_INTEGER) - (b.distanceM ?? Number.MAX_SAFE_INTEGER), + ) .slice(0, limit); return successResponse( c, { - location: typeof lat === 'number' && typeof lng === 'number' ? { latitude: lat, longitude: lng } : null, + location: + typeof lat === 'number' && typeof lng === 'number' + ? { latitude: lat, longitude: lng } + : null, keyword, appliedKeyword: result.appliedKeyword, area1, @@ -89,7 +109,8 @@ export async function handleEmart24SearchProducts(c: ApiContext) { (c.req.query('sortType') as 'SALE' | 'LATEST' | 'PRICE_ASC' | 'PRICE_DESC' | null) || 'SALE'; const saleProductYn = (c.req.query('saleProductYn') as 'Y' | 'N' | null) || 'N'; const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10); - const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000; + const timeoutMs = + Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000; if (keyword.trim().length === 0) { return errorResponse(c, 'MISSING_QUERY', '검색어(keyword)를 입력해주세요.'); @@ -105,6 +126,8 @@ export async function handleEmart24SearchProducts(c: ApiContext) { saleProductYn, }, { + ...convenienceTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), timeout: timeoutMs, }, ); @@ -154,7 +177,11 @@ export async function handleEmart24CheckInventory(c: ApiContext) { const lng = typeof parsedLng === 'number' && Number.isFinite(parsedLng) ? parsedLng : undefined; if (pluCd.trim().length === 0 && keyword.trim().length === 0) { - return errorResponse(c, 'MISSING_PLU_CD', '상품 PLU 코드(pluCd) 또는 검색어(keyword)를 입력해주세요.'); + return errorResponse( + c, + 'MISSING_PLU_CD', + '상품 PLU 코드(pluCd) 또는 검색어(keyword)를 입력해주세요.', + ); } const bizNos = bizNoArr @@ -191,6 +218,8 @@ export async function handleEmart24CheckInventory(c: ApiContext) { productPageSize, storeLimit, timeoutMs, + ...convenienceTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), bizNos, includeRequestedStoresWithoutQty: bizNos.length > 0, }); diff --git a/src/api/routes/compareRoutes.ts b/src/api/routes/compareRoutes.ts index 7d94d0a8..67d3c27c 100644 --- a/src/api/routes/compareRoutes.ts +++ b/src/api/routes/compareRoutes.ts @@ -1,3 +1,4 @@ +import { isFreshOperationalRequest } from '../operationalFreshness.js'; import type { Hono } from 'hono'; import { withEdgeCache } from '../../utils/cache.js'; import { handleCompareProducts } from '../compareHandlers.js'; @@ -8,18 +9,23 @@ export function registerCompareRoutes(app: Hono<{ Bindings: AppBindings }>): voi withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 10, staleWhileRevalidateSeconds: 60, keyPrefix: 'compare-products-v3', shouldCache: async (response) => { try { - const payload = await response.json() as { + const payload = (await response.json()) as { success?: unknown; data?: { results?: unknown; errors?: unknown }; } | null; - return response.ok && payload?.success === true && + return ( + response.ok && + payload?.success === true && Array.isArray(payload.data?.results) && - Array.isArray(payload.data?.errors) && payload.data.errors.length === 0; + Array.isArray(payload.data?.errors) && + payload.data.errors.length === 0 + ); } catch { return false; } diff --git a/src/api/routes/emart24Routes.ts b/src/api/routes/emart24Routes.ts index af0ae45f..f5d559d1 100644 --- a/src/api/routes/emart24Routes.ts +++ b/src/api/routes/emart24Routes.ts @@ -1,3 +1,4 @@ +import { isFreshOperationalRequest } from '../operationalFreshness.js'; /** * 이마트24 GET API 라우트 등록 */ @@ -16,6 +17,7 @@ export function registerEmart24Routes(app: Hono<{ Bindings: AppBindings }>): voi withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 30, staleWhileRevalidateSeconds: 60 * 3, keyPrefix: 'emart24-stores-v2', @@ -28,6 +30,7 @@ export function registerEmart24Routes(app: Hono<{ Bindings: AppBindings }>): voi withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 10, staleWhileRevalidateSeconds: 60, keyPrefix: 'emart24-products-v2', @@ -40,6 +43,7 @@ export function registerEmart24Routes(app: Hono<{ Bindings: AppBindings }>): voi withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 5, staleWhileRevalidateSeconds: 60, keyPrefix: 'emart24-inventory-v2', diff --git a/src/index.ts b/src/index.ts index ea409849..9cc6fa08 100644 --- a/src/index.ts +++ b/src/index.ts @@ -106,7 +106,7 @@ const createRegistry = (bindings?: AppBindings) => { zyteApiKey: bindings?.ZYTE_API_KEY, ...geocodeBindings(bindings), }), - createEmart24Service, + () => createEmart24Service(convenienceTransportFromBindings(bindings)), () => createMegaboxService(geocodeBindings(bindings)), () => createDtryxService(dtryxTransportFromBindings(bindings)), () => diff --git a/src/services/compare/client.ts b/src/services/compare/client.ts index a62dc522..049869a8 100644 --- a/src/services/compare/client.ts +++ b/src/services/compare/client.ts @@ -112,7 +112,7 @@ async function searchService( pageSize: limit, sortType: 'PRICE_ASC', saleProductYn: 'N', - }); + }, options); return result.products.slice(0, limit).map((product) => ({ service, serviceName: SERVICE_NAMES[service], diff --git a/src/services/emart24/client.ts b/src/services/emart24/client.ts index 0f792394..e312bb8b 100644 --- a/src/services/emart24/client.ts +++ b/src/services/emart24/client.ts @@ -4,6 +4,12 @@ import { fetchJson } from '../../utils/http.js'; import { EMART24_API } from './api.js'; +import { + hasConvenienceRelay, + requestConvenienceRelay, + type ConvenienceTransportOptions, +} from '../../utils/convenienceTransport.js'; +import { EMART24_JSON_HEADERS, EMART24_FORM_HEADERS } from './relaySpecs.js'; import type { Emart24Product, Emart24ProductSearchResponse, @@ -13,7 +19,7 @@ import type { Emart24WebStoreResponse, } from './types.js'; -interface RequestOptions { +export interface RequestOptions extends ConvenienceTransportOptions { timeout?: number; } @@ -38,16 +44,6 @@ interface SearchEmart24StockByStoresParams { bizNos: string[]; } -const EMART24_JSON_HEADERS = { - Accept: 'application/json, text/javascript, */*; q=0.01', - 'X-Requested-With': 'XMLHttpRequest', -} as const; - -const EMART24_FORM_HEADERS = { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - Accept: 'application/json, text/javascript, */*; q=0.01', - 'X-Requested-With': 'XMLHttpRequest', -} as const; function emart24FetchOptions(totalTimeout: number) { return { timeout: Math.max(1, Math.floor((totalTimeout - 250) / 2)), @@ -168,11 +164,18 @@ export async function fetchEmart24Stores( endpoint.searchParams.set('SVR_24', '1'); } - const body = await fetchJson(endpoint.toString(), { - ...emart24FetchOptions(deadline - Date.now()), - method: 'GET', - headers: EMART24_JSON_HEADERS, - }); + const body = hasConvenienceRelay(options) + ? await requestConvenienceRelay( + 'emart24-stores', + Object.fromEntries(endpoint.searchParams), + options, + deadline - Date.now(), + ) + : await fetchJson(endpoint.toString(), { + ...emart24FetchOptions(deadline - Date.now()), + method: 'GET', + headers: EMART24_JSON_HEADERS, + }); if (body.error === 0 || body.error === undefined) { const stores = (body.data || []).map(toStore).filter((store) => store.storeCode.length > 0); @@ -207,16 +210,23 @@ export async function searchEmart24Products( form.set('saleProductYn', saleProductYn); form.set('searchWord', keyword.trim()); - const body = await fetchJson( - `${EMART24_API.EVERSE_BASE_URL}${EMART24_API.PRODUCT_SEARCH_PATH}`, - { - ...emart24FetchOptions(timeout), - method: 'POST', - retryUnsafeMethods: true, - headers: EMART24_FORM_HEADERS, - body: form.toString(), - }, - ); + const body = hasConvenienceRelay(options) + ? await requestConvenienceRelay( + 'emart24-products', + Object.fromEntries(form), + options, + timeout, + ) + : await fetchJson( + `${EMART24_API.EVERSE_BASE_URL}${EMART24_API.PRODUCT_SEARCH_PATH}`, + { + ...emart24FetchOptions(timeout), + method: 'POST', + retryUnsafeMethods: true, + headers: EMART24_FORM_HEADERS, + body: form.toString(), + }, + ); const products = (body.productList || []) .map(toProduct) @@ -250,6 +260,13 @@ export async function searchEmart24StockByStores( endpoint.searchParams.set('searchPluCode', pluCd); endpoint.searchParams.set('bizNoArr', filteredBizNos.join(',')); + if (hasConvenienceRelay(options)) + return requestConvenienceRelay( + 'emart24-stock', + Object.fromEntries(endpoint.searchParams), + options, + timeout, + ); return fetchJson(endpoint.toString(), { ...emart24FetchOptions(timeout), method: 'GET', @@ -263,6 +280,13 @@ export async function fetchEmart24StoreDetail( ): Promise { const { timeout = 15000 } = options; + if (hasConvenienceRelay(options)) + return requestConvenienceRelay( + 'emart24-store-detail', + { bizNo }, + options, + timeout, + ); return fetchJson( `${EMART24_API.EVERSE_BASE_URL}${EMART24_API.STORE_DETAIL_PATH_PREFIX}${encodeURIComponent(bizNo)}`, { diff --git a/src/services/emart24/index.ts b/src/services/emart24/index.ts index 36c04480..01199579 100644 --- a/src/services/emart24/index.ts +++ b/src/services/emart24/index.ts @@ -1,3 +1,4 @@ +import type { ConvenienceTransportOptions } from '../../utils/convenienceTransport.js'; /** * 이마트24 서비스 프로바이더 */ @@ -17,14 +18,19 @@ const EMART24_METADATA: ServiceMetadata = { class Emart24Service implements ServiceProvider { readonly metadata = EMART24_METADATA; + constructor(private readonly options: ConvenienceTransportOptions = {}) {} getTools(): ToolRegistration[] { - return [createFindNearbyStoresTool(), createSearchProductsTool(), createCheckInventoryTool()]; + return [ + createFindNearbyStoresTool(this.options), + createSearchProductsTool(this.options), + createCheckInventoryTool(this.options), + ]; } } -export function createEmart24Service(): ServiceProvider { - return new Emart24Service(); +export function createEmart24Service(options: ConvenienceTransportOptions = {}): ServiceProvider { + return new Emart24Service(options); } export * from './types.js'; diff --git a/src/services/emart24/inventoryLookup.ts b/src/services/emart24/inventoryLookup.ts index 820a56ff..01e0dce1 100644 --- a/src/services/emart24/inventoryLookup.ts +++ b/src/services/emart24/inventoryLookup.ts @@ -9,10 +9,11 @@ import { fetchEmart24Stores, searchEmart24Products, searchEmart24StockByStores, + type RequestOptions, } from './client.js'; import type { Emart24Product, Emart24Store, Emart24StoreInventory } from './types.js'; -export interface Emart24InventoryLookupArgs { +export interface Emart24InventoryLookupArgs extends RequestOptions { pluCd?: string; keyword?: string; latitude?: number; @@ -61,7 +62,12 @@ async function resolvePluCd( productPage: number, productPageSize: number, timeoutMs: number, -): Promise<{ pluCd: string; products: Emart24Product[]; selectionReason: Emart24InventoryLookupResult['selectionReason'] }> { + options: RequestOptions, +): Promise<{ + pluCd: string; + products: Emart24Product[]; + selectionReason: Emart24InventoryLookupResult['selectionReason']; +}> { const normalizedPluCd = (pluCd || '').trim(); if (normalizedPluCd.length > 0) { return { @@ -82,12 +88,18 @@ async function resolvePluCd( pageSize: productPageSize, }, { + ...options, timeout: timeoutMs, }, ); - const firstProduct = productResult.products.find((item) => item.pluCd.trim().length > 0 && item.goodsName.replace(/\s+/g, '').toLowerCase() === keyword.replace(/\s+/g, '').toLowerCase()) - ?? productResult.products.find((item) => item.pluCd.trim().length > 0); + const firstProduct = + productResult.products.find( + (item) => + item.pluCd.trim().length > 0 && + item.goodsName.replace(/\s+/g, '').toLowerCase() === + keyword.replace(/\s+/g, '').toLowerCase(), + ) ?? productResult.products.find((item) => item.pluCd.trim().length > 0); if (!firstProduct) { throw new Error('상품 검색 결과에서 PLU 코드를 찾을 수 없습니다.'); } @@ -95,7 +107,11 @@ async function resolvePluCd( return { pluCd: firstProduct.pluCd, products: productResult.products, - selectionReason: firstProduct.goodsName.replace(/\s+/g, '').toLowerCase() === keyword.replace(/\s+/g, '').toLowerCase() ? 'exact_match' : 'first_candidate', + selectionReason: + firstProduct.goodsName.replace(/\s+/g, '').toLowerCase() === + keyword.replace(/\s+/g, '').toLowerCase() + ? 'exact_match' + : 'first_candidate', }; } @@ -152,8 +168,17 @@ export async function lookupEmart24Inventory( includeRequestedStoresWithoutQty = false, } = args; - const resolved = await resolvePluCd(pluCd, keyword, productPage, productPageSize, timeoutMs); - const normalizedBizNos = [...new Set(bizNos.map((value) => value.trim()).filter((value) => value.length > 0))]; + const resolved = await resolvePluCd( + pluCd, + keyword, + productPage, + productPageSize, + timeoutMs, + args, + ); + const normalizedBizNos = [ + ...new Set(bizNos.map((value) => value.trim()).filter((value) => value.length > 0)), + ]; let nearbyStores: Emart24Store[] = []; let nearbyTotalCount = normalizedBizNos.length; @@ -169,22 +194,28 @@ export async function lookupEmart24Inventory( page: 1, }, { + ...args, timeout: timeoutMs, }, ); appliedStoreKeyword = nearbyStoreResult.appliedKeyword; nearbyTotalCount = nearbyStoreResult.totalCount; - nearbyStores = sortStoresByDistance(nearbyStoreResult.stores, latitude, longitude).slice(0, storeLimit); + nearbyStores = sortStoresByDistance(nearbyStoreResult.stores, latitude, longitude).slice( + 0, + storeLimit, + ); } - const requestedBizNos = normalizedBizNos.length > 0 ? normalizedBizNos : nearbyStores.map((store) => store.storeCode); + const requestedBizNos = + normalizedBizNos.length > 0 ? normalizedBizNos : nearbyStores.map((store) => store.storeCode); const stockResult = await searchEmart24StockByStores( { pluCd: resolved.pluCd, bizNos: requestedBizNos, }, { + ...args, timeout: timeoutMs, }, ); @@ -204,7 +235,7 @@ export async function lookupEmart24Inventory( .filter((value) => value.length > 0); const detailResults = await Promise.allSettled( - inventoryBizNos.map((bizNo) => fetchEmart24StoreDetail(bizNo, { timeout: timeoutMs })), + inventoryBizNos.map((bizNo) => fetchEmart24StoreDetail(bizNo, { ...args, timeout: timeoutMs })), ); const nearbyStoreByCode = new Map(nearbyStores.map((store) => [store.storeCode, store])); diff --git a/src/services/emart24/relaySpecs.ts b/src/services/emart24/relaySpecs.ts new file mode 100644 index 00000000..f869ab76 --- /dev/null +++ b/src/services/emart24/relaySpecs.ts @@ -0,0 +1,155 @@ +/** 기존 공식 경로·헤더·폼 인코딩을 직접 조회와 HTTP 중계에서 공유합니다. */ +import * as z from 'zod'; +import { EMART24_API as API } from './api.js'; +import type { DirectRouteSpec } from '../../utils/directRouteSpecs.js'; +const text = z + .string() + .max(200) + .regex(/^[^\p{Cc}]*$/u); +const code = z.string().min(1).max(64).regex(/^\d+$/); +const page = z.string().regex(/^[1-9]\d{0,3}$/); +const quantity = z.union([ + z.number().int().min(-1).max(Number.MAX_SAFE_INTEGER), + z.string().regex(/^(?:-1|\d{1,9}|)$/), + z.null(), +]); +const scalar = z.union([z.number().finite(), z.string()]); +const productRow = z + .object({ + pluCd: code, + goodsNm: z.string().min(1), + originPrice: scalar.optional(), + viewPrice: scalar.optional(), + }) + .passthrough(); +const storeRow = z.object({ CODE: code, TITLE: z.string().min(1) }).passthrough(); +const noError = z + .object({ error: z.never().optional(), success: z.literal(true).optional() }) + .passthrough(); +const storesInput = z + .object({ + page, + search: text.optional(), + AREA1: text.optional(), + AREA2: text.optional(), + SVR_24: z.literal('1').optional(), + }) + .strict(); +const productsInput = z + .object({ + currentPage: page, + pageCnt: page, + sortType: z.enum(['SALE', 'LATEST', 'PRICE_ASC', 'PRICE_DESC']), + saleProductYn: z.enum(['Y', 'N']), + searchWord: text.min(1), + }) + .strict(); +const stockInput = z + .object({ + searchPluCode: code, + bizNoArr: z + .string() + .max(1800) + .regex(/^\d+(,\d+){0,199}$/), + }) + .strict(); +const detailInput = z.object({ bizNo: code }).strict(); +export const EMART24_JSON_HEADERS = { + Accept: 'application/json, text/javascript, */*; q=0.01', + 'X-Requested-With': 'XMLHttpRequest', +} as const; +export const EMART24_FORM_HEADERS = { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + ...EMART24_JSON_HEADERS, +} as const; +const storesResponse = z.union([ + z + .object({ + error: z.literal(0).optional(), + count: scalar, + data: z.array(storeRow), + success: z.literal(true).optional(), + }) + .passthrough(), + z + .object({ + error: z.literal(1), + data: z.array(z.never()).optional(), + success: z.literal(true).optional(), + }) + .passthrough(), +]); +function get(path: string, base: string, input: z.ZodType, body: unknown) { + const parsed = input.parse(body) as Record; + const url = new URL(path, base); + url.search = new URLSearchParams(parsed).toString(); + return { url, init: { method: 'GET', headers: EMART24_JSON_HEADERS } }; +} +export const emart24DirectRouteSpecs = { + 'emart24-stores': { + input: storesInput, + probe: () => ({ page: '1', search: '강남' }), + response: storesResponse, + build: (body: unknown) => get(API.STORE_PATH, API.WEB_BASE_URL, storesInput, body), + }, + 'emart24-products': { + input: productsInput, + probe: () => ({ + currentPage: '1', + pageCnt: '1', + sortType: 'SALE', + saleProductYn: 'N', + searchWord: '커피', + }), + response: noError.and( + z.object({ totalCnt: scalar, productList: z.array(productRow) }).passthrough(), + ), + build(body: unknown) { + return { + url: new URL(API.PRODUCT_SEARCH_PATH, API.EVERSE_BASE_URL), + init: { + method: 'POST', + headers: EMART24_FORM_HEADERS, + body: new URLSearchParams(productsInput.parse(body)).toString(), + }, + }; + }, + }, + 'emart24-stock': { + input: stockInput, + probe: () => ({ searchPluCode: '8801115139836', bizNoArr: '00940' }), + response: noError.and( + z + .object({ + storeGoodsInfo: z.object({ pluCd: code }).passthrough(), + storeGoodsQty: z + .array(z.object({ BIZNO: code, BIZQTY: quantity.optional() }).passthrough()) + .nullable(), + }) + .passthrough(), + ), + build: (body: unknown) => + get(API.STOCK_SEARCH_STORE_PATH, API.EVERSE_BASE_URL, stockInput, body), + }, + 'emart24-store-detail': { + input: detailInput, + probe: () => ({ bizNo: '00940' }), + response: noError.and( + z.object({ storeInfo: z.object({ storeNm: z.string().min(1) }).passthrough() }).passthrough(), + ), + build(body: unknown) { + const { bizNo } = detailInput.parse(body); + return { + url: new URL( + `${API.STORE_DETAIL_PATH_PREFIX}${encodeURIComponent(bizNo)}`, + API.EVERSE_BASE_URL, + ), + init: { method: 'GET', headers: EMART24_JSON_HEADERS }, + }; + }, + }, +} satisfies Record; +export type Emart24Operation = keyof typeof emart24DirectRouteSpecs; +export function isEmart24Operation(operation: string): operation is Emart24Operation { + return Object.hasOwn(emart24DirectRouteSpecs, operation); +} diff --git a/src/services/emart24/tools/checkInventory.ts b/src/services/emart24/tools/checkInventory.ts index 3eeec240..bb545d74 100644 --- a/src/services/emart24/tools/checkInventory.ts +++ b/src/services/emart24/tools/checkInventory.ts @@ -1,3 +1,4 @@ +import type { ConvenienceTransportOptions } from '../../../utils/convenienceTransport.js'; /** * 이마트24 재고 확인 도구 */ @@ -21,7 +22,10 @@ interface CheckInventoryArgs { timeoutMs?: number; } -async function checkInventory(args: CheckInventoryArgs): Promise { +async function checkInventory( + args: CheckInventoryArgs, + options: ConvenienceTransportOptions, +): Promise { const { pluCd, keyword = '', @@ -49,6 +53,7 @@ async function checkInventory(args: CheckInventoryArgs): Promise Promise, + handler: (args) => checkInventory(args as CheckInventoryArgs, options), }; } diff --git a/src/services/emart24/tools/findNearbyStores.ts b/src/services/emart24/tools/findNearbyStores.ts index 143f99cb..dcd95daf 100644 --- a/src/services/emart24/tools/findNearbyStores.ts +++ b/src/services/emart24/tools/findNearbyStores.ts @@ -1,3 +1,4 @@ +import type { ConvenienceTransportOptions } from '../../../utils/convenienceTransport.js'; /** * 이마트24 주변 매장 탐색 도구 */ @@ -18,7 +19,10 @@ interface FindNearbyStoresArgs { timeoutMs?: number; } -async function findNearbyStores(args: FindNearbyStoresArgs): Promise { +async function findNearbyStores( + args: FindNearbyStoresArgs, + options: ConvenienceTransportOptions, +): Promise { const { latitude, longitude, @@ -40,6 +44,7 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise Promise, + handler: (args) => findNearbyStores(args as FindNearbyStoresArgs, options), }; } diff --git a/src/services/emart24/tools/searchProducts.ts b/src/services/emart24/tools/searchProducts.ts index 422c3241..024b647d 100644 --- a/src/services/emart24/tools/searchProducts.ts +++ b/src/services/emart24/tools/searchProducts.ts @@ -1,3 +1,4 @@ +import type { ConvenienceTransportOptions } from '../../../utils/convenienceTransport.js'; /** * 이마트24 상품 검색 도구 */ @@ -22,7 +23,10 @@ function buildTextResponse(payload: Record): McpToolResponse { }; } -async function searchProducts(args: SearchProductsArgs): Promise { +async function searchProducts( + args: SearchProductsArgs, + options: ConvenienceTransportOptions, +): Promise { const { keyword, page = 1, @@ -46,6 +50,7 @@ async function searchProducts(args: SearchProductsArgs): Promise Promise, + handler: (args) => searchProducts(args as SearchProductsArgs, options), }; } diff --git a/src/services/emart24/types.ts b/src/services/emart24/types.ts index d811132b..cf116ecc 100644 --- a/src/services/emart24/types.ts +++ b/src/services/emart24/types.ts @@ -84,7 +84,7 @@ interface Emart24GoodsInfoRecord { export interface Emart24StockByStoreResponse { storeGoodsInfo?: Emart24GoodsInfoRecord; - storeGoodsQty?: Emart24StoreQtyRecord[]; + storeGoodsQty?: Emart24StoreQtyRecord[] | null; } interface Emart24StoreDetailInfo { diff --git a/src/utils/convenienceTransport.ts b/src/utils/convenienceTransport.ts index d6c2462b..00cd5e86 100644 --- a/src/utils/convenienceTransport.ts +++ b/src/utils/convenienceTransport.ts @@ -1,3 +1,4 @@ +import { emart24DirectRouteSpecs, isEmart24Operation } from '../services/emart24/relaySpecs.js'; import { parseSevenStockFailure, EXTERNAL_SERVICE_RETRY_HINT } from './sevenStockFailure.js'; /** 편의점 공통 릴레이 설정과 인증 전송입니다. */ import { requestDirectRoute } from './directRoutes.js'; @@ -42,6 +43,7 @@ export async function requestConvenienceRelay( timeout = 15000, ): Promise { validateRelay(options); + if (isEmart24Operation(operation)) emart24DirectRouteSpecs[operation].input.parse(body); if (!isRouteKey(operation)) return requestConvenienceRelayRaw(operation, body, options, timeout); return routeOperation( operation, @@ -197,7 +199,9 @@ async function requestConvenienceRelayRaw( typeof data !== 'object' || Array.isArray(data) || ('success' in data && data.success === false) || - 'error' in data || + (isEmart24Operation(operation) + ? !emart24DirectRouteSpecs[operation].response.safeParse(data).success + : 'error' in data) || ('resp_cd' in data && data.resp_cd !== '0000') ) throw Error('Invalid response'); diff --git a/src/utils/directRouteSpecs.ts b/src/utils/directRouteSpecs.ts index 5593342f..dcc15bb4 100644 --- a/src/utils/directRouteSpecs.ts +++ b/src/utils/directRouteSpecs.ts @@ -1,3 +1,4 @@ +import { emart24DirectRouteSpecs } from '../services/emart24/relaySpecs.js'; /** 직접 조회와 주기 검사에서 같은 고정 경로·검증기를 사용합니다. */ import * as z from 'zod'; import { cgvDirectRouteSpecs } from '../services/cgv/directRoute.js'; @@ -241,7 +242,7 @@ Object.entries(dtOperations).forEach(([key, operation]) => { }, }; }); -export const directRouteSpecs = { ...specs, ...cgvDirectRouteSpecs } as Record< +export const directRouteSpecs = { ...specs, ...cgvDirectRouteSpecs, ...emart24DirectRouteSpecs } as Record< RouteKey, DirectRouteSpec >; diff --git a/src/utils/routeHealth.ts b/src/utils/routeHealth.ts index 3178ab57..abd0a12c 100644 --- a/src/utils/routeHealth.ts +++ b/src/utils/routeHealth.ts @@ -11,6 +11,10 @@ export const ROUTE_KEYS = [ 'cu-prime', 'cu-stock', 'cu-store', + 'emart24-stores', + 'emart24-products', + 'emart24-stock', + 'emart24-store-detail', 'gs25-products', 'gs25-stock', 'seven-goods', diff --git a/tests/api/emart24-relay-integration.test.ts b/tests/api/emart24-relay-integration.test.ts new file mode 100644 index 00000000..5e3daa49 --- /dev/null +++ b/tests/api/emart24-relay-integration.test.ts @@ -0,0 +1,115 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { afterEach, expect, it, vi } from 'vitest'; +import app from '../../src/index.js'; +const env = { + CONVENIENCE_RELAY_URL: 'https://relay.example', + CONVENIENCE_RELAY_TOKEN: 'token', + HEALTH_CHECK_SECRET: 'health', +}; +const fixture = (url: string) => { + const op = url.split('/').at(-1); + if (op === 'emart24-products') + return { totalCnt: 1, productList: [{ pluCd: '123', goodsNm: '커피', viewPrice: 2800 }] }; + if (op === 'emart24-stores') + return { + error: 0, + count: 2, + data: [ + { CODE: '00940', TITLE: '청담하나점' }, + { CODE: '02130', TITLE: '강남역점' }, + ], + }; + if (op === 'emart24-stock') + return { + storeGoodsInfo: { pluCd: '123' }, + storeGoodsQty: [{ BIZNO: '00940', BIZQTY: '0' }, { BIZNO: '02130' }], + }; + if (op === 'emart24-store-detail') return { storeInfo: { storeNm: '강남매장' } }; + throw Error(`unexpected direct request ${url}`); +}; +afterEach(() => vi.unstubAllGlobals()); +it('REST·MCP·비교의 이마트24 조회는 같은 중계 바인딩을 사용한다', async () => { + const fetcher = vi.fn(async (url: string) => Response.json(fixture(url))); + vi.stubGlobal('fetch', fetcher); + for (const path of [ + '/api/emart24/products?keyword=커피', + '/api/emart24/stores?keyword=강남', + '/api/emart24/inventory?keyword=커피&storeKeyword=강남', + '/api/compare/products?keyword=커피&services=emart24', + ]) { + const response = await app.request(path, undefined, env); + expect(response.status).toBe(200); + expect((await response.json()).success).toBe(true); + } + const client = new Client({ name: 'test', version: '1' }); + await client.connect( + new StreamableHTTPClientTransport(new URL('https://local.test/mcp'), { + fetch: async (url, init) => app.request(new Request(url, init), undefined, env), + }), + ); + try { + for (const [name, args] of [ + ['emart24_search_products', { keyword: '커피' }], + ['emart24_find_nearby_stores', { keyword: '강남' }], + ['emart24_check_inventory', { keyword: '커피', storeKeyword: '강남' }], + ] as const) { + const result = await client.callTool({ name, arguments: args }); + expect(result.isError).not.toBe(true); + expect(JSON.stringify(result)).not.toContain('degraded'); + } + expect( + fetcher.mock.calls.every(([url]) => + url.startsWith('https://relay.example/v1/convenience/emart24-'), + ), + ).toBe(true); + } finally { + await client.close(); + } +}); +it('인증된 새 조회만 엣지와 모든 재고 단계의 중계 캐시를 우회한다', async () => { + const fetcher = vi.fn(async (url: string) => Response.json(fixture(url))); + const cache = { + match: vi.fn(async () => Response.json({ success: true, data: { cached: true } })), + put: vi.fn(), + }; + vi.stubGlobal('fetch', fetcher); + vi.stubGlobal('caches', { default: cache }); + const response = await app.request( + '/api/emart24/inventory?keyword=커피&storeKeyword=강남', + { headers: { 'x-health-check-force-fresh': 'true', Authorization: 'Bearer health' } }, + env, + ); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + const data = await response.json(); + expect(data.data.stores.map((store: { bizQty: unknown }) => store.bizQty)).toEqual([0, null]); + expect(cache.match).not.toHaveBeenCalled(); + expect(cache.put).not.toHaveBeenCalled(); + expect(fetcher).toHaveBeenCalledTimes(5); + for (const [, init] of fetcher.mock.calls as unknown as [string, RequestInit][]) + expect(init.headers).toEqual(expect.objectContaining({ 'x-relay-cache-bypass': 'true' })); + const compared = await app.request( + '/api/compare/products?keyword=커피&services=emart24', + { headers: { 'x-health-check-force-fresh': 'true', Authorization: 'Bearer health' } }, + env, + ); + expect(compared.headers.get('cache-control')).toBe('no-store'); + expect(cache.match).not.toHaveBeenCalled(); + expect((fetcher.mock.calls.at(-1) as unknown as [string, RequestInit])[1].headers).toEqual( + expect.objectContaining({ 'x-relay-cache-bypass': 'true' }), + ); + await app.request( + '/api/emart24/products?keyword=커피', + { headers: { 'x-health-check-force-fresh': 'true', Authorization: 'Bearer wrong' } }, + env, + ); + expect(cache.match).toHaveBeenCalledTimes(1); + await app.request( + '/api/compare/products?keyword=커피&services=emart24', + { headers: { 'x-health-check-force-fresh': 'true', Authorization: 'Bearer wrong' } }, + env, + ); + expect(cache.match).toHaveBeenCalledTimes(2); + expect(fetcher).toHaveBeenCalledTimes(6); +}); diff --git a/tests/relay/emart24.test.ts b/tests/relay/emart24.test.ts new file mode 100644 index 00000000..83a7ff22 --- /dev/null +++ b/tests/relay/emart24.test.ts @@ -0,0 +1,242 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createConvenienceRelay } from '../../scripts/relay/convenience.js'; +import { + searchEmart24Products, + fetchEmart24Stores, + searchEmart24StockByStores, + fetchEmart24StoreDetail, +} from '../../src/services/emart24/client.js'; +const request = (operation: string, body: unknown, fresh = false) => + new Request(`http://localhost/v1/convenience/${operation}`, { + method: 'POST', + headers: { + Authorization: 'Bearer convenience', + ...(fresh ? { 'x-relay-cache-bypass': 'true' } : {}), + }, + body: JSON.stringify(body), + }); +const product = { + currentPage: '1', + pageCnt: '1', + sortType: 'SALE', + saleProductYn: 'N', + searchWord: '커피', +}; +const products = { + totalCnt: 1, + productList: [{ pluCd: '8801115139836', goodsNm: '커피', viewPrice: 2800 }], +}; +const options = { + convenienceRelayUrl: 'http://localhost:3000', + convenienceRelayToken: 'convenience', +}; +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); +describe('이마트24 고정 HTTP 중계', () => { + it('상품 검색을 기존 공식 폼 헤더로 보내고 인증·할당량·새 조회를 유지한다', async () => { + const fetcher = vi.fn(async () => Response.json(products)); + const takeQuota = vi.fn(async () => true); + const relay = createConvenienceRelay('convenience', { fetcher, takeQuota }); + expect((await relay(request('emart24-products', product))).status).toBe(200); + expect((await relay(request('emart24-products', product))).status).toBe(200); + expect(fetcher).toHaveBeenCalledTimes(1); + expect((await relay(request('emart24-products', product, true))).status).toBe(200); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(takeQuota).toHaveBeenCalledTimes(2); + const [url, init] = fetcher.mock.calls[0] as unknown as [URL, RequestInit]; + expect(url.href).toBe('https://everse.emart24.co.kr/stock/stock/search'); + expect(init.headers).toEqual({ + Accept: 'application/json, text/javascript, */*; q=0.01', + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }); + expect(new URLSearchParams(init.body as string).get('searchWord')).toBe('커피'); + }); + it.each([ + [ + 'emart24-stores', + { page: '1', search: '강남', AREA1: '서울특별시', SVR_24: '1' }, + { error: 0, count: 1, data: [{ CODE: '00940', TITLE: '청담하나점' }] }, + 'https://emart24.co.kr/api1/store', + ], + [ + 'emart24-stock', + { searchPluCode: '8801115139836', bizNoArr: '00940' }, + { + storeGoodsInfo: { pluCd: '8801115139836' }, + storeGoodsQty: [{ BIZNO: '00940', BIZQTY: '0' }], + }, + 'https://everse.emart24.co.kr/api/stock/v2/stock-search/store', + ], + [ + 'emart24-store-detail', + { bizNo: '00940' }, + { storeInfo: { storeNm: '청담하나점' } }, + 'https://everse.emart24.co.kr/api/stock/stock/store/00940', + ], + ])('%s는 고정 GET 경로와 정상 오류 코드 0을 허용한다', async (operation, body, data, path) => { + const fetcher = vi.fn(async () => Response.json(data)); + const relay = createConvenienceRelay('convenience', { fetcher, takeQuota: async () => true }); + const response = await relay(request(operation, body)); + expect(response.status).toBe(200); + expect(await response.json()).toEqual(data); + const [url, init] = fetcher.mock.calls[0] as unknown as [URL, RequestInit]; + expect(url.href.split('?')[0]).toBe(path); + expect(init.method).toBe('GET'); + expect(init.body).toBeUndefined(); + }); + it('임의 URL·경로·필드와 인증 없는 호출은 원본을 호출하지 않는다', async () => { + const fetcher = vi.fn(); + const relay = createConvenienceRelay('convenience', { fetcher, takeQuota: async () => true }); + expect( + (await relay(request('emart24-products', { ...product, url: 'https://example.com' }))).status, + ).toBe(400); + expect((await relay(request('emart24-store-detail', { bizNo: '../path' }))).status).toBe(400); + const unauthenticated = new Request('http://localhost/v1/convenience/emart24-products', { + method: 'POST', + body: JSON.stringify(product), + }); + expect((await relay(unauthenticated)).status).toBe(401); + expect(fetcher).not.toHaveBeenCalled(); + }); + it.each([ + new Response('403 Forbidden', { status: 403 }), + Response.json({ totalCnt: 1, productList: [{}] }), + Response.json({ error: 1, data: [] }), + ])('원본 차단과 잘못된 응답은 캐시하거나 성공 처리하지 않는다', async (upstream) => { + const relay = createConvenienceRelay('convenience', { + fetcher: async () => upstream.clone(), + takeQuota: async () => true, + }); + expect((await relay(request('emart24-products', product))).status).toBe(502); + }); + it('원본 차단 응답의 스트림 취소가 실패해도 원래 조회 오류를 반환한다', async () => { + const upstream = new Response('blocked', { status: 403 }); + const cancel = vi.spyOn(upstream.body!, 'cancel').mockRejectedValue(new Error('stream closed')); + const relay = createConvenienceRelay('convenience', { + fetcher: async () => upstream, + takeQuota: async () => true, + }); + expect((await relay(request('emart24-products', product))).status).toBe(502); + expect(cancel).toHaveBeenCalledOnce(); + }); + it('재고 숫자 0과 누락·음수 미확인을 구분하고 객체 수량을 거절한다', async () => { + const data = { + storeGoodsInfo: { pluCd: '123' }, + storeGoodsQty: [{ BIZNO: '00940', BIZQTY: { value: 0 } }], + }; + const fetcher = vi.fn(async () => Response.json(data)); + const relay = createConvenienceRelay('convenience', { fetcher, takeQuota: async () => true }); + expect( + (await relay(request('emart24-stock', { searchPluCode: '123', bizNoArr: '00940' }))).status, + ).toBe(502); + }); + it('공식 원본의 재고 행 null은 실제 빈 목록으로 전달한다', async () => { + const data = { storeGoodsInfo: { pluCd: '123' }, storeGoodsQty: null }; + const relay = createConvenienceRelay('convenience', { + fetcher: async () => Response.json(data), + takeQuota: async () => true, + }); + const response = await relay( + request('emart24-stock', { searchPluCode: '123', bizNoArr: '00940' }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual(data); + }); + it('재고만 30초에 만료하고 상품·매장 상세 캐시는 5분 유지한다', async () => { + const clock = vi.spyOn(Date, 'now').mockReturnValue(100000); + try { + const fetcher = vi.fn(async (url: RequestInfo | URL) => + Response.json( + String(url).includes('stock-search') + ? { storeGoodsInfo: { pluCd: '123' }, storeGoodsQty: [] } + : String(url).includes('/store/') + ? { storeInfo: { storeNm: '강남점' } } + : products, + ), + ); + const relay = createConvenienceRelay('convenience', { fetcher, takeQuota: async () => true }); + const calls = [ + ['emart24-stock', { searchPluCode: '123', bizNoArr: '00940' }], + ['emart24-products', product], + ['emart24-store-detail', { bizNo: '00940' }], + ] as const; + for (const [op, body] of calls) expect((await relay(request(op, body))).status).toBe(200); + clock.mockReturnValue(130001); + for (const [op, body] of calls) expect((await relay(request(op, body))).status).toBe(200); + expect(fetcher).toHaveBeenCalledTimes(4); + clock.mockReturnValue(400001); + for (const [op, body] of calls) expect((await relay(request(op, body))).status).toBe(200); + expect(fetcher).toHaveBeenCalledTimes(7); + } finally { + clock.mockRestore(); + } + }); + it('호출 취소는 중계 원본 신호를 중단하며 성공을 만들지 않는다', async () => { + const controller = new AbortController(); + let upstreamSignal: AbortSignal | undefined; + let started!: () => void; + const ready = new Promise((resolve) => { + started = resolve; + }); + const relay = createConvenienceRelay('convenience', { + fetcher: async (_url, init) => { + upstreamSignal = init?.signal ?? undefined; + started(); + return new Promise(() => {}); + }, + takeQuota: async () => true, + }); + const req = new Request(request('emart24-products', product), { signal: controller.signal }); + const pending = relay(req); + await ready; + controller.abort(); + expect((await pending).status).toBe(499); + expect(upstreamSignal?.aborted).toBe(true); + }); + it('설정된 클라이언트의 잘못된 입력은 직접 조회나 중계 전에 거절한다', async () => { + const fetcher = vi.fn(); + vi.stubGlobal('fetch', fetcher); + await expect(searchEmart24Products({ keyword: '잘못된\n검색' }, options)).rejects.toThrow(); + expect(fetcher).not.toHaveBeenCalled(); + }); + it('설정된 클라이언트는 같은 중계와 새 조회 헤더를 사용한다', async () => { + const fetcher = vi.fn(async () => Response.json(products)); + vi.stubGlobal('fetch', fetcher); + const result = await searchEmart24Products( + { keyword: '커피', pageSize: 1 }, + { ...options, bypassCache: true }, + ); + expect(result.totalCount).toBe(1); + expect(fetcher.mock.calls[0][0]).toBe('http://localhost:3000/v1/convenience/emart24-products'); + const init = fetcher.mock.calls[0][1] as RequestInit; + expect(init.headers).toEqual( + expect.objectContaining({ + Authorization: 'Bearer convenience', + 'x-relay-cache-bypass': 'true', + }), + ); + }); + it('모든 클라이언트 작업은 같은 중계 설정을 전달한다', async () => { + const fetcher = vi.fn(async (url: string) => + Response.json( + url.includes('store-detail') + ? { storeInfo: { storeNm: '매장' } } + : url.includes('emart24-stock') + ? { storeGoodsInfo: { pluCd: '123' }, storeGoodsQty: [] } + : { error: 0, count: 0, data: [] }, + ), + ); + vi.stubGlobal('fetch', fetcher); + await fetchEmart24Stores({ keyword: '강남' }, options); + await searchEmart24StockByStores({ pluCd: '123', bizNos: ['00940'] }, options); + await fetchEmart24StoreDetail('00940', options); + expect(fetcher.mock.calls.map(([url]) => url)).toEqual([ + 'http://localhost:3000/v1/convenience/emart24-stores', + 'http://localhost:3000/v1/convenience/emart24-stock', + 'http://localhost:3000/v1/convenience/emart24-store-detail', + ]); + }); +}); diff --git a/tests/utils/directRoutes.test.ts b/tests/utils/directRoutes.test.ts index bf426f42..f9cc7330 100644 --- a/tests/utils/directRoutes.test.ts +++ b/tests/utils/directRoutes.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { requestDirectRoute, checkDirectRoute } from '../../src/utils/directRoutes.js'; +import { ROUTE_KEYS } from '../../src/utils/routeHealth.js'; import { directRouteSpecs } from '../../src/utils/directRouteSpecs.js'; afterEach(() => { @@ -8,7 +9,7 @@ afterEach(() => { }); describe('direct routes', () => { it('defines only fixed official routes and pins GS inventory without network access', async () => { - expect(Object.keys(directRouteSpecs)).toHaveLength(24); + expect(Object.keys(directRouteSpecs)).toHaveLength(ROUTE_KEYS.length); const fetcher = vi.fn(); vi.stubGlobal('fetch', fetcher); await expect(requestDirectRoute('gs25-stock', {}, 3000)).rejects.toThrow('pinned-relay'); diff --git a/tests/utils/emart24-routing.test.ts b/tests/utils/emart24-routing.test.ts new file mode 100644 index 00000000..19a68dca --- /dev/null +++ b/tests/utils/emart24-routing.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { checkDirectRoute } from '../../src/utils/directRoutes.js'; +import { emart24DirectRouteSpecs } from '../../src/services/emart24/relaySpecs.js'; +import { requestConvenienceRelay } from '../../src/utils/convenienceTransport.js'; +import { withRouteRouting, type RouteSnapshot } from '../../src/utils/routeHealth.js'; +const options = { convenienceRelayUrl: 'https://relay.example', convenienceRelayToken: 'token' }; +const products = { totalCnt: 1, productList: [{ pluCd: '123', goodsNm: '커피' }] }; +const fixtures = { + 'emart24-products': products, + 'emart24-stores': { error: 0, count: 0, data: [] }, + 'emart24-stock': { storeGoodsInfo: { pluCd: '123' }, storeGoodsQty: null }, + 'emart24-store-detail': { storeInfo: { storeNm: '강남매장' } }, +}; +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); +describe('이마트24 기존 직접 경로 선택', () => { + it.each(Object.keys(fixtures) as (keyof typeof fixtures)[])( + '%s의 읽기 전용 사전 점검은 실제 요청과 같은 검증기를 쓴다', + async (key) => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json(fixtures[key])), + ); + expect(await checkDirectRoute(key)).toMatchObject({ key, direct: true }); + }, + ); + it('403 직접 실패는 남은 예산으로 중계하고 해당 작업만 5분 대기한다', async () => { + const pending: Promise[] = []; + const snapshot: RouteSnapshot = { + routes: { + 'emart24-products': { key: 'emart24-products', direct: true, checkedAt: Date.now() }, + }, + relays: { convenience: { group: 'convenience', healthy: true, checkedAt: Date.now() } }, + }; + const ns = { + idFromName: () => ({ toString: () => 'emart-test' }), + get: () => ({ fetch: async () => Response.json(snapshot) }), + } as unknown as DurableObjectNamespace; + const fetcher = vi.fn(async (url: RequestInfo | URL) => + new URL(String(url)).origin === 'https://relay.example' + ? Response.json(products) + : new Response('403 Forbidden', { status: 403 }), + ); + vi.stubGlobal('fetch', fetcher); + const run = () => + withRouteRouting( + ns, + (p) => pending.push(p), + () => + requestConvenienceRelay( + 'emart24-products', + emart24DirectRouteSpecs['emart24-products'].probe(), + options, + 5000, + ), + ); + await run(); + await Promise.all(pending); + fetcher.mockClear(); + expect(await run()).toEqual(products); + expect(await run()).toEqual(products); + expect( + fetcher.mock.calls.filter( + ([url]) => new URL(String(url)).origin === 'https://everse.emart24.co.kr', + ), + ).toHaveLength(1); + expect( + fetcher.mock.calls.filter(([url]) => new URL(String(url)).origin === 'https://relay.example'), + ).toHaveLength(2); + }); +}); diff --git a/tests/utils/routeProbes.test.ts b/tests/utils/routeProbes.test.ts index 30ba8664..a53225d4 100644 --- a/tests/utils/routeProbes.test.ts +++ b/tests/utils/routeProbes.test.ts @@ -16,7 +16,7 @@ it('skips scheduling work when namespace is absent', async () => { await refreshRouteChecks({}); expect(checkDirectRoute).not.toHaveBeenCalled(); }); -it('writes all twenty-four checks and authenticated health probes with fixed paths', async () => { +it('모든 경로와 고정된 인증 중계 헬스 경로를 점검한다', async () => { await refreshRouteChecks({ UPSTREAM_ROUTE_HEALTH: namespace, OY_RELAY_URL: 'https://oy.example', @@ -28,7 +28,7 @@ it('writes all twenty-four checks and authenticated health probes with fixed pat DTRYX_RELAY_URL: 'https://dt.example', DTRYX_RELAY_TOKEN: 'dt', }); - expect(checkDirectRoute).toHaveBeenCalledTimes(24); + expect(checkDirectRoute).toHaveBeenCalledTimes(ROUTE_KEYS.length); expect(vi.mocked(boundedRouteJson).mock.calls.map(([url]) => String(url))).toEqual([ 'https://dt.example/v1/cgv/health', 'https://oy.example/health',