From 41bdcea726c9d4fc71d169744a0e1d639f776082 Mon Sep 17 00:00:00 2001
From: Taejun Park
Date: Sun, 8 Mar 2026 16:49:41 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8B=A4=EC=9D=B4=EC=86=8C=20=EC=A7=84?=
=?UTF-8?q?=EC=97=B4=20=EC=9C=84=EC=B9=98=20=EC=A1=B0=ED=9A=8C=20=EB=8F=84?=
=?UTF-8?q?=EA=B5=AC=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 50 ++-
src/api/handlers.ts | 27 ++
src/api/routes/daisoRoutes.ts | 13 +
src/services/daiso/api.ts | 3 +
src/services/daiso/index.ts | 2 +
.../daiso/tools/getDisplayLocation.ts | 107 ++++++
src/services/daiso/types.ts | 21 ++
.../api/handlers-get-display-location.test.ts | 93 ++++++
tests/app/app-api-daiso.test.ts | 36 +++
tests/services/daiso/index.test.ts | 5 +-
.../daiso/tools/getDisplayLocation.test.ts | 304 ++++++++++++++++++
11 files changed, 642 insertions(+), 19 deletions(-)
create mode 100644 src/services/daiso/tools/getDisplayLocation.ts
create mode 100644 tests/api/handlers-get-display-location.test.ts
create mode 100644 tests/services/daiso/tools/getDisplayLocation.test.ts
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('');
+ });
+});