From 4cf00071eba5e8e38ec1654b7ccf19874c15b46f Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Fri, 15 May 2026 15:50:39 +0900 Subject: [PATCH] fix live smoke fallbacks --- src/api/gs25Handlers.ts | 35 +++- src/api/lottemartHandlers.ts | 2 + src/index.ts | 1 + src/services/gs25/tools/findNearbyStores.ts | 40 ++++- src/services/lottemart/index.ts | 16 +- .../lottemart/tools/findNearbyStores.ts | 3 +- .../lottemart/tools/searchProducts.ts | 8 +- .../gs25/tools/findNearbyStores.test.ts | 157 ++++++++++++++++++ .../lottemart/tools/findNearbyStores.test.ts | 47 ++++++ .../lottemart/tools/searchProducts.test.ts | 37 +++++ 10 files changed, 337 insertions(+), 9 deletions(-) diff --git a/src/api/gs25Handlers.ts b/src/api/gs25Handlers.ts index 23dfcd6..c0bd182 100644 --- a/src/api/gs25Handlers.ts +++ b/src/api/gs25Handlers.ts @@ -45,7 +45,7 @@ export async function handleGs25FindStores(c: ApiContext) { } } - const storeResult = await fetchGs25Stores( + let storeResult = await fetchGs25Stores( { serviceCode, latitude, @@ -55,6 +55,38 @@ export async function handleGs25FindStores(c: ApiContext) { timeout: 20000, }, ); + let fallbackUsed = false; + + if (storeResult.stores.length === 0 && typeof latitude === 'number' && typeof longitude === 'number') { + try { + const fallbackProduct = (await fetchGs25SearchProducts('오감자', { timeout: 20000 })).find( + (product) => product.itemCode.trim().length > 0, + ); + + if (fallbackProduct) { + const fallbackResult = await fetchGs25Stores( + { + serviceCode, + itemCode: fallbackProduct.itemCode, + realTimeStockYn: 'Y', + latitude, + longitude, + useCache: false, + }, + { + timeout: 20000, + }, + ); + + if (fallbackResult.stores.length > 0) { + storeResult = fallbackResult; + fallbackUsed = true; + } + } + } catch { + fallbackUsed = false; + } + } const selected = selectGs25StoresForKeyword(storeResult.stores, keyword, { relaxWhenEmpty: typeof latitude === 'number' && typeof longitude === 'number', @@ -74,6 +106,7 @@ export async function handleGs25FindStores(c: ApiContext) { : null, cacheHit: storeResult.cacheHit, filterRelaxed: selected.filterRelaxed, + fallbackUsed, stores, }, { diff --git a/src/api/lottemartHandlers.ts b/src/api/lottemartHandlers.ts index b2b692e..a067d75 100644 --- a/src/api/lottemartHandlers.ts +++ b/src/api/lottemartHandlers.ts @@ -36,6 +36,7 @@ export async function handleLotteMartFindStores(c: ApiContext) { { timeout: DEFAULT_LOTTEMART_TIMEOUT_MS, googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY, + zyteApiKey: c.env?.ZYTE_API_KEY, }, ); @@ -92,6 +93,7 @@ export async function handleLotteMartSearchProducts(c: ApiContext) { }, { timeout: DEFAULT_LOTTEMART_TIMEOUT_MS, + zyteApiKey: c.env?.ZYTE_API_KEY, }, ); diff --git a/src/index.ts b/src/index.ts index 74ac051..49dada1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,6 +71,7 @@ const createRegistry = (bindings?: AppBindings) => { () => createLotteMartService({ googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY, + zyteApiKey: bindings?.ZYTE_API_KEY, }), createMegaboxService, () => diff --git a/src/services/gs25/tools/findNearbyStores.ts b/src/services/gs25/tools/findNearbyStores.ts index 869158f..620f8e4 100644 --- a/src/services/gs25/tools/findNearbyStores.ts +++ b/src/services/gs25/tools/findNearbyStores.ts @@ -6,6 +6,7 @@ import * as z from 'zod'; import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; import { attachDistanceToGs25Stores, + fetchGs25SearchProducts, fetchGs25Stores, geocodeGs25Address, selectGs25StoresForKeyword, @@ -50,7 +51,7 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise product.itemCode.trim().length > 0, + ); + + if (fallbackProduct) { + const fallbackResult = await fetchGs25Stores( + { + serviceCode, + itemCode: fallbackProduct.itemCode, + realTimeStockYn: 'Y', + latitude: resolvedLatitude, + longitude: resolvedLongitude, + useCache: false, + }, + { + timeout: timeoutMs, + }, + ); + + if (fallbackResult.stores.length > 0) { + result = fallbackResult; + fallbackUsed = true; + } + } + } catch { + fallbackUsed = false; + } + } const selected = selectGs25StoresForKeyword(result.stores, keyword, { relaxWhenEmpty: typeof resolvedLatitude === 'number' && typeof resolvedLongitude === 'number', @@ -87,6 +124,7 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise Promise, diff --git a/src/services/lottemart/tools/searchProducts.ts b/src/services/lottemart/tools/searchProducts.ts index 421fb37..d349d85 100644 --- a/src/services/lottemart/tools/searchProducts.ts +++ b/src/services/lottemart/tools/searchProducts.ts @@ -66,7 +66,7 @@ async function searchProducts(args: SearchProductsArgs): Promise Promise, + handler: ((args: SearchProductsArgs) => + searchProducts({ + ...args, + zyteApiKey: args.zyteApiKey || zyteApiKey, + })) as (args: unknown) => Promise, }; } diff --git a/tests/services/gs25/tools/findNearbyStores.test.ts b/tests/services/gs25/tools/findNearbyStores.test.ts index eb93b0f..3f0cbda 100644 --- a/tests/services/gs25/tools/findNearbyStores.test.ts +++ b/tests/services/gs25/tools/findNearbyStores.test.ts @@ -144,4 +144,161 @@ describe('createFindNearbyStoresTool', () => { process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; }); + + it('좌표 기반 매장 조회가 비면 상품 재고 조회를 이용해 가까운 GS25 매장으로 대체한다', async () => { + const prevGoogleKey = process.env.GOOGLE_MAPS_API_KEY; + process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; + + mockFetch + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + status: 'OK', + results: [{ geometry: { location: { lat: 37.4979, lng: 127.0276 } } }], + }), + ), + ) + .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + SearchQueryResult: { + Collection: [ + { + Documentset: { + Document: [{ field: { itemCode: '8801117752804', itemName: '오감자' } }], + }, + }, + ], + }, + }), + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + stores: [ + { + storeCode: 'near', + storeName: 'GS25강남메트로점', + storeAddress: '서울 강남구', + storeXCoordination: '127.0276', + storeYCoordination: '37.4979', + }, + ], + }), + ), + ); + + const tool = createFindNearbyStoresTool(); + const result = await tool.handler({ keyword: '강남', limit: 3 }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.fallbackUsed).toBe(true); + expect(parsed.count).toBe(1); + expect(parsed.stores[0].storeCode).toBe('near'); + + process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; + }); + + it('좌표 기반 매장 조회가 비고 fallback 상품 조회가 실패해도 빈 매장 결과를 반환한다', async () => { + const prevGoogleKey = process.env.GOOGLE_MAPS_API_KEY; + process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; + + mockFetch + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + status: 'OK', + results: [{ geometry: { location: { lat: 37.4979, lng: 127.0276 } } }], + }), + ), + ) + .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))) + .mockRejectedValueOnce(new Error('product search unavailable')); + + 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.count).toBe(0); + + process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; + }); + + it('fallback 상품 후보가 없으면 빈 매장 결과를 반환한다', async () => { + const prevGoogleKey = process.env.GOOGLE_MAPS_API_KEY; + process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; + + mockFetch + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + status: 'OK', + results: [{ geometry: { location: { lat: 37.4979, lng: 127.0276 } } }], + }), + ), + ) + .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + SearchQueryResult: { + Collection: [{ Documentset: { Document: [{ field: { itemCode: '', itemName: '오감자' } }] } }], + }, + }), + ), + ); + + 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.count).toBe(0); + + process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; + }); + + it('fallback 상품 재고 조회도 비면 빈 매장 결과를 반환한다', async () => { + const prevGoogleKey = process.env.GOOGLE_MAPS_API_KEY; + process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; + + mockFetch + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + status: 'OK', + results: [{ geometry: { location: { lat: 37.4979, lng: 127.0276 } } }], + }), + ), + ) + .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + SearchQueryResult: { + Collection: [ + { + Documentset: { + Document: [{ field: { itemCode: '8801117752804', itemName: '오감자' } }], + }, + }, + ], + }, + }), + ), + ) + .mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] }))); + + 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.count).toBe(0); + + process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; + }); }); diff --git a/tests/services/lottemart/tools/findNearbyStores.test.ts b/tests/services/lottemart/tools/findNearbyStores.test.ts index ad77e27..dfa0764 100644 --- a/tests/services/lottemart/tools/findNearbyStores.test.ts +++ b/tests/services/lottemart/tools/findNearbyStores.test.ts @@ -8,6 +8,18 @@ import { createFindNearbyStoresTool } from '../../../../src/services/lottemart/t const mockFetch = vi.fn(); const createSessionResponse = () => new Response('', { headers: { 'set-cookie': 'ASPSESSIONID=TEST; path=/' } }); +const createZyteResponse = (bodyText: string) => + new Response( + JSON.stringify({ + statusCode: 200, + httpResponseBody: Buffer.from(bodyText, 'utf8').toString('base64'), + }), + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ); beforeEach(() => { mockFetch.mockReset(); @@ -122,4 +134,39 @@ describe('createFindNearbyStoresTool', () => { expect(parsed.brandVariant).toBeNull(); expect(parsed.count).toBe(1); }); + + it('기본 Zyte 키로 직접 매장 조회 실패를 우회한다', async () => { + mockFetch + .mockResolvedValueOnce(new Response('origin timeout', { status: 522, statusText: 'Origin Timeout' })) + .mockResolvedValueOnce( + createZyteResponse(` +
+
    +
  • +
    잠실점
    +
    +
      +
    • 주소 : 서울 송파구 올림픽로 240
    • +
    • 상담전화 : 02-411-8025
    • +
    +
    + +
  • +
+
+ `), + ); + + const tool = createFindNearbyStoresTool(undefined, 'test-zyte-key'); + const result = await tool.handler({ + area: '서울', + keyword: '잠실', + limit: 1, + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.count).toBe(1); + expect(parsed.stores[0].storeName).toBe('잠실점'); + expect(String(mockFetch.mock.calls[1]?.[0])).toBe('https://api.zyte.com/v1/extract'); + }); }); diff --git a/tests/services/lottemart/tools/searchProducts.test.ts b/tests/services/lottemart/tools/searchProducts.test.ts index 8704903..9ddfd16 100644 --- a/tests/services/lottemart/tools/searchProducts.test.ts +++ b/tests/services/lottemart/tools/searchProducts.test.ts @@ -8,6 +8,18 @@ import { createSearchProductsTool } from '../../../../src/services/lottemart/too const mockFetch = vi.fn(); const createSessionResponse = () => new Response('', { headers: { 'set-cookie': 'ASPSESSIONID=TEST; path=/' } }); +const createZyteResponse = (bodyText: string) => + new Response( + JSON.stringify({ + statusCode: 200, + httpResponseBody: Buffer.from(bodyText, 'utf8').toString('base64'), + }), + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ); beforeEach(() => { mockFetch.mockReset(); @@ -69,4 +81,29 @@ describe('createSearchProductsTool', () => { expect(parsed.totalCount).toBe(1); expect(parsed.products[0].productName).toBe('코카콜라'); }); + + it('기본 Zyte 키로 직접 매장 옵션 조회 실패를 우회한다', async () => { + mockFetch + .mockResolvedValueOnce(new Response('origin timeout', { status: 522, statusText: 'Origin Timeout' })) + .mockResolvedValueOnce(createZyteResponse('')) + .mockResolvedValueOnce( + new Response(` + +
검색결과 : 0
+ +
    + `), + ); + + const tool = createSearchProductsTool('test-zyte-key'); + const result = await tool.handler({ + area: '서울', + storeCode: '2301', + keyword: '콜라', + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.storeCode).toBe('2301'); + expect(String(mockFetch.mock.calls[1]?.[0])).toBe('https://api.zyte.com/v1/extract'); + }); });