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
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ CU와 세븐일레븐 클라이언트는 기존 헤더, 메서드, 요청 본문
- 선택적 워밍업은 실패해도 무시되는 호출이므로 원본 직결만 사용해 불필요한 Zyte 비용을 막는다.
- 워밍업 요청 실패는 기존처럼 본 요청을 막지 않지만, 본 재고 요청의 폴백 실패는 호출자에게 전달한다.
- 본 재고 요청도 확인된 `400`/`403`/Zyte `520` 차단이면 빈 항목, `available: false`, 비민감 오류 설명을 반환한다. 매장 검색이 가능하면 매장 결과는 계속 제공한다.
- MCP에서 매장 결과를 요청하지 않으면 후속 매장 호출을 생략하고, 매장 보강까지 차단돼도 재고 degraded 결과는 유지한다.
- MCP 도구에는 요청 컨텍스트의 Zyte 및 Google Maps 바인딩을 생성자 옵션으로 주입한다.

### 세븐일레븐
Expand Down
128 changes: 70 additions & 58 deletions src/services/cu/tools/checkInventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,70 +61,80 @@ async function checkInventory(args: CheckInventoryArgs): Promise<McpToolResponse
const hasInputLocation = typeof latitude === 'number' && typeof longitude === 'number';
const resolvedLatitude = hasInputLocation ? latitude : undefined;
const resolvedLongitude = hasInputLocation ? longitude : undefined;
let storeResult: Awaited<ReturnType<typeof fetchCuStores>> | null = null;
let storeResult: Awaited<ReturnType<typeof fetchCuStores>> | null =
storeLimit <= 0 ? { totalCount: 0, stores: [] } : null;
let storeUnavailableReason: string | null = null;

// 좌표 미입력 + 매장 키워드 입력 시, 키워드 기반 매장 검색 결과를 우선 사용합니다.
if (!hasInputLocation && storeKeyword.trim().length > 0) {
const keywordStoreResult = await fetchCuStores(
{
searchWord: storeKeyword,
},
{
timeout: timeoutMs,
apiKey: zyteApiKey,
},
);
const firstAddress = keywordStoreResult.stores.find((store) => store.address.trim().length > 0)?.address || '';
if (firstAddress.length > 0) {
const geocoded = await geocodeCuAddress(firstAddress, {
timeout: timeoutMs,
googleMapsApiKey: googleMapsApiKey || process.env.GOOGLE_MAPS_API_KEY,
});
if (geocoded) {
const hasStockSeed = !!firstStockItem?.itemCode;
storeResult = await fetchCuStores(
{
latitude: geocoded.latitude,
longitude: geocoded.longitude,
searchWord: storeKeyword,
itemCd: firstStockItem?.itemCode || '',
onItemNo: firstStockItem?.onItemNo || '',
jipCd: firstStockItem?.itemCode || '',
isRecommend: hasStockSeed ? 'Y' : '',
recommendId: hasStockSeed ? 'stock' : '',
pageType: hasStockSeed ? 'search_improve stock_sch_improve' : 'search_improve',
},
{
timeout: timeoutMs,
apiKey: zyteApiKey,
},
);
try {
// 좌표 미입력 + 매장 키워드 입력 시, 키워드 기반 매장 검색 결과를 우선 사용합니다.
if (!storeResult && !hasInputLocation && storeKeyword.trim().length > 0) {
const keywordStoreResult = await fetchCuStores(
{
searchWord: storeKeyword,
},
{
timeout: timeoutMs,
apiKey: zyteApiKey,
},
);
const firstAddress =
keywordStoreResult.stores.find((store) => store.address.trim().length > 0)?.address || '';
if (firstAddress.length > 0) {
const geocoded = await geocodeCuAddress(firstAddress, {
timeout: timeoutMs,
googleMapsApiKey: googleMapsApiKey || process.env.GOOGLE_MAPS_API_KEY,
});
if (geocoded) {
const hasStockSeed = !!firstStockItem?.itemCode;
storeResult = await fetchCuStores(
{
latitude: geocoded.latitude,
longitude: geocoded.longitude,
searchWord: storeKeyword,
itemCd: firstStockItem?.itemCode || '',
onItemNo: firstStockItem?.onItemNo || '',
jipCd: firstStockItem?.itemCode || '',
isRecommend: hasStockSeed ? 'Y' : '',
recommendId: hasStockSeed ? 'stock' : '',
pageType: hasStockSeed ? 'search_improve stock_sch_improve' : 'search_improve',
},
{
timeout: timeoutMs,
apiKey: zyteApiKey,
},
);
}
}
storeResult ||= keywordStoreResult;
}
storeResult ||= keywordStoreResult;
}

if (!storeResult) {
const hasStockSeed = !!firstStockItem?.itemCode;
storeResult = await fetchCuStores(
{
latitude: resolvedLatitude,
longitude: resolvedLongitude,
searchWord: storeKeyword,
itemCd: firstStockItem?.itemCode || '',
onItemNo: firstStockItem?.onItemNo || '',
jipCd: firstStockItem?.itemCode || '',
isRecommend: hasStockSeed ? 'Y' : '',
recommendId: hasStockSeed ? 'stock' : '',
pageType: hasStockSeed ? 'search_improve stock_sch_improve' : 'search_improve',
},
{
timeout: timeoutMs,
apiKey: zyteApiKey,
},
);
if (!storeResult) {
const hasStockSeed = !!firstStockItem?.itemCode;
storeResult = await fetchCuStores(
{
latitude: resolvedLatitude,
longitude: resolvedLongitude,
searchWord: storeKeyword,
itemCd: firstStockItem?.itemCode || '',
onItemNo: firstStockItem?.onItemNo || '',
jipCd: firstStockItem?.itemCode || '',
isRecommend: hasStockSeed ? 'Y' : '',
recommendId: hasStockSeed ? 'stock' : '',
pageType: hasStockSeed ? 'search_improve stock_sch_improve' : 'search_improve',
},
{
timeout: timeoutMs,
apiKey: zyteApiKey,
},
);
}
} catch (error) {
storeUnavailableReason =
error instanceof Error ? error.message : 'CU 매장 조회 중 알 수 없는 오류가 발생했습니다.';
storeResult = { totalCount: 0, stores: [] };
}

storeResult ||= { totalCount: 0, stores: [] };
const limitedStores = storeResult.stores.slice(0, storeLimit);

const result = {
Expand All @@ -140,6 +150,8 @@ async function checkInventory(args: CheckInventoryArgs): Promise<McpToolResponse
? { latitude: resolvedLatitude, longitude: resolvedLongitude }
: null,
nearbyStores: {
available: storeUnavailableReason === null,
unavailableReason: storeUnavailableReason,
totalCount: storeResult.totalCount,
count: limitedStores.length,
stockItemCode: firstStockItem?.itemCode || null,
Expand Down
74 changes: 74 additions & 0 deletions tests/services/cu/tools/checkInventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,80 @@ afterEach(() => {
});

describe('createCheckInventoryTool', () => {
it('storeLimit이 0이면 차단된 재고 뒤의 매장 조회를 생략한다', async () => {
mockFetch
.mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] })))
.mockResolvedValueOnce(new Response('blocked', { status: 403 }))
.mockResolvedValueOnce(new Response('error code: 520', { status: 520 }));

const tool = createCheckInventoryTool({ zyteApiKey: 'worker-key' });
const result = await tool.handler({ keyword: '커피', storeLimit: 0 });

const parsed = JSON.parse(result.content[0].text);
expect(parsed.inventory).toEqual(
expect.objectContaining({
available: false,
items: [],
}),
);
expect(parsed.nearbyStores).toEqual(
expect.objectContaining({
totalCount: 0,
count: 0,
stores: [],
}),
);
expect(mockFetch).toHaveBeenCalledTimes(3);
});

it('기본 매장 조회까지 차단되어도 degraded 결과를 반환한다', async () => {
mockFetch
.mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] })))
.mockResolvedValueOnce(new Response('stock blocked', { status: 403 }))
.mockResolvedValueOnce(new Response('error code: 520', { status: 520 }))
.mockResolvedValueOnce(new Response('store blocked', { status: 403 }))
.mockResolvedValueOnce(new Response('error code: 520', { status: 520 }));

const tool = createCheckInventoryTool({ zyteApiKey: 'worker-key' });
const result = await tool.handler({ keyword: '커피' });

const parsed = JSON.parse(result.content[0].text);
expect(parsed.inventory.available).toBe(false);
expect(parsed.nearbyStores).toEqual(
expect.objectContaining({
available: false,
unavailableReason: expect.stringContaining('520'),
totalCount: 0,
stores: [],
}),
);
expect(mockFetch).toHaveBeenCalledTimes(5);
});

it('매장 조회가 비 Error 값으로 실패해도 안전한 원인을 반환한다', async () => {
mockFetch
.mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] })))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
spellModifyYn: 'N',
data: { stockResult: { result: { total_count: 0, rows: [] } } },
}),
),
)
.mockRejectedValueOnce('blocked');

const result = await createCheckInventoryTool().handler({ keyword: '커피' });
const parsed = JSON.parse(result.content[0].text);

expect(parsed.nearbyStores).toEqual(
expect.objectContaining({
available: false,
unavailableReason: 'CU 매장 조회 중 알 수 없는 오류가 발생했습니다.',
}),
);
});

it('주입된 Worker 키로 폴백하고 차단된 재고를 unavailable로 반환한다', async () => {
const zyteBanBody = JSON.stringify({
type: '/download/website-ban',
Expand Down