From b53d55c5ec2a8893ff83efe946e2da4442090939 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Fri, 15 May 2026 14:00:01 +0900 Subject: [PATCH] fix: harden mcp smoke test failures --- src/api/cgvHandlers.ts | 7 +- src/api/gs25Handlers.ts | 18 ++- src/api/sevenelevenHandlers.ts | 5 + src/index.ts | 9 ++ src/services/cgv/client.ts | 3 +- .../daiso/tools/getDisplayLocation.ts | 4 +- src/services/gs25/client.ts | 29 +++- src/services/gs25/tools/checkInventory.ts | 7 +- src/services/gs25/tools/findNearbyStores.ts | 13 +- src/services/lottecinema/client.ts | 5 +- src/services/lottemart/session.ts | 86 ++++++----- src/services/seveneleven/client.ts | 2 +- .../seveneleven/tools/getSearchPopwords.ts | 1 + tests/api/cgv-handlers.test.ts | 44 ++++++ .../api/handlers-get-display-location.test.ts | 18 +-- tests/app/app-api-daiso.test.ts | 18 +-- tests/app/app-api-seveneleven.test.ts | 13 ++ tests/app/app-platform.test.ts | 21 +++ .../daiso/tools/getDisplayLocation.test.ts | 133 +++++++++--------- .../gs25/tools/checkInventory.test.ts | 50 +++++++ .../gs25/tools/findNearbyStores.test.ts | 51 +++++++ tests/services/lottecinema/client.test.ts | 41 ++++++ tests/services/lottemart/session.test.ts | 27 ++++ .../seveneleven/tools/searchProducts.test.ts | 35 +++++ 24 files changed, 497 insertions(+), 143 deletions(-) diff --git a/src/api/cgvHandlers.ts b/src/api/cgvHandlers.ts index 2a01268..a60f602 100644 --- a/src/api/cgvHandlers.ts +++ b/src/api/cgvHandlers.ts @@ -194,7 +194,11 @@ export async function handleCgvGetTimetable(c: ApiContext) { zyteApiKey: c.env?.ZYTE_API_KEY, }); - const filtered = filterAndSortTimetable(timetable, { theaterCode, movieCode, limit }); + const exactFiltered = filterAndSortTimetable(timetable, { theaterCode, movieCode, limit }); + const filterRelaxed = Boolean(movieCode && exactFiltered.length === 0 && timetable.length > 0); + const filtered = filterRelaxed + ? filterAndSortTimetable(timetable, { theaterCode, limit }) + : exactFiltered; return successResponse( c, @@ -208,6 +212,7 @@ export async function handleCgvGetTimetable(c: ApiContext) { longitude: longitude ?? null, }, resolvedTheater, + filterRelaxed, timetable: filtered, }, { total: filtered.length, pageSize: limit }, diff --git a/src/api/gs25Handlers.ts b/src/api/gs25Handlers.ts index 93e7504..23dfcd6 100644 --- a/src/api/gs25Handlers.ts +++ b/src/api/gs25Handlers.ts @@ -10,6 +10,7 @@ import { fetchGs25Stores, filterGs25StoresByKeyword, geocodeGs25Address, + selectGs25StoresForKeyword, sortGs25Stores, } from '../services/gs25/client.js'; @@ -47,14 +48,18 @@ export async function handleGs25FindStores(c: ApiContext) { const storeResult = await fetchGs25Stores( { serviceCode, + latitude, + longitude, }, { timeout: 20000, }, ); - const filtered = filterGs25StoresByKeyword(storeResult.stores, keyword); - const withDistance = attachDistanceToGs25Stores(filtered, latitude, longitude); + const selected = selectGs25StoresForKeyword(storeResult.stores, keyword, { + relaxWhenEmpty: typeof latitude === 'number' && typeof longitude === 'number', + }); + const withDistance = attachDistanceToGs25Stores(selected.stores, latitude, longitude); const stores = sortGs25Stores(withDistance).slice(0, limit); return successResponse( @@ -68,10 +73,11 @@ export async function handleGs25FindStores(c: ApiContext) { ? { latitude, longitude } : null, cacheHit: storeResult.cacheHit, + filterRelaxed: selected.filterRelaxed, stores, }, { - total: filtered.length, + total: selected.stores.length, pageSize: limit, }, ); @@ -233,7 +239,10 @@ export async function handleGs25CheckInventory(c: ApiContext) { } } - const filtered = filterGs25StoresByKeyword(stockResult.stores, storeKeyword); + const selected = selectGs25StoresForKeyword(stockResult.stores, storeKeyword, { + relaxWhenEmpty: typeof latitude === 'number' && typeof longitude === 'number', + }); + const filtered = selected.stores; const withDistance = attachDistanceToGs25Stores(filtered, latitude, longitude); const stores = sortGs25Stores(withDistance).slice(0, storeLimit); @@ -251,6 +260,7 @@ export async function handleGs25CheckInventory(c: ApiContext) { itemCode: resolvedItemCode, storeKeyword, geocodeUsed, + filterRelaxed: selected.filterRelaxed, location: typeof latitude === 'number' && typeof longitude === 'number' ? { latitude, longitude } diff --git a/src/api/sevenelevenHandlers.ts b/src/api/sevenelevenHandlers.ts index ad3c088..46dd35f 100644 --- a/src/api/sevenelevenHandlers.ts +++ b/src/api/sevenelevenHandlers.ts @@ -183,8 +183,13 @@ export async function handleSevenElevenGetSearchPopwords(c: ApiContext) { return successResponse(c, { label, + available: keywords.length > 0, count: keywords.length, keywords, + note: + keywords.length === 0 + ? '현재 응답에서 인기 검색어 목록을 찾지 못했습니다.' + : '홈 인기 검색어를 조회했습니다.', }); } catch (error) { const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; diff --git a/src/index.ts b/src/index.ts index 6d0597a..74ac051 100644 --- a/src/index.ts +++ b/src/index.ts @@ -146,6 +146,15 @@ const handleMcpRequest = async (c: Context<{ Bindings: AppBindings }>) => { if (sessionId) { const existing = mcpSessions.get(sessionId); if (!existing) { + if (c.req.method === 'POST') { + const transport = new WebStandardStreamableHTTPServerTransport(); + const server = createMcpServer(c.env); + await server.connect(transport); + const response = await transport.handleRequest(c.req.raw); + response.headers.set('x-mcp-session-fallback', 'stateless'); + return response; + } + return c.json( { error: 'Session not found', diff --git a/src/services/cgv/client.ts b/src/services/cgv/client.ts index c20fa22..b550c48 100644 --- a/src/services/cgv/client.ts +++ b/src/services/cgv/client.ts @@ -196,7 +196,8 @@ export async function fetchCgvTimetable(params: CommonFetchParams): Promise 0) { return filteredByMovie; } - return fetchTimetableByMovieCode(playDate, theaterCode, params.movieCode, params); + const timetableByMovieCode = await fetchTimetableByMovieCode(playDate, theaterCode, params.movieCode, params); + return timetableByMovieCode.length > 0 ? timetableByMovieCode : timetableBySite; } return timetableBySite; } diff --git a/src/services/daiso/tools/getDisplayLocation.ts b/src/services/daiso/tools/getDisplayLocation.ts index d3811f0..4154d03 100644 --- a/src/services/daiso/tools/getDisplayLocation.ts +++ b/src/services/daiso/tools/getDisplayLocation.ts @@ -8,7 +8,7 @@ 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'; +import { fetchDaisoJsonWithAuth } from '../client.js'; /** 도구 입력 인터페이스 */ interface GetDisplayLocationArgs { @@ -32,7 +32,7 @@ export async function fetchDisplayLocation( productId: string, storeCode: string, ): Promise { - const data = await fetchDaisoJson(DAISOMALL_API.DISPLAY_LOCATION, { + const data = await fetchDaisoJsonWithAuth(DAISOMALL_API.DISPLAY_LOCATION, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pdNo: productId, strCd: storeCode }), diff --git a/src/services/gs25/client.ts b/src/services/gs25/client.ts index 6338fd9..256391b 100644 --- a/src/services/gs25/client.ts +++ b/src/services/gs25/client.ts @@ -161,8 +161,17 @@ function normalizeStore(raw: NonNullable[numbe }; } -function buildCacheKey(params: Required>): string { - return `${params.serviceCode}:${params.keyword}:${params.storeCode}`; +function buildCacheKey( + params: Required> & + Pick, +): string { + return [ + params.serviceCode, + params.itemCode?.trim() || params.keyword, + params.storeCode, + typeof params.latitude === 'number' ? params.latitude : '', + typeof params.longitude === 'number' ? params.longitude : '', + ].join(':'); } export function calculateDistanceM(lat1: number, lng1: number, lat2: number, lng2: number): number { @@ -216,6 +225,19 @@ export function filterGs25StoresByKeyword(stores: Gs25Store[], keyword: string): }); } +export function selectGs25StoresForKeyword( + stores: Gs25Store[], + keyword: string, + options: { relaxWhenEmpty?: boolean } = {}, +): { stores: Gs25Store[]; filterRelaxed: boolean } { + const filtered = filterGs25StoresByKeyword(stores, keyword); + if (filtered.length > 0 || !options.relaxWhenEmpty || keyword.trim().length === 0 || stores.length === 0) { + return { stores: filtered, filterRelaxed: false }; + } + + return { stores, filterRelaxed: true }; +} + export function attachDistanceToGs25Stores( stores: Gs25Store[], latitude?: number, @@ -359,6 +381,9 @@ export async function fetchGs25Stores( serviceCode, keyword: itemCode.trim() || keyword.trim(), storeCode: storeCode.trim(), + itemCode: itemCode.trim(), + latitude, + longitude, }); if (useCache) { diff --git a/src/services/gs25/tools/checkInventory.ts b/src/services/gs25/tools/checkInventory.ts index de42ffe..6f8c77d 100644 --- a/src/services/gs25/tools/checkInventory.ts +++ b/src/services/gs25/tools/checkInventory.ts @@ -11,6 +11,7 @@ import { fetchGs25Stores, filterGs25StoresByKeyword, geocodeGs25Address, + selectGs25StoresForKeyword, sortGs25Stores, } from '../client.js'; @@ -145,7 +146,10 @@ async function checkInventory(args: CheckInventoryArgs): Promise item.CinemaID && item.CinemaNameKR && item.DivisionCode && item.DetailDivisionCode) .map((item) => ({ theaterId: String(item.CinemaID), @@ -124,7 +124,8 @@ export async function fetchLotteCinemaTicketingPage( latitude: toNullableNumber(item.Latitude), longitude: toNullableNumber(item.Longitude), address: item.CinemaAddrSummary || '', - })); + })) + .map((theater) => [theater.theaterId, theater] as const)).values()); const movies = (response.Movies?.Movies?.Items || []) .filter((item) => item.RepresentationMovieCode && item.MovieNameKR) diff --git a/src/services/lottemart/session.ts b/src/services/lottemart/session.ts index 85418e5..cb4ff10 100644 --- a/src/services/lottemart/session.ts +++ b/src/services/lottemart/session.ts @@ -40,6 +40,52 @@ type LotteMartSocketConnect = ( writable: WritableStream; }; +export function __testOnlyCreateLotteMartSocketResponse(raw: Uint8Array): Response | null { + const delimiter = new TextEncoder().encode('\r\n\r\n'); + let boundary = -1; + for (let index = 0; index <= raw.length - delimiter.length; index += 1) { + let matched = true; + for (let inner = 0; inner < delimiter.length; inner += 1) { + if (raw[index + inner] !== delimiter[inner]) { + matched = false; + break; + } + } + if (matched) { + boundary = index; + break; + } + } + + if (boundary < 0) { + return null; + } + + const headerText = new TextDecoder().decode(raw.slice(0, boundary)); + if (!headerText.startsWith('HTTP/')) { + return null; + } + + const bodyBytes = raw.slice(boundary + delimiter.length); + const headerLinesRaw = headerText.split('\r\n'); + const statusLine = headerLinesRaw.shift() as string; + const [, statusCodeText = '500', ...statusTextParts] = statusLine.split(' '); + const responseHeaders = new Headers(); + for (const line of headerLinesRaw) { + const separatorIndex = line.indexOf(':'); + if (separatorIndex < 0) { + continue; + } + responseHeaders.append(line.slice(0, separatorIndex).trim(), line.slice(separatorIndex + 1).trim()); + } + + return new Response(bodyBytes, { + status: parseInt(statusCodeText, 10) || 500, + statusText: statusTextParts.join(' ').trim(), + headers: responseHeaders, + }); +} + /* c8 ignore start */ async function fetchLotteMartSocketResponse(url: string, init: RequestInit, sessionCookie: string): Promise { let connectFn: LotteMartSocketConnect | null = null; @@ -103,45 +149,7 @@ async function fetchLotteMartSocketResponse(url: string, init: RequestInit, sess offset += chunk.length; } - const delimiter = new TextEncoder().encode('\r\n\r\n'); - let boundary = -1; - for (let index = 0; index <= raw.length - delimiter.length; index += 1) { - let matched = true; - for (let inner = 0; inner < delimiter.length; inner += 1) { - if (raw[index + inner] !== delimiter[inner]) { - matched = false; - break; - } - } - if (matched) { - boundary = index; - break; - } - } - - if (boundary < 0) { - throw new Error('롯데마트 소켓 응답 헤더를 파싱하지 못했습니다.'); - } - - const headerText = new TextDecoder().decode(raw.slice(0, boundary)); - const bodyBytes = raw.slice(boundary + delimiter.length); - const headerLinesRaw = headerText.split('\r\n'); - const statusLine = headerLinesRaw.shift() || 'HTTP/1.1 500 Socket Error'; - const [, statusCodeText = '500', ...statusTextParts] = statusLine.split(' '); - const responseHeaders = new Headers(); - for (const line of headerLinesRaw) { - const separatorIndex = line.indexOf(':'); - if (separatorIndex < 0) { - continue; - } - responseHeaders.append(line.slice(0, separatorIndex).trim(), line.slice(separatorIndex + 1).trim()); - } - - return new Response(bodyBytes, { - status: parseInt(statusCodeText, 10) || 500, - statusText: statusTextParts.join(' ').trim(), - headers: responseHeaders, - }); + return __testOnlyCreateLotteMartSocketResponse(raw); } /* c8 ignore end */ diff --git a/src/services/seveneleven/client.ts b/src/services/seveneleven/client.ts index 9f0ca7e..99b0eb0 100644 --- a/src/services/seveneleven/client.ts +++ b/src/services/seveneleven/client.ts @@ -226,7 +226,7 @@ export async function searchSevenElevenProducts( const collectionProducts = normalizeProducts(allDocuments); const contentProducts = normalizeProducts(Array.isArray(data.content) ? data.content : []); - const products = collectionProducts.length > 0 ? collectionProducts : contentProducts; + const products = (collectionProducts.length > 0 ? collectionProducts : contentProducts).slice(0, pageSize); return { query: queryResult?.query || query, diff --git a/src/services/seveneleven/tools/getSearchPopwords.ts b/src/services/seveneleven/tools/getSearchPopwords.ts index e75f76a..6eb416a 100644 --- a/src/services/seveneleven/tools/getSearchPopwords.ts +++ b/src/services/seveneleven/tools/getSearchPopwords.ts @@ -25,6 +25,7 @@ async function getSearchPopwords(args: GetSearchPopwordsArgs): Promise 0, count: keywords.length, keywords, note: diff --git a/tests/api/cgv-handlers.test.ts b/tests/api/cgv-handlers.test.ts index e7a2e6f..0486720 100644 --- a/tests/api/cgv-handlers.test.ts +++ b/tests/api/cgv-handlers.test.ts @@ -571,6 +571,50 @@ describe('handleCgvGetTimetable', () => { expect(payload.data.timetable[0].startTime).toBe('09:30'); }); + it('movieCode 필터 결과가 비어도 극장 시간표가 있으면 필터 완화 결과를 반환한다', async () => { + mockFetch + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + statusCode: 0, + statusMessage: '조회 되었습니다.', + data: [ + { + siteNo: '0056', + siteNm: 'CGV강남', + scnYmd: '20260304', + scnSseq: '1', + movNo: 'M2', + movNm: '다른 영화', + scnsrtTm: '0930', + scnendTm: '1130', + stcnt: 100, + frSeatCnt: 30, + }, + ], + }), + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + statusCode: 0, + statusMessage: '조회 되었습니다.', + data: [], + }), + ), + ); + + const ctx = createMockContext({ playDate: '20260304', theaterCode: '0056', movieCode: 'M1' }); + await handleCgvGetTimetable(ctx); + + const payload = (ctx.json as ReturnType).mock.calls[0][0] as { + data: { filterRelaxed: boolean; timetable: Array<{ movieCode: string }> }; + }; + expect(payload.data.filterRelaxed).toBe(true); + expect(payload.data.timetable[0].movieCode).toBe('M2'); + }); + it('theaterCode/movieCode가 없으면 null 필터를 반환한다', async () => { mockFetch .mockResolvedValueOnce( diff --git a/tests/api/handlers-get-display-location.test.ts b/tests/api/handlers-get-display-location.test.ts index e2ecf85..1d36bfe 100644 --- a/tests/api/handlers-get-display-location.test.ts +++ b/tests/api/handlers-get-display-location.test.ts @@ -11,14 +11,16 @@ setupFetchMock(mockFetch); describe('handleGetDisplayLocation', () => { it('진열 위치 정보를 반환한다', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - data: [{ zoneNo: '60', stairNo: '2', storeErp: '04515' }], - }), - ), - ); + mockFetch + .mockResolvedValueOnce(new Response('display-token', { headers: { 'X-DM-UID': 'dm-uid-123' } })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + data: [{ zoneNo: '60', stairNo: '2', storeErp: '04515' }], + }), + ), + ); const ctx = createMockContext({ productId: '12345', storeCode: '04515' }); await handleGetDisplayLocation(ctx); diff --git a/tests/app/app-api-daiso.test.ts b/tests/app/app-api-daiso.test.ts index 2b7a4e2..2b25e61 100644 --- a/tests/app/app-api-daiso.test.ts +++ b/tests/app/app-api-daiso.test.ts @@ -235,14 +235,16 @@ describe('GET /api/daiso/inventory', () => { describe('GET /api/daiso/display-location', () => { it('진열 위치 정보를 반환한다', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - data: [{ zoneNo: '60', stairNo: '2', storeErp: '04515' }], - }), - ), - ); + mockFetch + .mockResolvedValueOnce(new Response('display-token', { headers: { 'X-DM-UID': 'dm-uid-123' } })) + .mockResolvedValueOnce( + 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'); diff --git a/tests/app/app-api-seveneleven.test.ts b/tests/app/app-api-seveneleven.test.ts index ce1f317..2ddc9b0 100644 --- a/tests/app/app-api-seveneleven.test.ts +++ b/tests/app/app-api-seveneleven.test.ts @@ -70,8 +70,21 @@ describe('GET /api/seveneleven/popwords', () => { const data = await res.json(); expect(data.success).toBe(true); + expect(data.data.available).toBe(true); expect(data.data.keywords).toEqual(['삼각김밥', '도시락']); }); + + it('인기 검색어가 비어 있으면 unavailable 안내를 포함한다', async () => { + mockFetch.mockResolvedValue(new Response(JSON.stringify({ success: true, data: {} }))); + + const res = await app.request('/api/seveneleven/popwords?label=home'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.data.available).toBe(false); + expect(data.data.note).toContain('찾지 못했습니다'); + }); }); describe('GET /api/seveneleven/stores', () => { diff --git a/tests/app/app-platform.test.ts b/tests/app/app-platform.test.ts index 431d799..595d299 100644 --- a/tests/app/app-platform.test.ts +++ b/tests/app/app-platform.test.ts @@ -180,6 +180,27 @@ describe('MCP 엔드포인트', () => { expect(data.error).toBe('Session not found'); }); + it('세션 ID가 유실된 POST 요청은 stateless MCP 처리로 fallback한다', async () => { + const res = await app.request('/mcp', { + method: 'POST', + headers: { + Accept: 'application/json, text/event-stream', + 'Content-Type': 'application/json', + 'mcp-session-id': 'missing-session-id', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {}, + }), + }); + + expect(res.status).toBe(200); + expect(res.headers.get('x-mcp-session-fallback')).toBe('stateless'); + expect(res.headers.get('Content-Type')).toContain('text/event-stream'); + }); + it('POST /도 MCP 요청을 처리한다', async () => { const res = await app.request('/', { method: 'POST', diff --git a/tests/services/daiso/tools/getDisplayLocation.test.ts b/tests/services/daiso/tools/getDisplayLocation.test.ts index 51043fd..4bd0cb3 100644 --- a/tests/services/daiso/tools/getDisplayLocation.test.ts +++ b/tests/services/daiso/tools/getDisplayLocation.test.ts @@ -9,6 +9,12 @@ import { const mockFetch = vi.fn(); +function mockDisplayLocationResponse(body: unknown): void { + mockFetch + .mockResolvedValueOnce(new Response('display-token', { headers: { 'X-DM-UID': 'dm-uid-123' } })) + .mockResolvedValueOnce(new Response(JSON.stringify(body))); +} + beforeEach(() => { mockFetch.mockReset(); vi.stubGlobal('fetch', mockFetch); @@ -31,7 +37,7 @@ describe('fetchDisplayLocation', () => { ], }; - mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse))); + mockDisplayLocationResponse(mockResponse); const result = await fetchDisplayLocation('12345', '04515'); @@ -50,7 +56,7 @@ describe('fetchDisplayLocation', () => { message: '조회 실패', }; - mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse))); + mockDisplayLocationResponse(mockResponse); const result = await fetchDisplayLocation('12345', '04515'); @@ -65,7 +71,7 @@ describe('fetchDisplayLocation', () => { data: [], }; - mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse))); + mockDisplayLocationResponse(mockResponse); const result = await fetchDisplayLocation('12345', '04515'); @@ -74,19 +80,34 @@ describe('fetchDisplayLocation', () => { }); it('fetch 요청 body에 {pdNo, strCd} JSON이 포함되어야 함', async () => { - mockFetch.mockResolvedValue(new Response(JSON.stringify({ success: true, data: [] }))); + mockDisplayLocationResponse({ success: true, data: [] }); await fetchDisplayLocation('12345', '04515'); - expect(mockFetch).toHaveBeenCalledWith( + expect(mockFetch).toHaveBeenLastCalledWith( expect.any(String), expect.objectContaining({ method: 'POST', body: JSON.stringify({ pdNo: '12345', strCd: '04515' }), + headers: expect.objectContaining({ + Authorization: expect.stringContaining('Bearer '), + 'X-DM-UID': 'dm-uid-123', + Cookie: 'DM_UID=dm-uid-123', + }), }), ); }); + it('진열 위치 조회 전에 다이소 인증 토큰을 요청한다', async () => { + mockDisplayLocationResponse({ success: true, data: [] }); + + await fetchDisplayLocation('12345', '04515'); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ method: 'GET' })); + expect(mockFetch.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ method: 'POST' })); + }); + it('응답의 storeErp가 없으면 storeCode 값으로 대체되어야 함', async () => { const mockResponse = { success: true, @@ -98,7 +119,7 @@ describe('fetchDisplayLocation', () => { ], }; - mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse))); + mockDisplayLocationResponse(mockResponse); const result = await fetchDisplayLocation('12345', '04515'); @@ -127,7 +148,7 @@ describe('fetchDisplayLocation', () => { ], }; - mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse))); + mockDisplayLocationResponse(mockResponse); const result = await fetchDisplayLocation('12345', '04515'); @@ -138,7 +159,7 @@ describe('fetchDisplayLocation', () => { }); it('success:false 응답에서 message가 없으면 null을 반환', async () => { - mockFetch.mockResolvedValue(new Response(JSON.stringify({ success: false }))); + mockDisplayLocationResponse({ success: false }); const result = await fetchDisplayLocation('12345', '04515'); @@ -147,14 +168,10 @@ describe('fetchDisplayLocation', () => { }); it('data 항목에 zoneNo가 없으면 빈 문자열로 처리', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - data: [{ stairNo: '2', storeErp: '04515' }], - }), - ), - ); + mockDisplayLocationResponse({ + success: true, + data: [{ stairNo: '2', storeErp: '04515' }], + }); const result = await fetchDisplayLocation('12345', '04515'); @@ -162,14 +179,10 @@ describe('fetchDisplayLocation', () => { }); it('data 항목에 stairNo가 없으면 빈 문자열로 처리', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - data: [{ zoneNo: '60', storeErp: '04515' }], - }), - ), - ); + mockDisplayLocationResponse({ + success: true, + data: [{ zoneNo: '60', storeErp: '04515' }], + }); const result = await fetchDisplayLocation('12345', '04515'); @@ -177,14 +190,10 @@ describe('fetchDisplayLocation', () => { }); it('data가 배열이 아닌 경우 빈 locations 배열 반환', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - data: null, - }), - ), - ); + mockDisplayLocationResponse({ + success: true, + data: null, + }); const result = await fetchDisplayLocation('12345', '04515'); @@ -224,20 +233,16 @@ describe('createGetDisplayLocationTool', () => { }); it('진열 위치가 있을 때 locations에 층/구역 정보가 포함되어야 함', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - data: [ - { - zoneNo: '60', - stairNo: '2', - storeErp: '04515', - }, - ], - }), - ), - ); + mockDisplayLocationResponse({ + success: true, + data: [ + { + zoneNo: '60', + stairNo: '2', + storeErp: '04515', + }, + ], + }); const tool = createGetDisplayLocationTool(); const result = await tool.handler({ productId: '12345', storeCode: '04515' }); @@ -249,14 +254,10 @@ describe('createGetDisplayLocationTool', () => { }); it('진열 위치가 없을 때 hasLocation이 false', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - data: [], - }), - ), - ); + mockDisplayLocationResponse({ + success: true, + data: [], + }); const tool = createGetDisplayLocationTool(); const result = await tool.handler({ productId: '12345', storeCode: '04515' }); @@ -267,14 +268,10 @@ describe('createGetDisplayLocationTool', () => { }); it('stairNo가 없는 진열 위치에서 zoneNo만 포함', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - data: [{ zoneNo: '60', storeErp: '04515' }], - }), - ), - ); + mockDisplayLocationResponse({ + success: true, + data: [{ zoneNo: '60', storeErp: '04515' }], + }); const tool = createGetDisplayLocationTool(); const result = await tool.handler({ productId: '12345', storeCode: '04515' }); @@ -285,14 +282,10 @@ describe('createGetDisplayLocationTool', () => { }); it('zoneNo가 null인 경우 빈 문자열로 변환', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - data: [{ stairNo: '2', zoneNo: null, storeErp: '04515' }], - }), - ), - ); + mockDisplayLocationResponse({ + success: true, + data: [{ stairNo: '2', zoneNo: null, storeErp: '04515' }], + }); const tool = createGetDisplayLocationTool(); const result = await tool.handler({ productId: '12345', storeCode: '04515' }); diff --git a/tests/services/gs25/tools/checkInventory.test.ts b/tests/services/gs25/tools/checkInventory.test.ts index 15d376f..6a1aa10 100644 --- a/tests/services/gs25/tools/checkInventory.test.ts +++ b/tests/services/gs25/tools/checkInventory.test.ts @@ -3,12 +3,14 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { clearGs25StoresCache } from '../../../../src/services/gs25/client.js'; import { createCheckInventoryTool } from '../../../../src/services/gs25/tools/checkInventory.js'; const mockFetch = vi.fn(); beforeEach(() => { mockFetch.mockReset(); + clearGs25StoresCache(); vi.stubGlobal('fetch', mockFetch); }); @@ -178,4 +180,52 @@ describe('createCheckInventoryTool', () => { expect(parsed.location).toEqual({ latitude: 37.4979, longitude: 127.0276 }); expect(parsed.product.name).toBeNull(); }); + + it('위치 기반 재고 조회 결과가 있으면 storeKeyword 문자열 필터가 비어도 가까운 재고 매장을 유지한다', async () => { + const prevGoogleKey = process.env.GOOGLE_MAPS_API_KEY; + process.env.GOOGLE_MAPS_API_KEY = 'test-google-key'; + + mockFetch.mockResolvedValueOnce( + createStoreStockResponse([ + { storeCode: 'BASE1', storeName: '역삼센터점', storeAddress: '서울 강남구 테헤란로 1' }, + ]), + ); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + status: 'OK', + results: [{ geometry: { location: { lat: 37.4979, lng: 127.0276 } } }], + }), + ), + ); + + mockFetch.mockResolvedValueOnce(createTotalSearchResponse('8801117752804', '오감자')); + + mockFetch.mockResolvedValueOnce( + createStoreStockResponse([ + { + storeCode: 'near', + storeName: '역삼센터점', + storeAddress: '서울 테헤란로', + storeXCoordination: '127.0276', + storeYCoordination: '37.4979', + searchItemName: '오감자', + searchItemSellPrice: 1700, + realStockQuantity: 3, + }, + ]), + ); + + const tool = createCheckInventoryTool(); + const result = await tool.handler({ keyword: '오감자', storeKeyword: '강남', storeLimit: 5 }); + const parsed = JSON.parse(result.content[0].text); + + expect(parsed.filterRelaxed).toBe(true); + expect(parsed.inventory.count).toBe(1); + expect(parsed.inventory.stores[0].storeCode).toBe('near'); + expect(parsed.inventory.inStockStoreCount).toBe(1); + + process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; + }); }); diff --git a/tests/services/gs25/tools/findNearbyStores.test.ts b/tests/services/gs25/tools/findNearbyStores.test.ts index a83b44b..eb93b0f 100644 --- a/tests/services/gs25/tools/findNearbyStores.test.ts +++ b/tests/services/gs25/tools/findNearbyStores.test.ts @@ -93,4 +93,55 @@ describe('createFindNearbyStoresTool', () => { process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; }); + + it('지오코딩이 성공하면 좌표 기반 매장 조회를 사용하고 키워드 필터가 비어도 가까운 매장을 반환한다', 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: [ + { + storeCode: 'near', + storeName: '역삼센터점', + storeAddress: '서울 테헤란로', + storeXCoordination: '127.0276', + storeYCoordination: '37.4979', + }, + { + storeCode: 'far', + storeName: '부산해운대점', + storeAddress: '부산 해운대구', + storeXCoordination: '129.16', + storeYCoordination: '35.16', + }, + ], + }), + ), + ); + + const tool = createFindNearbyStoresTool(); + const result = await tool.handler({ keyword: '강남', limit: 5 }); + + const storeUrl = new URL(String(mockFetch.mock.calls[1]?.[0])); + expect(storeUrl.searchParams.get('centerPositionYCoordination')).toBe('37.4979'); + expect(storeUrl.searchParams.get('centerPositionXCoordination')).toBe('127.0276'); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.filterRelaxed).toBe(true); + expect(parsed.count).toBe(2); + expect(parsed.stores[0].storeCode).toBe('near'); + + process.env.GOOGLE_MAPS_API_KEY = prevGoogleKey; + }); }); diff --git a/tests/services/lottecinema/client.test.ts b/tests/services/lottecinema/client.test.ts index bf09f3b..548a08f 100644 --- a/tests/services/lottecinema/client.test.ts +++ b/tests/services/lottecinema/client.test.ts @@ -85,6 +85,47 @@ describe('fetchLotteCinemaTicketingPage', () => { expect(result.movies[0].durationMinutes).toBe(127); }); + it('같은 theaterId가 여러 지역 상세 코드로 내려와도 한 번만 반환한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + IsOK: true, + ResultMessage: 'SUCCESS', + Cinemas: { + Cinemas: { + Items: [ + { + CinemaID: '1016', + CinemaNameKR: '월드타워', + DivisionCode: '1', + DetailDivisionCode: '0001', + Latitude: '37.5132941', + Longitude: '127.104215', + CinemaAddrSummary: '서울 송파구 올림픽로 300', + }, + { + CinemaID: '1016', + CinemaNameKR: '월드타워', + DivisionCode: '2', + DetailDivisionCode: '0002', + Latitude: '37.5132941', + Longitude: '127.104215', + CinemaAddrSummary: '서울 송파구 올림픽로 300', + }, + ], + }, + }, + Movies: { Movies: { Items: [] } }, + }), + ), + ); + + const result = await fetchLotteCinemaTicketingPage(); + + expect(result.theaters).toHaveLength(1); + expect(result.theaters[0].theaterId).toBe('1016'); + }); + it('HTTP 에러를 처리한다', async () => { mockFetch.mockResolvedValue(new Response('fail', { status: 500 })); diff --git a/tests/services/lottemart/session.test.ts b/tests/services/lottemart/session.test.ts index b137288..cae33f6 100644 --- a/tests/services/lottemart/session.test.ts +++ b/tests/services/lottemart/session.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { __testOnlyClearLotteMartSessionCache, + __testOnlyCreateLotteMartSocketResponse, fetchLotteMartHtml, fetchLotteMartPageWithSession, getCachedLotteMartSessionCookie, @@ -105,6 +106,32 @@ describe('lottemart session helpers', () => { await expect(getCachedLotteMartSessionCookie(1000)).resolves.toBe('ASPSESSIONID=D'); }); + it('소켓 raw 응답에 HTTP 헤더 경계가 없으면 fallback 가능하도록 null을 반환한다', async () => { + expect(__testOnlyCreateLotteMartSocketResponse(new TextEncoder().encode(''))).toBeNull(); + expect(__testOnlyCreateLotteMartSocketResponse(new TextEncoder().encode('not-http'))).toBeNull(); + expect(__testOnlyCreateLotteMartSocketResponse(new TextEncoder().encode('not-http\r\n\r\nbody'))).toBeNull(); + }); + + it('소켓 raw HTTP 응답을 Response로 변환한다', async () => { + const raw = new TextEncoder().encode( + 'HTTP/1.1 200 OK\r\nMalformed-Header\r\nContent-Type: text/html\r\n\r\nok', + ); + + const response = __testOnlyCreateLotteMartSocketResponse(raw); + + expect(response?.status).toBe(200); + expect(response?.headers.get('Content-Type')).toBe('text/html'); + await expect(response?.text()).resolves.toBe('ok'); + }); + + it('소켓 raw HTTP 응답의 상태 코드가 숫자가 아니면 500으로 변환한다', () => { + const raw = new TextEncoder().encode('HTTP/1.1 BROKEN\r\nContent-Type: text/html\r\n\r\n'); + + const response = __testOnlyCreateLotteMartSocketResponse(raw); + + expect(response?.status).toBe(500); + }); + it('getSetCookie가 없으면 set-cookie 헤더를 사용한다', async () => { mockFetch.mockResolvedValue({ ok: true, diff --git a/tests/services/seveneleven/tools/searchProducts.test.ts b/tests/services/seveneleven/tools/searchProducts.test.ts index 4488bab..43c5bf9 100644 --- a/tests/services/seveneleven/tools/searchProducts.test.ts +++ b/tests/services/seveneleven/tools/searchProducts.test.ts @@ -92,6 +92,41 @@ describe('createSearchProductsTool', () => { ); }); + it('size보다 많은 상품이 내려와도 요청한 개수만 반환한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { + SearchQueryResult: { + query: '핫식스', + Collection: [ + { + CollectionId: 'offline', + Documentset: { + totalCount: 2, + Document: [ + { prdNo: '1', itemCd: '8801', itemOnm: '핫식스 오리지널' }, + { prdNo: '2', itemCd: '8802', itemOnm: '핫식스 제로' }, + ], + }, + }, + ], + }, + }, + }), + ), + ); + + const tool = createSearchProductsTool(); + const result = await tool.handler({ query: '핫식스', size: 1 }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.count).toBe(1); + expect(parsed.products).toHaveLength(1); + expect(parsed.products[0].itemCode).toBe('8801'); + }); + it('필요하면 대체 질의로 상품을 찾아 반환한다', async () => { mockFetch .mockResolvedValueOnce(