From 4caf358985f62d628b20fb84c67540c9587ea7eb Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Thu, 10 Sep 2026 20:15:46 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20GS25=20=EB=A7=A4=EC=9E=A5=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EB=B3=B5=EA=B5=AC=20=EB=B0=8F=20=EC=99=B8=EB=B6=80?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/upstream-reliability-2026-09-10.md | 20 +++ src/api/emart24Handlers.ts | 8 +- src/api/gs25Handlers.ts | 32 ++-- src/api/healthCheckDefinitions.ts | 2 - src/api/healthCheckTypes.ts | 1 - src/api/healthChecks.ts | 35 ++--- src/services/cu/client.ts | 18 +-- src/services/cu/types.ts | 1 + src/services/cu/upstreamError.ts | 16 ++ src/services/gs25/api.ts | 4 +- src/services/gs25/client.ts | 138 ++---------------- src/services/gs25/tools/findNearbyStores.ts | 19 ++- src/utils/http.ts | 14 +- tests/api/emart24-handlers.test.ts | 19 +++ tests/api/gs25-handlers.test.ts | 51 ++++--- tests/api/health-checks.test.ts | 27 +++- tests/app/app-api-cu.test.ts | 14 ++ tests/services/cu/client.test.ts | 38 +++-- tests/services/gs25/client.test.ts | 59 -------- .../gs25/tools/findNearbyStores.test.ts | 50 ++++++- .../services/gs25/web-store-migration.test.ts | 37 +++++ tests/utils/httpJson.test.ts | 39 +++++ tests/utils/zyteJsonFallback.test.ts | 8 + 23 files changed, 360 insertions(+), 290 deletions(-) create mode 100644 docs/upstream-reliability-2026-09-10.md create mode 100644 src/services/cu/upstreamError.ts create mode 100644 tests/services/gs25/web-store-migration.test.ts create mode 100644 tests/utils/httpJson.test.ts diff --git a/docs/upstream-reliability-2026-09-10.md b/docs/upstream-reliability-2026-09-10.md new file mode 100644 index 00000000..4396c82e --- /dev/null +++ b/docs/upstream-reliability-2026-09-10.md @@ -0,0 +1,20 @@ +# 2026-09-10 외부 서비스 응답 개선 + +## 확인한 원인과 변경 + +- GS25 기존 매장 웹 주소는 새 GS리테일 사이트로 이동합니다. 기존 CSRF 토큰과 이중 JSON 응답 처리 대신 새 사이트가 사용하는 공개 매장 API를 연결합니다. `shopName`으로 검색하고 `posY`를 위도, `posX`를 경도로 정규화합니다. 실제 강남 검색에서 46개 매장을 확인했습니다. 공개 매장 정보는 재고 수량의 근거가 아닙니다. +- REST와 MCP 매장 탐색 모두 공개 매장 경로를 사용해 빈 기본 응답을 보완합니다. 공개 경로까지 실패하면 빈 매장 목록으로 성공 처리하지 않습니다. +- CU 재고 검색은 운영에서 JSON 대신 HTML을 반환했습니다. 정상 HTTP 코드의 HTML도 식별 가능한 오류로 구분하고 CU 응답에 `available:false`와 원인을 전달합니다. 헬스체크도 이를 정상 재고로 처리하지 않습니다. 일반 JSON 문법 오류는 그대로 실패하고, content-type이 잘못 지정된 유효 JSON은 계속 허용합니다. 확인된 오류 코드 `3000`도 정상 빈 재고로 처리하지 않습니다. +- 올리브영 전체 헬스체크의 고정 5초 제한을 제거해 요청한 제한 시간을 적용합니다. 간이 검사의 요청 제한과 지연 경고는 유지합니다. 이마트24 매장·상품 핸들러도 `timeoutMs`를 반영합니다. + +## 남은 운영 제약 + +GS25 재고 원본은 HTTP 401과 `인증키가 제공되지 않음`을 반환합니다. 운영에 유효한 `GS25_API_KEY`를 설정해야 재고 복구를 검증할 수 있습니다. 공개 매장 API로 인증이 필요한 재고를 대체하지 않습니다. + +CU는 로컬 직접 요청이 성공했지만 운영 경로의 HTML 응답 원인은 확정되지 않았습니다. 이번 변경은 실패를 명확하게 전달하며 조회 자체를 복구했다고 주장하지 않습니다. 확인되지 않은 HTML에 유료 대체 요청을 추가하지 않습니다. + +이마트24 매장은 직접 검사에서 약 1.5초에 성공했다가 약 31초 후 시간 초과로 실패하고 다시 성공했습니다. 제한 시간 전달을 바로잡아도 외부 서버의 간헐 지연·403이 모두 해소되는 것은 아닙니다. + +## 검증 + +회귀 테스트는 GS25 공개 API와 좌표 변환, CU 응답 형식·조회 불가 상태, 간이/전체 검사 제한 시간 적용을 검증합니다. 운영 반영 뒤 매장 검색 및 전체 fresh 헬스체크로 실제 복구 범위와 잔여 장애를 확인합니다. diff --git a/src/api/emart24Handlers.ts b/src/api/emart24Handlers.ts index 309ef04b..bd148f9d 100644 --- a/src/api/emart24Handlers.ts +++ b/src/api/emart24Handlers.ts @@ -17,6 +17,8 @@ export async function handleEmart24FindStores(c: ApiContext) { const service24h = c.req.query('service24h') === 'true'; 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 rawLat = c.req.query('lat'); const rawLng = c.req.query('lng'); const parsedLat = rawLat ? parseFloat(rawLat) : undefined; @@ -34,7 +36,7 @@ export async function handleEmart24FindStores(c: ApiContext) { service24h, }, { - timeout: 15000, + timeout: timeoutMs, }, ); @@ -86,6 +88,8 @@ export async function handleEmart24SearchProducts(c: ApiContext) { const sortType = (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; if (keyword.trim().length === 0) { return errorResponse(c, 'MISSING_QUERY', '검색어(keyword)를 입력해주세요.'); @@ -101,7 +105,7 @@ export async function handleEmart24SearchProducts(c: ApiContext) { saleProductYn, }, { - timeout: 15000, + timeout: timeoutMs, }, ); diff --git a/src/api/gs25Handlers.ts b/src/api/gs25Handlers.ts index fd9e82db..4110d267 100644 --- a/src/api/gs25Handlers.ts +++ b/src/api/gs25Handlers.ts @@ -54,6 +54,7 @@ export async function handleGs25FindStores(c: ApiContext) { } } + let fallbackUsed = false; let storeResult = await fetchGs25Stores( { serviceCode, @@ -65,11 +66,15 @@ export async function handleGs25FindStores(c: ApiContext) { zyteApiKey: c.env?.ZYTE_API_KEY, apiKey: c.env?.GS25_API_KEY, }, - ); - let fallbackUsed = false; + ).catch(async (error: unknown) => { + if (!isGs25UpstreamUnavailableError(error) || keyword.trim().length === 0) throw error; + const webStoreResult = await fetchGs25WebStores(keyword, { timeout: 20000 }); + fallbackUsed = true; + return { ...webStoreResult, cacheHit: false }; + }); if ( - storeResult.stores.length === 0 && + !fallbackUsed && storeResult.stores.length === 0 && typeof latitude === 'number' && typeof longitude === 'number' ) { @@ -99,23 +104,10 @@ export async function handleGs25FindStores(c: ApiContext) { } } - if (storeResult.stores.length === 0 && keyword.trim().length > 0) { - try { - const webStoreResult = await fetchGs25WebStores(keyword, { - timeout: 20000, - }); - - if (webStoreResult.stores.length > 0) { - storeResult = { - totalCount: webStoreResult.totalCount, - stores: webStoreResult.stores, - cacheHit: false, - }; - fallbackUsed = true; - } - } catch { - fallbackUsed = false; - } + if (!fallbackUsed && storeResult.stores.length === 0 && keyword.trim().length > 0) { + const webStoreResult = await fetchGs25WebStores(keyword, { timeout: 20000 }); + storeResult = { ...webStoreResult, cacheHit: false }; + fallbackUsed = true; } const selected = selectGs25StoresForKeyword(storeResult.stores, keyword, { diff --git a/src/api/healthCheckDefinitions.ts b/src/api/healthCheckDefinitions.ts index 220951aa..663a66e6 100644 --- a/src/api/healthCheckDefinitions.ts +++ b/src/api/healthCheckDefinitions.ts @@ -132,7 +132,6 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [ path: '/api/oliveyoung/products?keyword=%EB%A6%BD%EB%B0%A4&size=1', collectionKey: 'products', requiredFields: ['goodsNumber', 'goodsName', 'productNo', 'productName', 'name'], - timeoutMs: 5000, }, { id: 'megabox.theaters', @@ -213,6 +212,5 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [ path: '/api/oliveyoung/inventory?keyword=%EC%84%A0%ED%81%AC%EB%A6%BC&storeKeyword=%EB%AA%85%EB%8F%99&size=1&storeLimit=1&stockCheckLimit=0', collectionKey: 'inventoryProducts', requiredFields: ['goodsNumber', 'goodsName', 'productNo', 'productName', 'name'], - timeoutMs: 5000, }, ]; diff --git a/src/api/healthCheckTypes.ts b/src/api/healthCheckTypes.ts index 80a347ee..9e6744ff 100644 --- a/src/api/healthCheckTypes.ts +++ b/src/api/healthCheckTypes.ts @@ -22,7 +22,6 @@ export interface HealthCheckDefinition { | 'inventoryItems' | 'inventoryStores'; requiredFields?: string[]; - timeoutMs?: number; degradedFailurePatterns?: string[]; allowEmpty?: boolean; } diff --git a/src/api/healthChecks.ts b/src/api/healthChecks.ts index 086e3013..6e3e6653 100644 --- a/src/api/healthChecks.ts +++ b/src/api/healthChecks.ts @@ -151,23 +151,6 @@ function shouldDegradeCliContractPath(path: string, message: string): boolean { return false; } -function resolveCheckTimeoutMs( - check: Pick, - timeoutMs: number, -): number { - if (check.timeoutMs === undefined) { - return timeoutMs; - } - return Math.min(timeoutMs, Math.trunc(check.timeoutMs)); -} - -function resolveCliContractTimeoutMs(path: string, timeoutMs: number): number { - if (path.startsWith('/api/oliveyoung/')) { - return Math.min(timeoutMs, 5000); - } - return timeoutMs; -} - function buildCheckUrl( baseUrl: string, check: HealthCheckDefinition, @@ -220,7 +203,7 @@ async function runCliContractCheck( const degradedMessages: string[] = []; for (const path of CLI_CONTRACT_PATHS) { - const checkTimeoutMs = resolveCliContractTimeoutMs(path, params.timeoutMs); + const checkTimeoutMs = params.timeoutMs; const syntheticCheck = { ...check, path }; try { const response = await params.fetchImpl( @@ -286,7 +269,7 @@ async function runSingleCheck( return runCliContractCheck(check, params); } - const timeoutMs = resolveCheckTimeoutMs(check, params.timeoutMs); + const timeoutMs = params.timeoutMs; const startedAt = params.now(); const cacheBustValue = params.cacheBust ? startedAt : undefined; @@ -318,6 +301,20 @@ async function runSingleCheck( }; } + const inventory = (body.data as { inventory?: { available?: boolean; unavailableReason?: unknown } } | undefined)?.inventory; + if (check.id === 'cu.inventory' && inventory?.available === false) { + const reason = inventory.unavailableReason; + return { + id: check.id, + service: check.service, + target: check.target, + status: 'degraded', + durationMs, + httpStatus: response.status, + message: typeof reason === 'string' && reason.trim() ? reason.trim() : 'inventory unavailable', + }; + } + const count = typeof body.meta?.total === 'number' ? body.meta.total : toCount(body.data); const shapeOk = hasRequiredRepresentativeFields( body.data, diff --git a/src/services/cu/client.ts b/src/services/cu/client.ts index c4991a4e..3df9e49d 100644 --- a/src/services/cu/client.ts +++ b/src/services/cu/client.ts @@ -2,10 +2,11 @@ * CU API 클라이언트 */ -import { fetchJson, HttpError } from '../../utils/http.js'; +import { fetchJson } from '../../utils/http.js'; import { decodeBase64, requestByZyte } from '../../utils/zyte.js'; import { fetchJsonWithZyteFallback } from '../../utils/zyteJsonFallback.js'; import { CU_API } from './api.js'; +import { cuStockUnavailableReason } from './upstreamError.js'; import type { CuStockItem, CuStockMainResponse, CuStore, CuStoreResponse } from './types.js'; interface RequestOptions { @@ -402,17 +403,11 @@ export async function fetchCuStock( try { body = await requestCuJson(CU_API.STOCK_MAIN_PATH, payload, options); } catch (error) { - const message = error instanceof Error ? error.message : ''; - const originBlocked = error instanceof HttpError && [400, 403, 429].includes(error.status); - const zyteBlocked = - message.includes('Zyte API 호출 실패: 520') || - message.includes('Zyte 대상 응답 실패: 520'); - if (originBlocked || zyteBlocked) { + const unavailableReason = cuStockUnavailableReason(error); + if (unavailableReason) { return { available: false, - unavailableReason: originBlocked - ? `CU 재고 API가 차단되었습니다 (${error.status} Request Blocked).` - : 'CU 재고 API가 차단되었습니다 (Zyte Website Ban 520).', + unavailableReason, totalCount: 0, spellModifyYn: 'N', items: [], @@ -421,6 +416,9 @@ export async function fetchCuStock( throw error; } + if (body.resp_cd === '3000') { + throw new Error('CU 재고 API가 조회 실패 응답을 반환했습니다 (3000).'); + } const result = body.data?.stockResult?.result; const rows = result?.rows || []; diff --git a/src/services/cu/types.ts b/src/services/cu/types.ts index 46954075..609fb218 100644 --- a/src/services/cu/types.ts +++ b/src/services/cu/types.ts @@ -64,6 +64,7 @@ interface CuStockRow { } export interface CuStockMainResponse { + resp_cd?: string; spellModifyYn?: string; data?: { stockResult?: { diff --git a/src/services/cu/upstreamError.ts b/src/services/cu/upstreamError.ts new file mode 100644 index 00000000..4f147fdb --- /dev/null +++ b/src/services/cu/upstreamError.ts @@ -0,0 +1,16 @@ +/** CU 재고 조회 불가 사유를 응답 형식과 전송 실패에 따라 구분합니다. */ +import { HttpError, UnexpectedHtmlResponseError } from '../../utils/http.js'; + +export function cuStockUnavailableReason(error: unknown): string | null { + if (error instanceof UnexpectedHtmlResponseError) { + return 'CU 재고 API가 JSON 대신 HTML을 반환하여 재고를 확인할 수 없습니다.'; + } + if (error instanceof HttpError && [400, 403, 429].includes(error.status)) { + return `CU 재고 API가 차단되었습니다 (${error.status} Request Blocked).`; + } + const message = error instanceof Error ? error.message : ''; + if (message.includes('Zyte API 호출 실패: 520') || message.includes('Zyte 대상 응답 실패: 520')) { + return 'CU 재고 API가 차단되었습니다 (Zyte Website Ban 520).'; + } + return null; +} diff --git a/src/services/gs25/api.ts b/src/services/gs25/api.ts index 54984d51..13403df7 100644 --- a/src/services/gs25/api.ts +++ b/src/services/gs25/api.ts @@ -7,7 +7,7 @@ export const GS25_API = { STORE_STOCK_PATH: '/api/bff/v2/store/stock', APIGW_BASE_URL: 'https://b2c-apigw.woodongs.com', TOTAL_SEARCH_PATH: '/search/v3/totalSearch', - WEB_BASE_URL: 'https://gs25.gsretail.com', + WEB_BASE_URL: 'https://www.gsretail.com', WEB_LOCATIONS_PATH: '/gscvs/ko/store-services/locations', - WEB_LOCATION_LIST_PATH: '/gscvs/ko/store-services/locationList', + WEB_LOCATION_LIST_PATH: '/api/homepage/brand/storeSearch/selectGs25Stores', } as const; diff --git a/src/services/gs25/client.ts b/src/services/gs25/client.ts index 14f18bbf..1a0ce17e 100644 --- a/src/services/gs25/client.ts +++ b/src/services/gs25/client.ts @@ -3,7 +3,7 @@ */ /* c8 ignore start */ -import { fetchJson, fetchWithTimeout, HttpError } from '../../utils/http.js'; +import { fetchJson } from '../../utils/http.js'; import { GS25_API } from './api.js'; import { normalizeStore, toNumber } from './storeUtils.js'; import { fetchGs25StoreStockResponse } from './storeStockTransport.js'; @@ -47,16 +47,8 @@ interface Gs25WebLocationStore { shopCode?: string; shopName?: string; address?: string; - longs?: string | number; - lat?: string | number; - offeringService?: string[]; -} - -interface Gs25WebLocationResponse { - results?: Gs25WebLocationStore[]; - pagination?: { - totalNumberOfResults?: number; - }; + posX?: string | number; + posY?: string | number; } interface GoogleGeocodeResponse { @@ -92,39 +84,16 @@ const GS25_DEFAULT_FETCH_OPTIONS = { const GS25_STORES_CACHE_TTL_MS = 60 * 5 * 1000; const gs25StoresCache = new Map(); -function extractGs25WebCsrfToken(html: string): string | null { - return ( - html.match(/name="CSRFToken"\s+value="([^"]+)"/)?.[1] || - html.match(/ACC\.config\.CSRFToken\s*=\s*"([^"]+)"/)?.[1] || - null - ); -} - -function extractGs25WebCookieHeader(headers: Headers): string | undefined { - const setCookie = headers.get('set-cookie') || ''; - const jsessionId = setCookie.match(/JSESSIONID=[^;,\s]+/)?.[0]; - return jsessionId; -} - -function parseGs25WebLocationResponse( - body: string | Gs25WebLocationResponse, -): Gs25WebLocationResponse { - if (typeof body === 'string') { - return JSON.parse(body) as Gs25WebLocationResponse; - } - return body; -} - function normalizeGs25WebStore(raw: Gs25WebLocationStore): Gs25Store { - const propertyNames = (raw.offeringService || []).filter((item) => item.trim().length > 0); + const propertyNames: string[] = []; return { storeCode: raw.shopCode || '', storeName: raw.shopName || '', address: raw.address || '', phone: '', - latitude: toNumber(raw.longs), - longitude: toNumber(raw.lat), + latitude: toNumber(raw.posY), + longitude: toNumber(raw.posX), serviceCode: '01', realStockQuantity: 0, pickupStockQuantity: 0, @@ -209,97 +178,18 @@ export async function fetchGs25WebStores( } const { timeout = 20000 } = options; - const locationsUrl = new URL(GS25_API.WEB_LOCATIONS_PATH, GS25_API.WEB_BASE_URL); - const locationsResponse = await fetchWithTimeout(locationsUrl.toString(), { + const endpoint = new URL(GS25_API.WEB_LOCATION_LIST_PATH, GS25_API.WEB_BASE_URL); + endpoint.searchParams.set('shopName', query); + const body = await fetchJson(endpoint.toString(), { ...GS25_DEFAULT_FETCH_OPTIONS, - method: 'GET', timeout, - headers: { - Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'Accept-Language': GS25_DEFAULT_HEADERS['Accept-Language'], - 'User-Agent': GS25_DEFAULT_HEADERS['User-Agent'], - }, + headers: { Accept: 'application/json' }, }); - - if (!locationsResponse.ok) { - throw new HttpError( - locationsResponse.status, - locationsResponse.statusText, - await locationsResponse.text(), - ); - } - - const html = await locationsResponse.text(); - const csrfToken = extractGs25WebCsrfToken(html); - if (!csrfToken) { - throw new Error('GS25 웹 매장 검색 CSRF 토큰을 찾을 수 없습니다.'); + if (!Array.isArray(body)) { + throw new Error('GS25 매장 응답 형식이 올바르지 않습니다.'); } - - const form = new URLSearchParams({ - pageNum: '1', - pageSize: '50', - searchShopName: query, - searchSido: '', - searchGugun: '', - searchDong: '', - searchType: '', - searchTypeService: '0', - searchTypeToto: '0', - searchTypeCafe25: '0', - searchTypeInstant: '0', - searchTypeDrug: '0', - searchTypeSelf25: '0', - searchTypePost: '0', - searchTypeATM: '0', - searchTypeWithdrawal: '0', - searchTypeTaxrefund: '0', - searchTypeSmartAtm: '0', - searchTypeSelfCookingUtensils: '0', - searchTypeDeliveryService: '0', - searchTypeParcelService: '0', - searchTypePotatoes: '0', - searchTypeCardiacDefi: '0', - searchTypeFishShapedBun: '0', - searchTypeWine25: '0', - searchTypeGoPizza: '0', - searchTypeSpiritWine: '0', - searchTypeFreshGanghw: '0', - searchTypeMusinsa: '0', - searchTypePosa: '0', - }); - const locationListUrl = new URL(GS25_API.WEB_LOCATION_LIST_PATH, GS25_API.WEB_BASE_URL); - locationListUrl.searchParams.set('CSRFToken', csrfToken); - const cookieHeader = extractGs25WebCookieHeader(locationsResponse.headers); - - const body = parseGs25WebLocationResponse( - await fetchJson(locationListUrl.toString(), { - ...GS25_DEFAULT_FETCH_OPTIONS, - method: 'POST', - retryUnsafeMethods: true, - timeout, - headers: { - Accept: 'application/json, text/javascript, */*; q=0.01', - 'Accept-Language': GS25_DEFAULT_HEADERS['Accept-Language'], - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - Referer: locationsUrl.toString(), - 'User-Agent': GS25_DEFAULT_HEADERS['User-Agent'], - 'X-Requested-With': 'XMLHttpRequest', - ...(cookieHeader ? { Cookie: cookieHeader } : {}), - }, - body: form.toString(), - }), - ); - const stores = (body.results || []) - .map(normalizeGs25WebStore) - .filter((store) => store.storeCode.length > 0); - - return { - totalCount: - typeof body.pagination?.totalNumberOfResults === 'number' - ? body.pagination.totalNumberOfResults - : stores.length, - stores, - }; + const stores = body.map(normalizeGs25WebStore).filter((store) => store.storeCode.length > 0); + return { totalCount: stores.length, stores }; } export async function fetchGs25Stores( diff --git a/src/services/gs25/tools/findNearbyStores.ts b/src/services/gs25/tools/findNearbyStores.ts index a8db74e2..0c3a4169 100644 --- a/src/services/gs25/tools/findNearbyStores.ts +++ b/src/services/gs25/tools/findNearbyStores.ts @@ -4,9 +4,11 @@ import * as z from 'zod'; import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; +import { isGs25UpstreamUnavailableError } from '../errors.js'; import { attachDistanceToGs25Stores, fetchGs25Stores, + fetchGs25WebStores, geocodeGs25Address, selectGs25StoresForKeyword, sortGs25Stores, @@ -63,6 +65,7 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise { + if (!isGs25UpstreamUnavailableError(error) || keyword.trim().length === 0) throw error; + const webResult = await fetchGs25WebStores(keyword, { timeout: timeoutMs }); + fallbackUsed = true; + return { ...webResult, cacheHit: false }; + }); if ( - result.stores.length === 0 && + !fallbackUsed && result.stores.length === 0 && typeof resolvedLatitude === 'number' && typeof resolvedLongitude === 'number' ) { @@ -108,6 +115,12 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise 0) { + const webResult = await fetchGs25WebStores(keyword, { timeout: timeoutMs }); + result = { ...webResult, cacheHit: false }; + fallbackUsed = true; + } + const selected = selectGs25StoresForKeyword(result.stores, keyword, { relaxWhenEmpty: typeof resolvedLatitude === 'number' && typeof resolvedLongitude === 'number', }); diff --git a/src/utils/http.ts b/src/utils/http.ts index ffe519bd..be149e50 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -39,6 +39,14 @@ export class HttpError extends Error { } } +/** HTML 원문을 노출하지 않고 예상하지 못한 응답 형식을 구분합니다. */ +export class UnexpectedHtmlResponseError extends Error { + constructor(readonly status: number, readonly contentType: string) { + super(`JSON 대신 HTML 응답을 받았습니다 (HTTP ${status}).`); + this.name = 'UnexpectedHtmlResponseError'; + } +} + export function createTimeoutController( timeout: number, ): { controller: AbortController; timeoutId: ReturnType } { @@ -163,7 +171,11 @@ export async function fetchJson(url: string, options: FetchOptions = {}): Pro throw new HttpError(response.status, response.statusText, await response.text()); } - return response.json() as Promise; + const body = await response.text(); + if (/^\s*(?: { diff --git a/tests/api/emart24-handlers.test.ts b/tests/api/emart24-handlers.test.ts index a275c9f2..69fa7d44 100644 --- a/tests/api/emart24-handlers.test.ts +++ b/tests/api/emart24-handlers.test.ts @@ -323,3 +323,22 @@ describe('handleEmart24CheckInventory', () => { ); }); }); + + +describe.each([ + ['stores', handleEmart24FindStores], + ['products', handleEmart24SearchProducts], +] as const)('%s timeoutMs', (_name, handler) => { + it.each([ + ['2345', 2345], + ['20000', 20000], + ['invalid', 15000], + ['0', 15000], + ['-1', 15000], + ])('요청 제한 %s를 %i ms로 적용한다', async (timeoutMs, expected) => { + mockFetch.mockResolvedValue(new Response(JSON.stringify({ error: 0, data: [] }))); + const timer = vi.spyOn(globalThis, 'setTimeout'); + await handler(createMockContext({ keyword: '강남', timeoutMs })); + expect(timer).toHaveBeenCalledWith(expect.any(Function), expected); + }); +}); diff --git a/tests/api/gs25-handlers.test.ts b/tests/api/gs25-handlers.test.ts index 286369b3..5453c190 100644 --- a/tests/api/gs25-handlers.test.ts +++ b/tests/api/gs25-handlers.test.ts @@ -37,6 +37,30 @@ function createMockContext(query: Record = {}) { } describe('handleGs25FindStores', () => { + it('재고 API 인증 실패 시 키워드 공개 검색으로 복구한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('{}', { status: 401 })) + .mockResolvedValueOnce(new Response(JSON.stringify([ + { shopCode: 'VY010', shopName: 'GS25강남', posX: 127.02, posY: 37.49 }, + ]))); + const ctx = createMockContext({ keyword: '강남' }); + await handleGs25FindStores(ctx); + expect(ctx.json).toHaveBeenCalledWith(expect.objectContaining({ + success: true, data: expect.objectContaining({ fallbackUsed: true, + stores: [expect.objectContaining({ storeCode: 'VY010' })] }), + })); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it.each([401, 200])('원본 %s 이후 공개 매장 검색 실패를 숨기지 않는다', async (status) => { + mockFetch.mockResolvedValueOnce(new Response('{"stores":[]}', { status })) + .mockImplementation(() => Promise.resolve(new Response('public unavailable', { status: 503 }))); + const ctx = createMockContext({ keyword: '강남' }); + await handleGs25FindStores(ctx); + expect(ctx.json).toHaveBeenCalledWith(expect.objectContaining({ + success: false, error: expect.objectContaining({ message: expect.stringContaining('503') }), + }), 500); + }); + it('매장 검색 결과를 반환한다', async () => { mockFetch.mockResolvedValue( new Response( @@ -60,30 +84,9 @@ describe('handleGs25FindStores', () => { it('store/stock 매장 조회가 0건이면 GS25 웹 매장 검색으로 fallback한다', async () => { mockFetch .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))) - .mockResolvedValueOnce( - new Response('
', { - headers: { - 'Set-Cookie': 'JSESSIONID=session-id; Path=/; HttpOnly', - }, - }), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify( - JSON.stringify({ - results: [ - { - shopCode: 'VY010', - shopName: 'GS25S강남역1호점', - address: '서울 강남구 강남대로390', - longs: '37.4978492897333', - lat: '127.028648954517', - }, - ], - }), - ), - ), - ); + .mockResolvedValueOnce(new Response(JSON.stringify([ + { shopCode: 'VY010', shopName: 'GS25강남', address: '서울', posY: 37.49, posX: 127.02 }, + ]))); const ctx = createMockContext({ keyword: '강남', limit: '1' }); await handleGs25FindStores(ctx); diff --git a/tests/api/health-checks.test.ts b/tests/api/health-checks.test.ts index 6eb5f47a..e6beff94 100644 --- a/tests/api/health-checks.test.ts +++ b/tests/api/health-checks.test.ts @@ -22,6 +22,23 @@ function jsonResponse(body: unknown, status = 200): Response { } describe('runHealthChecks', () => { + it.each([ + ['CU 재고 API가 JSON 대신 HTML을 반환하여 재고를 확인할 수 없습니다.', 'CU 재고 API가 JSON 대신 HTML을 반환하여 재고를 확인할 수 없습니다.'], + [null, 'inventory unavailable'], + [' ', 'inventory unavailable'], + ])('CU 재고 unavailable 응답은 사유를 보존하여 degraded로 보고한다', async (unavailableReason, message) => { + const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse({ + success: true, + data: { inventory: { available: false, unavailableReason, items: [{ itemName: '커피' }] } }, + meta: { total: 1 }, + })); + const result = await runHealthChecks({ + baseUrl: 'https://example.com', check: 'cu.inventory', mode: 'deep', fetchImpl, fresh: true, + }); + expect(result.checks[0]).toMatchObject({ status: 'degraded', httpStatus: 200, message }); + expect(result.status).toBe('degraded'); + }); + it('편의점 상품·매장·재고 운영 체크 12개를 모두 정의한다', () => { const convenienceCheckIds = HEALTH_CHECKS.filter((check) => ['cu', 'gs25', 'seveneleven', 'emart24'].includes(check.service), @@ -101,7 +118,7 @@ describe('runHealthChecks', () => { const oliveyoungCall = fetchImpl.mock.calls.find((call) => String(call[0]).includes('/api/oliveyoung/products?'), ); - expect(String(oliveyoungCall?.[0])).toContain('timeoutMs=5000'); + expect(String(oliveyoungCall?.[0])).toContain('timeoutMs=7000'); }); it('full 모드에서 quick과 deep 체크를 함께 실행한다', async () => { @@ -216,10 +233,10 @@ describe('runHealthChecks', () => { ); expect(String(fetchImpl.mock.calls[0][0])).toContain('/api/oliveyoung/inventory?'); expect(String(fetchImpl.mock.calls[0][0])).toContain('stockCheckLimit=0'); - expect(String(fetchImpl.mock.calls[0][0])).toContain('timeoutMs=5000'); + expect(String(fetchImpl.mock.calls[0][0])).toContain('timeoutMs=7000'); }); - it('올리브영 체크는 전역 timeout이 커도 짧은 서비스 timeout을 사용한다', async () => { + it.each([5000, 20000])('올리브영 체크는 요청한 %i ms 제한을 사용한다', async (timeoutMs) => { const fetchImpl = vi.fn().mockResolvedValueOnce( jsonResponse({ success: true, @@ -233,14 +250,14 @@ describe('runHealthChecks', () => { const result = await runHealthChecks({ baseUrl: 'https://example.com', check: 'oliveyoung.products', - timeoutMs: 20000, + timeoutMs, fetchImpl, now: () => 1000, fresh: true, }); expect(result.status).toBe('ok'); - expect(String(fetchImpl.mock.calls[0][0])).toContain('timeoutMs=5000'); + expect(String(fetchImpl.mock.calls[0][0])).toContain(`timeoutMs=${timeoutMs}`); expect(fetchImpl.mock.calls[0][1]).toEqual( expect.objectContaining({ signal: expect.any(AbortSignal), diff --git a/tests/app/app-api-cu.test.ts b/tests/app/app-api-cu.test.ts index f8571e56..a0a01aef 100644 --- a/tests/app/app-api-cu.test.ts +++ b/tests/app/app-api-cu.test.ts @@ -50,6 +50,20 @@ describe('GET /api/cu/stores', () => { }); describe('GET /api/cu/inventory', () => { + it('HTML 응답은 재고 확인 불가 사유를 API 응답에 보존한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('{}')).mockResolvedValueOnce( + new Response('private upstream page'), + ); + const res = await app.request('/api/cu/inventory?keyword=과자&storeCheck=false'); + const body = await res.json(); + expect(body.data.inventory).toMatchObject({ + available: false, + unavailableReason: 'CU 재고 API가 JSON 대신 HTML을 반환하여 재고를 확인할 수 없습니다.', + items: [], + }); + expect(JSON.stringify(body)).not.toContain('private upstream page'); + }); + it('CU 재고 검색 결과를 반환한다', async () => { mockFetch .mockResolvedValueOnce( diff --git a/tests/services/cu/client.test.ts b/tests/services/cu/client.test.ts index 69330412..c3ce36be 100644 --- a/tests/services/cu/client.test.ts +++ b/tests/services/cu/client.test.ts @@ -12,6 +12,27 @@ import { const mockFetch = vi.fn(); +it('CU 조회 실패 응답을 빈 재고로 처리하지 않는다', async () => { + mockFetch.mockResolvedValueOnce(new Response('{}')).mockResolvedValueOnce(new Response( + JSON.stringify({ resp_cd: '3000', resp_msg: '재고조회 조회 실패' }), + )); + await expect(fetchCuStock({ keyword: '과자', limit: 1, offset: 0, searchSort: 'recom' })) + .rejects.toThrow('CU 재고 API가 조회 실패 응답을 반환했습니다 (3000).'); +}); + +it('재고 HTML 응답은 빈 재고 성공 대신 사용 불가로 반환한다', async () => { + mockFetch + .mockResolvedValueOnce(new Response('{}')) + .mockResolvedValueOnce(new Response('\r\nupstream page')); + const result = await fetchCuStock({ keyword: '과자', limit: 1, offset: 0, searchSort: 'recom' }); + expect(result).toMatchObject({ + available: false, + unavailableReason: 'CU 재고 API가 JSON 대신 HTML을 반환하여 재고를 확인할 수 없습니다.', + items: [], + }); + expect(mockFetch).toHaveBeenCalledTimes(2); +}); + function zyteJsonResponse(value: unknown): Response { return new Response( JSON.stringify({ @@ -376,20 +397,9 @@ describe('fetchCuStores', () => { }); it('비정상 숫자/문자 값은 0으로 보정하고 storeCode 기본값을 사용한다', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - totalCnt: 1, - storeList: [ - { - storeNm: '테스트점', - latVal: Number.POSITIVE_INFINITY, - longVal: Number.NaN, - stock: 'abc', - }, - ], - }), - } as unknown as Response); + mockFetch.mockResolvedValue(new Response( + '{"totalCnt":1,"storeList":[{"storeNm":"테스트점","latVal":1e400,"longVal":-1e400,"stock":"abc"}]}', + )); const result = await fetchCuStores({ latitude: 37.5, longitude: 127.0 }); diff --git a/tests/services/gs25/client.test.ts b/tests/services/gs25/client.test.ts index 944ba477..e8cbcc95 100644 --- a/tests/services/gs25/client.test.ts +++ b/tests/services/gs25/client.test.ts @@ -8,7 +8,6 @@ import { calculateDistanceM, clearGs25StoresCache, extractGs25ProductCandidates, - fetchGs25WebStores, fetchGs25Stores, fetchGs25SearchProducts, filterGs25StoresByKeyword, @@ -276,64 +275,6 @@ describe('fetchGs25Stores', () => { }); }); -describe('fetchGs25WebStores', () => { - it('GS25 웹 매장 검색 응답을 정규화한다', async () => { - mockFetch - .mockResolvedValueOnce( - new Response('
', { - headers: { - 'Set-Cookie': 'JSESSIONID=session-id; Path=/; HttpOnly', - }, - }), - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify( - JSON.stringify({ - results: [ - { - shopCode: 'VY010', - shopName: 'GS25S강남역1호점', - address: '서울 강남구 강남대로390', - longs: '37.4978492897333', - lat: '127.028648954517', - offeringService: ['cafe25', 'parcel_service'], - }, - ], - pagination: { totalNumberOfResults: 1 }, - }), - ), - ), - ); - - const result = await fetchGs25WebStores('강남', { timeout: 20000 }); - - expect(result.totalCount).toBe(1); - expect(result.stores[0]).toEqual( - expect.objectContaining({ - storeCode: 'VY010', - storeName: 'GS25S강남역1호점', - address: '서울 강남구 강남대로390', - latitude: 37.4978492897333, - longitude: 127.028648954517, - propertyNames: ['cafe25', 'parcel_service'], - }), - ); - expect(String((mockFetch.mock.calls[1][1] as RequestInit).body)).toContain( - 'searchShopName=%EA%B0%95%EB%82%A8', - ); - expect(mockFetch.mock.calls[1][1]).toEqual( - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - Cookie: 'JSESSIONID=session-id', - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - }), - }), - ); - }); -}); - describe('fetchGs25SearchProducts', () => { it('읽기성 POST 상품 검색은 allowlist로 재시도한다', async () => { mockFetch diff --git a/tests/services/gs25/tools/findNearbyStores.test.ts b/tests/services/gs25/tools/findNearbyStores.test.ts index 84ae412b..ee253eb6 100644 --- a/tests/services/gs25/tools/findNearbyStores.test.ts +++ b/tests/services/gs25/tools/findNearbyStores.test.ts @@ -19,6 +19,45 @@ afterEach(() => { }); describe('createFindNearbyStoresTool', () => { + it('재고 API 인증 실패 시 공개 키워드 검색으로 복구한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('{}', { status: 401 })) + .mockResolvedValueOnce(new Response(JSON.stringify([ + { shopCode: 'VY010', shopName: 'GS25강남', posX: 127.02, posY: 37.49 }, + ]))); + const response = await createFindNearbyStoresTool().handler({ keyword: '강남' }); + expect(JSON.parse(response.content[0].text)).toMatchObject({ + fallbackUsed: true, stores: [{ storeCode: 'VY010' }], + }); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it.each([401, 200])('원본 %s 이후 공개 검색 실패를 호출자에게 전달한다', async (status) => { + mockFetch.mockResolvedValueOnce(new Response('{"stores":[]}', { status })) + .mockImplementation(() => Promise.resolve(new Response('public unavailable', { status: 503 }))); + await expect(createFindNearbyStoresTool().handler({ keyword: '강남' })).rejects.toThrow('503'); + }); + + it('좌표만 주어진 인증 실패는 공개 검색으로 숨기지 않는다', async () => { + mockFetch.mockResolvedValueOnce(new Response('{}', { status: 401 })); + await expect(createFindNearbyStoresTool().handler({ latitude: 37.5, longitude: 127 })) + .rejects.toThrow('인증'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('일반 전송 오류는 공개 검색으로 숨기지 않는다', async () => { + mockFetch.mockRejectedValue(new Error('network failed')); + await expect(createFindNearbyStoresTool().handler({ keyword: '강남' })).rejects.toThrow('network failed'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('인증 실패 후 공개 검색이 비면 추가 재고 조회 없이 빈 결과를 반환한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('{}', { status: 401 })) + .mockResolvedValueOnce(new Response('[]')); + const response = await createFindNearbyStoresTool().handler({ keyword: '강남', latitude: 37.5, longitude: 127 }); + expect(JSON.parse(response.content[0].text)).toMatchObject({ count: 0, fallbackUsed: true }); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + it('올바른 도구 정의를 반환한다', () => { const tool = createFindNearbyStoresTool(); @@ -200,13 +239,15 @@ describe('createFindNearbyStoresTool', () => { ), ) .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))) - .mockRejectedValueOnce(new Error('fallback stock unavailable')); + .mockRejectedValueOnce(new Error('fallback stock unavailable')) + .mockRejectedValueOnce(new Error('fallback stock unavailable')) + .mockResolvedValueOnce(new Response('[]')); const tool = createFindNearbyStoresTool(); const result = await tool.handler({ keyword: '강남', limit: 3 }); const parsed = JSON.parse(result.content[0].text); - expect(parsed.fallbackUsed).toBe(false); + expect(parsed.fallbackUsed).toBe(true); expect(parsed.count).toBe(0); process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; @@ -226,13 +267,14 @@ describe('createFindNearbyStoresTool', () => { ), ) .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))) - .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))); + .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))) + .mockResolvedValueOnce(new Response('[]')); const tool = createFindNearbyStoresTool(); const result = await tool.handler({ keyword: '강남', limit: 3 }); const parsed = JSON.parse(result.content[0].text); - expect(parsed.fallbackUsed).toBe(false); + expect(parsed.fallbackUsed).toBe(true); expect(parsed.count).toBe(0); process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; diff --git a/tests/services/gs25/web-store-migration.test.ts b/tests/services/gs25/web-store-migration.test.ts new file mode 100644 index 00000000..6ea3b5f4 --- /dev/null +++ b/tests/services/gs25/web-store-migration.test.ts @@ -0,0 +1,37 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { fetchGs25WebStores } from '../../../src/services/gs25/client.js'; +import { createFindNearbyStoresTool } from '../../../src/services/gs25/tools/findNearbyStores.js'; + +afterEach(() => vi.unstubAllGlobals()); + +it('새 공개 매장 API에서 좌표를 정규화한다', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify([ + { shopCode: 'VY010', shopName: 'GS25강남', address: '서울 강남구', posX: 127.02, posY: 37.49 }, + ]))); + vi.stubGlobal('fetch', fetchMock); + const result = await fetchGs25WebStores('강남'); + expect(result.stores[0]).toMatchObject({ storeCode: 'VY010', latitude: 37.49, longitude: 127.02 }); + const url = new URL(fetchMock.mock.calls[0][0]); + expect(url.origin).toBe('https://www.gsretail.com'); + expect(url.pathname).toBe('/api/homepage/brand/storeSearch/selectGs25Stores'); + expect(url.searchParams.get('shopName')).toBe('강남'); + expect(fetchMock.mock.calls[0][1].headers.Accept).toBe('application/json'); + expect(fetchMock).toHaveBeenCalledTimes(1); +}); + +it('예상하지 못한 응답 구조를 빈 매장 목록으로 숨기지 않는다', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: 'unavailable' })))); + await expect(fetchGs25WebStores('강남')).rejects.toThrow(); +}); + +it('MCP에서도 재고 기반 매장이 없으면 공개 매장 검색을 사용한다', async () => { + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))) + .mockResolvedValueOnce(new Response(JSON.stringify([ + { shopCode: 'VY010', shopName: 'GS25강남', posX: 127.02, posY: 37.49 }, + ])))); + const response = await createFindNearbyStoresTool().handler({ keyword: '강남', googleMapsApiKey: '' }); + const result = JSON.parse(response.content[0].text); + expect(result.stores[0].storeCode).toBe('VY010'); + expect(result.fallbackUsed).toBe(true); +}); diff --git a/tests/utils/httpJson.test.ts b/tests/utils/httpJson.test.ts new file mode 100644 index 00000000..59fe9584 --- /dev/null +++ b/tests/utils/httpJson.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fetchJson } from '../../src/utils/http.js'; + +afterEach(() => vi.unstubAllGlobals()); + +describe('JSON 응답 형식 검증', () => { + it.each(['\r\nprivate content', 'private content'])( + 'HTML 응답을 본문 노출 없는 구분 가능한 오류로 반환한다', async (body) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body, { + headers: { 'Content-Type': 'text/html' }, + }))); + await expect(fetchJson('https://example.com')).rejects.toMatchObject({ + name: 'UnexpectedHtmlResponseError', status: 200, contentType: 'text/html', + }); + }, + ); + + it('Content-Type이 없어도 HTML 본문을 구분한다', async () => { + const response = new Response('private content'); + response.headers.delete('content-type'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)); + await expect(fetchJson('https://example.com')).rejects.toMatchObject({ + name: 'UnexpectedHtmlResponseError', contentType: '', + message: 'JSON 대신 HTML 응답을 받았습니다 (HTTP 200).', + }); + }); + + it('잘못 표기된 Content-Type의 유효한 JSON을 허용한다', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{"ok":true}', { + headers: { 'Content-Type': 'text/html' }, + }))); + await expect(fetchJson('https://example.com')).resolves.toEqual({ ok: true }); + }); + + it.each(['{broken', '', ''])('HTML이 아닌 잘못된 JSON은 구문 오류로 유지한다', async (body) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body))); + await expect(fetchJson('https://example.com')).rejects.toBeInstanceOf(SyntaxError); + }); +}); diff --git a/tests/utils/zyteJsonFallback.test.ts b/tests/utils/zyteJsonFallback.test.ts index cfce2c58..92c59d5a 100644 --- a/tests/utils/zyteJsonFallback.test.ts +++ b/tests/utils/zyteJsonFallback.test.ts @@ -34,6 +34,14 @@ afterEach(() => { }); describe('fetchJsonWithZyteFallback', () => { + it('HTML 성공 응답은 유료 재시도 없이 형식 오류를 전달한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('page')); + await expect(fetchJsonWithZyteFallback('https://example.com/api', { + zyteApiKey: 'test-key', + })).rejects.toMatchObject({ name: 'UnexpectedHtmlResponseError' }); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + it('원본 요청이 성공하면 Zyte를 호출하지 않는다', async () => { mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ success: true }), {