Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion src/api/gs25Handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export async function handleGs25FindStores(c: ApiContext) {
}
}

const storeResult = await fetchGs25Stores(
let storeResult = await fetchGs25Stores(
{
serviceCode,
latitude,
Expand All @@ -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',
Expand All @@ -74,6 +106,7 @@ export async function handleGs25FindStores(c: ApiContext) {
: null,
cacheHit: storeResult.cacheHit,
filterRelaxed: selected.filterRelaxed,
fallbackUsed,
stores,
},
{
Expand Down
2 changes: 2 additions & 0 deletions src/api/lottemartHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
);

Expand Down Expand Up @@ -92,6 +93,7 @@ export async function handleLotteMartSearchProducts(c: ApiContext) {
},
{
timeout: DEFAULT_LOTTEMART_TIMEOUT_MS,
zyteApiKey: c.env?.ZYTE_API_KEY,
},
);

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const createRegistry = (bindings?: AppBindings) => {
() =>
createLotteMartService({
googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
zyteApiKey: bindings?.ZYTE_API_KEY,
}),
createMegaboxService,
() =>
Expand Down
40 changes: 39 additions & 1 deletion src/services/gs25/tools/findNearbyStores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as z from 'zod';
import type { McpToolResponse, ToolRegistration } from '../../../core/types.js';
import {
attachDistanceToGs25Stores,
fetchGs25SearchProducts,
fetchGs25Stores,
geocodeGs25Address,
selectGs25StoresForKeyword,
Expand Down Expand Up @@ -50,7 +51,7 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise<McpToolResp
}
}

const result = await fetchGs25Stores(
let result = await fetchGs25Stores(
{
serviceCode,
latitude: resolvedLatitude,
Expand All @@ -60,6 +61,42 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise<McpToolResp
timeout: timeoutMs,
},
);
let fallbackUsed = false;

if (
result.stores.length === 0 &&
typeof resolvedLatitude === 'number' &&
typeof resolvedLongitude === 'number'
) {
try {
const fallbackProduct = (await fetchGs25SearchProducts('오감자', { timeout: timeoutMs })).find(
(product) => 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',
Expand Down Expand Up @@ -87,6 +124,7 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise<McpToolResp
totalCount: result.totalCount,
filteredCount: selected.stores.length,
filterRelaxed: selected.filterRelaxed,
fallbackUsed,
count: stores.length,
stores,
},
Expand Down
16 changes: 12 additions & 4 deletions src/services/lottemart/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,23 @@ const LOTTEMART_METADATA: ServiceMetadata = {
class LotteMartService implements ServiceProvider {
readonly metadata = LOTTEMART_METADATA;

constructor(private readonly googleMapsApiKey?: string) {}
constructor(
private readonly googleMapsApiKey?: string,
private readonly zyteApiKey?: string,
) {}

getTools(): ToolRegistration[] {
return [createFindNearbyStoresTool(this.googleMapsApiKey), createSearchProductsTool()];
return [
createFindNearbyStoresTool(this.googleMapsApiKey, this.zyteApiKey),
createSearchProductsTool(this.zyteApiKey),
];
}
}

export function createLotteMartService(options: { googleMapsApiKey?: string } = {}): ServiceProvider {
return new LotteMartService(options.googleMapsApiKey);
export function createLotteMartService(
options: { googleMapsApiKey?: string; zyteApiKey?: string } = {},
): ServiceProvider {
return new LotteMartService(options.googleMapsApiKey, options.zyteApiKey);
}

export * from './types.js';
3 changes: 2 additions & 1 deletion src/services/lottemart/tools/findNearbyStores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise<McpToolResp
};
}

export function createFindNearbyStoresTool(googleMapsApiKey?: string): ToolRegistration {
export function createFindNearbyStoresTool(googleMapsApiKey?: string, zyteApiKey?: string): ToolRegistration {
return {
name: 'lottemart_find_nearby_stores',
metadata: {
Expand All @@ -97,6 +97,7 @@ export function createFindNearbyStoresTool(googleMapsApiKey?: string): ToolRegis
findNearbyStores({
...args,
googleMapsApiKey: args.googleMapsApiKey || googleMapsApiKey,
zyteApiKey: args.zyteApiKey || zyteApiKey,
})) as (
args: unknown,
) => Promise<McpToolResponse>,
Expand Down
8 changes: 6 additions & 2 deletions src/services/lottemart/tools/searchProducts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ async function searchProducts(args: SearchProductsArgs): Promise<McpToolResponse
};
}

export function createSearchProductsTool(): ToolRegistration {
export function createSearchProductsTool(zyteApiKey?: string): ToolRegistration {
return {
name: 'lottemart_search_products',
metadata: {
Expand All @@ -85,6 +85,10 @@ export function createSearchProductsTool(): ToolRegistration {
.describe(`요청 제한 시간(ms, 기본값: ${DEFAULT_LOTTEMART_TIMEOUT_MS})`),
},
},
handler: searchProducts as (args: unknown) => Promise<McpToolResponse>,
handler: ((args: SearchProductsArgs) =>
searchProducts({
...args,
zyteApiKey: args.zyteApiKey || zyteApiKey,
})) as (args: unknown) => Promise<McpToolResponse>,
};
}
157 changes: 157 additions & 0 deletions tests/services/gs25/tools/findNearbyStores.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
});
Loading