diff --git a/README.md b/README.md index cf735cb..b555ad4 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,17 @@ daiso 인터랙티브 모드
+### daiso_get_display_location + +다이소 매장 내 상품의 진열 위치(구역/층)를 조회합니다. + +| 파라미터 | 필수 | 설명 | +| :---------- | :--: | :--------------------------------------- | +| `productId` | O | 상품 ID (daiso_search_products로 조회) | +| `storeCode` | O | 매장 코드 (daiso_check_inventory로 조회) | + +
+ ### cu_find_nearby_stores 위치 기반으로 주변 CU 매장을 조회합니다. @@ -459,6 +470,7 @@ Cloudflare Edge Cache API(`caches.default`)를 사용해 다이소 REST 응답 - `GET /api/daiso/products/:id`: 1시간 TTL - `GET /api/daiso/stores`: 24시간 TTL - `GET /api/daiso/inventory`: 10분 TTL +- `GET /api/daiso/display-location`: 10분 TTL - 공통: `stale-while-revalidate` 적용, 오류 응답(4xx/5xx)은 캐시하지 않음
@@ -483,23 +495,24 @@ MCP를 지원하지 않는 서비스를 위한 GET 기반 REST API입니다. ### 엔드포인트 -| 엔드포인트 | 설명 | -| :------------------------------ | :---------------------------------- | -| `GET /prompt` | API 사용법 설명 페이지 (에이전트용) | -| `GET /api/daiso/products` | 제품 검색 | -| `GET /api/daiso/products/:id` | 제품 상세 정보 | -| `GET /api/daiso/stores` | 매장 검색 | -| `GET /api/daiso/inventory` | 재고 확인 | -| `GET /api/cu/stores` | CU 매장 검색 | -| `GET /api/cu/inventory` | CU 재고 확인 | -| `GET /api/oliveyoung/stores` | 올리브영 매장 검색 | -| `GET /api/oliveyoung/inventory` | 올리브영 재고 확인 | -| `GET /api/megabox/theaters` | 메가박스 주변 지점 조회 | -| `GET /api/megabox/movies` | 메가박스 영화/회차 목록 조회 | -| `GET /api/megabox/seats` | 메가박스 잔여 좌석 조회 | -| `GET /api/cgv/theaters` | CGV 극장 목록 조회 | -| `GET /api/cgv/movies` | CGV 영화 목록 조회 | -| `GET /api/cgv/timetable` | CGV 상영 시간표 조회 | +| 엔드포인트 | 설명 | +| :-------------------------------- | :---------------------------------- | +| `GET /prompt` | API 사용법 설명 페이지 (에이전트용) | +| `GET /api/daiso/products` | 제품 검색 | +| `GET /api/daiso/products/:id` | 제품 상세 정보 | +| `GET /api/daiso/stores` | 매장 검색 | +| `GET /api/daiso/inventory` | 재고 확인 | +| `GET /api/daiso/display-location` | 진열 위치 조회 | +| `GET /api/cu/stores` | CU 매장 검색 | +| `GET /api/cu/inventory` | CU 재고 확인 | +| `GET /api/oliveyoung/stores` | 올리브영 매장 검색 | +| `GET /api/oliveyoung/inventory` | 올리브영 재고 확인 | +| `GET /api/megabox/theaters` | 메가박스 주변 지점 조회 | +| `GET /api/megabox/movies` | 메가박스 영화/회차 목록 조회 | +| `GET /api/megabox/seats` | 메가박스 잔여 좌석 조회 | +| `GET /api/cgv/theaters` | CGV 극장 목록 조회 | +| `GET /api/cgv/movies` | CGV 영화 목록 조회 | +| `GET /api/cgv/timetable` | CGV 상영 시간표 조회 | ### 제품 검색 @@ -711,6 +724,9 @@ AI: daiso_check_inventory 도구로 특정 매장 재고 확인 사용자: 강남역 근처 다이소 매장 찾아줘 AI: daiso_find_stores 도구로 매장 검색 +사용자: 이 상품 이 매장 어디에 있어? +AI: daiso_get_display_location 도구로 매장 내 진열 위치 조회 + 사용자: 명동 근처 올리브영 매장 찾아줘 AI: oliveyoung_find_nearby_stores 도구로 주변 매장 검색 diff --git a/src/api/handlers.ts b/src/api/handlers.ts index 982bdfb..8e64ad6 100644 --- a/src/api/handlers.ts +++ b/src/api/handlers.ts @@ -12,6 +12,7 @@ import { fetchStoreInventory, } from '../services/daiso/tools/checkInventory.js'; import { fetchProductById } from '../services/daiso/tools/getPriceInfo.js'; +import { fetchDisplayLocation } from '../services/daiso/tools/getDisplayLocation.js'; import { getImageUrl } from '../services/daiso/api.js'; import { fetchOliveyoungProducts, @@ -147,6 +148,32 @@ export async function handleCheckInventory(c: ApiContext) { } } +/** + * 진열 위치 조회 API 핸들러 + * GET /api/daiso/display-location?productId={상품ID}&storeCode={매장코드} + */ +export async function handleGetDisplayLocation(c: ApiContext) { + const productId = c.req.query('productId'); + const storeCode = c.req.query('storeCode'); + + if (!productId) { + return errorResponse(c, 'MISSING_PRODUCT_ID', '상품 ID(productId)를 입력해주세요.'); + } + + if (!storeCode) { + return errorResponse(c, 'MISSING_STORE_CODE', '매장 코드(storeCode)를 입력해주세요.'); + } + + try { + const result = await fetchDisplayLocation(productId, storeCode); + + return successResponse(c, result); + } catch (error) { + const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; + return errorResponse(c, 'DISPLAY_LOCATION_FAILED', message, 500); + } +} + /** * 올리브영 매장 검색 API 핸들러 * GET /api/oliveyoung/stores?keyword={키워드}&lat={위도}&lng={경도} diff --git a/src/api/routes/daisoRoutes.ts b/src/api/routes/daisoRoutes.ts index 902b874..6828ced 100644 --- a/src/api/routes/daisoRoutes.ts +++ b/src/api/routes/daisoRoutes.ts @@ -9,6 +9,7 @@ import { handleGetProduct, handleFindStores, handleCheckInventory, + handleGetDisplayLocation, } from '../handlers.js'; import type { AppBindings } from '../response.js'; @@ -60,4 +61,16 @@ export function registerDaisoRoutes(app: Hono<{ Bindings: AppBindings }>): void () => handleCheckInventory(c), ), ); + + app.get('/api/daiso/display-location', async (c) => + withEdgeCache( + c.req.url, + { + ttlSeconds: 60 * 10, + staleWhileRevalidateSeconds: 60, + keyPrefix: 'daiso-display-location-v1', + }, + () => handleGetDisplayLocation(c), + ), + ); } diff --git a/src/services/daiso/api.ts b/src/services/daiso/api.ts index ae8fc20..d80c8c5 100644 --- a/src/services/daiso/api.ts +++ b/src/services/daiso/api.ts @@ -18,6 +18,9 @@ export const DAISOMALL_API = { /** 매장별 재고 조회 API */ STORE_INVENTORY: 'https://mapi.daisomall.co.kr/ms/msg/newIntSelStr', + /** 매장 내 상품 진열 위치 조회 API */ + DISPLAY_LOCATION: 'https://fapi.daisomall.co.kr/pdo/selIntPdStDispInfo', + /** 이미지 CDN 베이스 URL */ IMAGE_BASE_URL: 'https://img.daisomall.co.kr', } as const; diff --git a/src/services/daiso/index.ts b/src/services/daiso/index.ts index 5439fd0..3f19f3f 100644 --- a/src/services/daiso/index.ts +++ b/src/services/daiso/index.ts @@ -10,6 +10,7 @@ import { createSearchProductsTool } from './tools/searchProducts.js'; import { createFindStoresTool } from './tools/findStores.js'; import { createCheckInventoryTool } from './tools/checkInventory.js'; import { createGetPriceInfoTool } from './tools/getPriceInfo.js'; +import { createGetDisplayLocationTool } from './tools/getDisplayLocation.js'; /** * 다이소 서비스 메타데이터 @@ -33,6 +34,7 @@ class DaisoService implements ServiceProvider { createFindStoresTool(), createCheckInventoryTool(), createGetPriceInfoTool(), + createGetDisplayLocationTool(), ]; } } diff --git a/src/services/daiso/tools/getDisplayLocation.ts b/src/services/daiso/tools/getDisplayLocation.ts new file mode 100644 index 0000000..d3811f0 --- /dev/null +++ b/src/services/daiso/tools/getDisplayLocation.ts @@ -0,0 +1,107 @@ +/** + * 진열 위치 조회 도구 + * + * 다이소몰 API를 사용하여 매장 내 상품 진열 위치(구역/층)를 조회합니다. + */ + +import * as z from 'zod'; +import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; +import type { DisplayLocation, DisplayLocationResponse } from '../types.js'; +import { DAISOMALL_API } from '../api.js'; +import { fetchDaisoJson } from '../client.js'; + +/** 도구 입력 인터페이스 */ +interface GetDisplayLocationArgs { + productId: string; + storeCode: string; +} + +/** 진열 위치 조회 결과 */ +export interface DisplayLocationResult { + productId: string; + storeCode: string; + hasLocation: boolean; + locations: DisplayLocation[]; + message?: string | null; +} + +/** + * 매장 내 상품 진열 위치 조회 + */ +export async function fetchDisplayLocation( + productId: string, + storeCode: string, +): Promise { + const data = await fetchDaisoJson(DAISOMALL_API.DISPLAY_LOCATION, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pdNo: productId, strCd: storeCode }), + }); + + if (!data.success) { + return { + productId, + storeCode, + hasLocation: false, + locations: [], + message: data.message ?? null, + }; + } + + const locations: DisplayLocation[] = Array.isArray(data.data) + ? data.data.map((item) => ({ + zoneNo: String(item.zoneNo ?? ''), + stairNo: String(item.stairNo ?? ''), + storeErp: String(item.storeErp ?? storeCode), + })) + : []; + + return { + productId, + storeCode, + hasLocation: locations.length > 0, + locations, + }; +} + +/** + * 진열 위치 조회 핸들러 + */ +async function getDisplayLocation(args: GetDisplayLocationArgs): Promise { + const { productId, storeCode } = args; + + if (!productId || productId.trim().length === 0) { + throw new Error('상품 ID(productId)를 입력해주세요.'); + } + + if (!storeCode || storeCode.trim().length === 0) { + throw new Error('매장 코드(storeCode)를 입력해주세요.'); + } + + const result = await fetchDisplayLocation(productId, storeCode); + + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; +} + +/** + * 도구 등록 정보 생성 + */ +export function createGetDisplayLocationTool(): ToolRegistration { + return { + name: 'daiso_get_display_location', + metadata: { + title: '진열 위치 조회', + description: + '다이소 매장 내 상품의 진열 위치(구역/층)를 조회합니다. 상품 ID와 매장 코드가 필요합니다.', + inputSchema: { + productId: z.string().describe('상품 ID (daiso_search_products로 조회한 상품의 id)'), + storeCode: z + .string() + .describe('매장 코드 (daiso_check_inventory로 조회한 매장의 storeCode)'), + }, + }, + handler: getDisplayLocation as (args: unknown) => Promise, + }; +} diff --git a/src/services/daiso/types.ts b/src/services/daiso/types.ts index 94375da..d7b4e5a 100644 --- a/src/services/daiso/types.ts +++ b/src/services/daiso/types.ts @@ -125,6 +125,27 @@ export interface StoreInventoryResponse { success: boolean; } +// 진열 위치 정보 +export interface DisplayLocation { + zoneNo: string; + stairNo: string; + storeErp: string; +} + +// 진열 위치 조회 응답 (fapi.daisomall.co.kr API) +export interface DisplayLocationResponse { + message: string | null; + data: Array<{ + zoneNo: string; + stairNo: string; + storeErp: string; + }>; + extraData: Record; + extraString: string | null; + returnCode: string | null; + success: boolean; +} + // 상품 문서 타입 (가격 조회용) export interface ProductDoc { PD_NO: string; diff --git a/tests/api/handlers-get-display-location.test.ts b/tests/api/handlers-get-display-location.test.ts new file mode 100644 index 0000000..2ae438c --- /dev/null +++ b/tests/api/handlers-get-display-location.test.ts @@ -0,0 +1,93 @@ +/** + * API 핸들러 테스트 - 진열 위치 조회 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { handleGetDisplayLocation } from '../../src/api/handlers.js'; +import { createMockContext, setupFetchMock } from './testHelpers.js'; + +const mockFetch = vi.fn(); +setupFetchMock(mockFetch); + +describe('handleGetDisplayLocation', () => { + it('진열 위치 정보를 반환한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: [{ zoneNo: '60', stairNo: '2', storeErp: '04515' }], + }), + ), + ); + + const ctx = createMockContext({ productId: '12345', storeCode: '04515' }); + await handleGetDisplayLocation(ctx); + + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + productId: '12345', + storeCode: '04515', + hasLocation: true, + }), + }), + ); + }); + + it('productId가 없으면 에러를 반환한다', async () => { + const ctx = createMockContext({ storeCode: '04515' }); + await handleGetDisplayLocation(ctx); + + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: { code: 'MISSING_PRODUCT_ID', message: '상품 ID(productId)를 입력해주세요.' }, + }), + 400, + ); + }); + + it('storeCode가 없으면 에러를 반환한다', async () => { + const ctx = createMockContext({ productId: '12345' }); + await handleGetDisplayLocation(ctx); + + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: { code: 'MISSING_STORE_CODE', message: '매장 코드(storeCode)를 입력해주세요.' }, + }), + 400, + ); + }); + + it('API 에러 시 500 에러를 반환한다', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const ctx = createMockContext({ productId: '12345', storeCode: '04515' }); + await handleGetDisplayLocation(ctx); + + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: { code: 'DISPLAY_LOCATION_FAILED', message: 'Network error' }, + }), + 500, + ); + }); + + it('알 수 없는 에러도 처리한다', async () => { + mockFetch.mockRejectedValue('Unknown error'); + + const ctx = createMockContext({ productId: '12345', storeCode: '04515' }); + await handleGetDisplayLocation(ctx); + + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: { code: 'DISPLAY_LOCATION_FAILED', message: '알 수 없는 오류가 발생했습니다.' }, + }), + 500, + ); + }); +}); diff --git a/tests/app/app-api-daiso.test.ts b/tests/app/app-api-daiso.test.ts index d61ea34..6def2a1 100644 --- a/tests/app/app-api-daiso.test.ts +++ b/tests/app/app-api-daiso.test.ts @@ -113,3 +113,39 @@ describe('GET /api/daiso/inventory', () => { expect(data.error.code).toBe('MISSING_PRODUCT_ID'); }); }); + +describe('GET /api/daiso/display-location', () => { + it('진열 위치 정보를 반환한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: [{ zoneNo: '60', stairNo: '2', storeErp: '04515' }], + }), + ), + ); + + const res = await app.request('/api/daiso/display-location?productId=12345&storeCode=04515'); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.data.hasLocation).toBe(true); + }); + + it('productId 없이 요청하면 에러를 반환한다', async () => { + const res = await app.request('/api/daiso/display-location?storeCode=04515'); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error.code).toBe('MISSING_PRODUCT_ID'); + }); + + it('storeCode 없이 요청하면 에러를 반환한다', async () => { + const res = await app.request('/api/daiso/display-location?productId=12345'); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error.code).toBe('MISSING_STORE_CODE'); + }); +}); diff --git a/tests/services/daiso/index.test.ts b/tests/services/daiso/index.test.ts index 27a1c54..1f0e15e 100644 --- a/tests/services/daiso/index.test.ts +++ b/tests/services/daiso/index.test.ts @@ -22,16 +22,17 @@ describe('createDaisoService', () => { expect(service.metadata.description).toBeDefined(); }); - it('4개의 도구를 반환한다', () => { + it('5개의 도구를 반환한다', () => { const service = createDaisoService(); const tools = service.getTools(); - expect(tools).toHaveLength(4); + expect(tools).toHaveLength(5); expect(tools.map((t) => t.name)).toEqual([ 'daiso_search_products', 'daiso_find_stores', 'daiso_check_inventory', 'daiso_get_price_info', + 'daiso_get_display_location', ]); }); diff --git a/tests/services/daiso/tools/getDisplayLocation.test.ts b/tests/services/daiso/tools/getDisplayLocation.test.ts new file mode 100644 index 0000000..51043fd --- /dev/null +++ b/tests/services/daiso/tools/getDisplayLocation.test.ts @@ -0,0 +1,304 @@ +/** + * 진열 위치 조회 도구 테스트 + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + fetchDisplayLocation, + createGetDisplayLocationTool, +} from '../../../../src/services/daiso/tools/getDisplayLocation.js'; + +const mockFetch = vi.fn(); + +beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('fetchDisplayLocation', () => { + it('정상 응답에서 hasLocation:true 반환 및 locations 배열에 데이터 포함', async () => { + const mockResponse = { + success: true, + data: [ + { + zoneNo: '60', + stairNo: '2', + storeErp: '04515', + }, + ], + }; + + mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse))); + + const result = await fetchDisplayLocation('12345', '04515'); + + expect(result.hasLocation).toBe(true); + expect(result.locations).toHaveLength(1); + expect(result.locations[0]).toEqual({ + zoneNo: '60', + stairNo: '2', + storeErp: '04515', + }); + }); + + it('success가 false인 경우 hasLocation:false 및 빈 locations 배열 반환', async () => { + const mockResponse = { + success: false, + message: '조회 실패', + }; + + mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse))); + + const result = await fetchDisplayLocation('12345', '04515'); + + expect(result.hasLocation).toBe(false); + expect(result.locations).toEqual([]); + expect(result.message).toBe('조회 실패'); + }); + + it('data가 빈 배열인 경우 hasLocation:false 및 빈 locations 배열 반환', async () => { + const mockResponse = { + success: true, + data: [], + }; + + mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse))); + + const result = await fetchDisplayLocation('12345', '04515'); + + expect(result.hasLocation).toBe(false); + expect(result.locations).toEqual([]); + }); + + it('fetch 요청 body에 {pdNo, strCd} JSON이 포함되어야 함', async () => { + mockFetch.mockResolvedValue(new Response(JSON.stringify({ success: true, data: [] }))); + + await fetchDisplayLocation('12345', '04515'); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ pdNo: '12345', strCd: '04515' }), + }), + ); + }); + + it('응답의 storeErp가 없으면 storeCode 값으로 대체되어야 함', async () => { + const mockResponse = { + success: true, + data: [ + { + zoneNo: '60', + stairNo: '2', + }, + ], + }; + + mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse))); + + const result = await fetchDisplayLocation('12345', '04515'); + + expect(result.locations[0].storeErp).toBe('04515'); + }); + + it('data 배열에 2개 이상의 위치가 있으면 모두 locations에 반환', async () => { + const mockResponse = { + success: true, + data: [ + { + zoneNo: '60', + stairNo: '2', + storeErp: '04515', + }, + { + zoneNo: '30', + stairNo: '1', + storeErp: '04515', + }, + { + zoneNo: '45', + stairNo: '3', + storeErp: '04515', + }, + ], + }; + + mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse))); + + const result = await fetchDisplayLocation('12345', '04515'); + + expect(result.locations).toHaveLength(3); + expect(result.locations[0].zoneNo).toBe('60'); + expect(result.locations[1].zoneNo).toBe('30'); + expect(result.locations[2].zoneNo).toBe('45'); + }); + + it('success:false 응답에서 message가 없으면 null을 반환', async () => { + mockFetch.mockResolvedValue(new Response(JSON.stringify({ success: false }))); + + const result = await fetchDisplayLocation('12345', '04515'); + + expect(result.hasLocation).toBe(false); + expect(result.message).toBeNull(); + }); + + it('data 항목에 zoneNo가 없으면 빈 문자열로 처리', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: [{ stairNo: '2', storeErp: '04515' }], + }), + ), + ); + + const result = await fetchDisplayLocation('12345', '04515'); + + expect(result.locations[0].zoneNo).toBe(''); + }); + + it('data 항목에 stairNo가 없으면 빈 문자열로 처리', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: [{ zoneNo: '60', storeErp: '04515' }], + }), + ), + ); + + const result = await fetchDisplayLocation('12345', '04515'); + + expect(result.locations[0].stairNo).toBe(''); + }); + + it('data가 배열이 아닌 경우 빈 locations 배열 반환', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: null, + }), + ), + ); + + const result = await fetchDisplayLocation('12345', '04515'); + + expect(result.hasLocation).toBe(false); + expect(result.locations).toEqual([]); + }); +}); + +describe('createGetDisplayLocationTool', () => { + it('올바른 name, metadata, handler를 포함한 도구 정의를 반환', () => { + const tool = createGetDisplayLocationTool(); + + expect(tool.name).toBe('daiso_get_display_location'); + expect(tool.metadata.title).toBe('진열 위치 조회'); + expect(tool.metadata.description).toContain('진열 위치'); + expect(typeof tool.handler).toBe('function'); + }); + + it('productId가 빈 문자열이면 에러를 throw', async () => { + mockFetch.mockResolvedValue(new Response(JSON.stringify({ success: true, data: [] }))); + + const tool = createGetDisplayLocationTool(); + + await expect(tool.handler({ productId: '', storeCode: '04515' })).rejects.toThrow( + '상품 ID(productId)를 입력해주세요.', + ); + }); + + it('storeCode가 빈 문자열이면 에러를 throw', async () => { + mockFetch.mockResolvedValue(new Response(JSON.stringify({ success: true, data: [] }))); + + const tool = createGetDisplayLocationTool(); + + await expect(tool.handler({ productId: '12345', storeCode: '' })).rejects.toThrow( + '매장 코드(storeCode)를 입력해주세요.', + ); + }); + + it('진열 위치가 있을 때 locations에 층/구역 정보가 포함되어야 함', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: [ + { + zoneNo: '60', + stairNo: '2', + storeErp: '04515', + }, + ], + }), + ), + ); + + const tool = createGetDisplayLocationTool(); + const result = await tool.handler({ productId: '12345', storeCode: '04515' }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.hasLocation).toBe(true); + expect(parsed.locations[0].stairNo).toBe('2'); + expect(parsed.locations[0].zoneNo).toBe('60'); + }); + + it('진열 위치가 없을 때 hasLocation이 false', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: [], + }), + ), + ); + + const tool = createGetDisplayLocationTool(); + const result = await tool.handler({ productId: '12345', storeCode: '04515' }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.hasLocation).toBe(false); + expect(parsed.locations).toHaveLength(0); + }); + + it('stairNo가 없는 진열 위치에서 zoneNo만 포함', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: [{ zoneNo: '60', storeErp: '04515' }], + }), + ), + ); + + const tool = createGetDisplayLocationTool(); + const result = await tool.handler({ productId: '12345', storeCode: '04515' }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.locations[0].zoneNo).toBe('60'); + expect(parsed.locations[0].stairNo).toBe(''); + }); + + it('zoneNo가 null인 경우 빈 문자열로 변환', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: [{ stairNo: '2', zoneNo: null, storeErp: '04515' }], + }), + ), + ); + + const tool = createGetDisplayLocationTool(); + const result = await tool.handler({ productId: '12345', storeCode: '04515' }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.locations[0].stairNo).toBe('2'); + expect(parsed.locations[0].zoneNo).toBe(''); + }); +});