From 4f6ae02733b16c23682e2d4de6d5b6531d32fedf Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 5 Oct 2026 11:31:43 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20CU=20=EC=A4=91=EA=B3=84=20=ED=82=A4?= =?UTF-8?q?=EC=9B=8C=EB=93=9C=20=EC=A1=B0=ED=9A=8C=EC=9D=98=20=EC=A7=80?= =?UTF-8?q?=EC=97=AD=20=EC=9E=AC=EA=B3=A0=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/convenience-relay.md | 2 + src/services/cu/client.ts | 4 +- tests/api/cu-relay-keyword.test.ts | 119 +++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/api/cu-relay-keyword.test.ts diff --git a/docs/convenience-relay.md b/docs/convenience-relay.md index d94f3180..aeb1a419 100644 --- a/docs/convenience-relay.md +++ b/docs/convenience-relay.md @@ -48,6 +48,8 @@ GS25 재고는 별도 공식 앱 인증키가 필요합니다. `GS25_API_KEY`를 ## 제한과 결과 해석 +CU 매장 키워드만 입력한 조회는 공식 웹에서 해당 지역 점포를 찾은 뒤 주소를 좌표로 변환하고, 상품 코드와 함께 PocketCU 중계에 전달합니다. 주소 좌표를 확인하지 못하면 해당 지역의 점포 정보만 반환하고 수량은 `-1`(미확인)로 표시합니다. 기본 서울 중심 좌표로 다른 지역의 재고를 대신 조회하지 않습니다. + 본문 16 KiB, 원본 응답 2 MiB, 동시 원본 호출 4개, 전체 요청 32개를 제한합니다. 본문·대기·원장·원본 처리를 합쳐 15초 기한을 적용하며 취소된 대기 요청은 예산을 소비하지 않습니다. 인증·입력 검증 뒤 성공한 상품·메타정보·초기화·카탈로그는 5분, 매장·실재고는 30초 동안 최대 256개·16 MiB의 메모리 캐시에 저장합니다. 캐시 적중은 호출 예산을 소비하지 않습니다. 오류는 캐시하지 않습니다. 리다이렉트·비 JSON·실패 envelope·잘못된 수량은 오류로 반환합니다. 재고 오류를 수량 0으로 바꾸지 않습니다. 세븐일레븐 수량 `-1`은 미확인이며 품절로 표시하지 않습니다. 반환된 매장의 수량이 모두 미확인이면 `stockAvailable`은 false입니다. 캐시 수량은 조회 시점 이후 바뀔 수 있으므로 실제 방문 전 점포 확인이 필요합니다. diff --git a/src/services/cu/client.ts b/src/services/cu/client.ts index 7c75ab57..9d194ef5 100644 --- a/src/services/cu/client.ts +++ b/src/services/cu/client.ts @@ -225,7 +225,7 @@ export async function fetchCuStores( const hasLongitude = typeof params.longitude === 'number' && Number.isFinite(params.longitude); // 좌표가 없고 검색어가 있으면 웹 매장 검색으로 폴백합니다. - if (!hasConvenienceRelay(options) && searchWord.length > 0 && (!hasLatitude || !hasLongitude)) { + if (searchWord.length > 0 && (!hasLatitude || !hasLongitude)) { const html = await requestCuWebHtml( CU_API.WEB_STORE_LIST_PATH, { @@ -253,6 +253,8 @@ export async function fetchCuStores( timeout, ); const stores = parseCuWebStores(html); + // 공식 웹은 점포 정보만 제공하므로 중계 재고 조회 전 수량은 미확인입니다. + if (hasConvenienceRelay(options)) for (const store of stores) store.stock = -1; return { totalCount: stores.length, stores, diff --git a/tests/api/cu-relay-keyword.test.ts b/tests/api/cu-relay-keyword.test.ts new file mode 100644 index 00000000..4ee34bbe --- /dev/null +++ b/tests/api/cu-relay-keyword.test.ts @@ -0,0 +1,119 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { afterEach, expect, it, vi } from 'vitest'; +import app from '../../src/index.js'; +import { fetchCuStores } from '../../src/services/cu/client.js'; +const env = { + CONVENIENCE_RELAY_URL: 'https://relay.example', + CONVENIENCE_RELAY_TOKEN: 'token', + KAKAO_REST_API_KEY: 'test-key', +}; +const html = `
강남예시점
서울 강남구 테헤란로 1
`; +afterEach(() => vi.unstubAllGlobals()); +function prepare(geocode = true) { + const fetcher = vi.fn(async (url: string, init?: RequestInit) => { + if (url.startsWith('https://cu.bgfretail.com/store/list_Ajax.do?')) return new Response(html); + if (url.startsWith('https://dapi.kakao.com/')) + return Response.json({ + documents: geocode + ? [{ x: '127.03', y: '37.5', address_name: '서울 강남구 테헤란로 1' }] + : [], + }); + if (url.endsWith('/cu-prime')) return Response.json({}); + if (url.endsWith('/cu-stock')) + return Response.json({ + data: { + stockResult: { + result: { rows: [{ fields: { item_cd: '123', on_item_no: '456', item_nm: '커피' } }] }, + }, + }, + }); + if (url.endsWith('/cu-store')) { + const body = JSON.parse(init!.body as string); + return Response.json({ + storeList: + body.latVal === '37.5' + ? [{ storeCd: 'sample', storeNm: '강남예시점', stock: 3 }] + : [ + { + storeCd: 'wrong', + storeNm: '시청예시점', + stock: 0, + doroStoreAddr1: '서울 중구 세종대로 1', + }, + ], + }); + } + throw Error('Unexpected endpoint'); + }); + vi.stubGlobal('fetch', fetcher); + return fetcher; +} +it('중계 설정이 있어도 좌표 없는 CU 매장 키워드는 공식 웹에서 찾는다', async () => { + const fetcher = prepare(); + const result = await fetchCuStores( + { searchWord: '강남' }, + { + convenienceRelayUrl: env.CONVENIENCE_RELAY_URL, + convenienceRelayToken: env.CONVENIENCE_RELAY_TOKEN, + }, + ); + expect(result.stores).toEqual([ + expect.objectContaining({ + storeName: '강남예시점', + address: '서울 강남구 테헤란로 1', + stock: -1, + }), + ]); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(fetcher.mock.calls[0][0]).toContain('https://cu.bgfretail.com/store/list_Ajax.do?'); + expect(new URL(fetcher.mock.calls[0][0]).searchParams.get('searchWord')).toBe('강남'); + expect(fetcher.mock.calls[0][1]).toMatchObject({ method: 'GET' }); +}); +it.each([true, false])( + 'REST와 MCP 키워드 재고는 요청 지역을 지키고 좌표 미확인 수량을 추정하지 않는다 (%s)', + async (geocode) => { + const fetcher = prepare(geocode); + const response = await app.request( + '/api/cu/inventory?keyword=커피&storeKeyword=강남', + undefined, + env, + ); + expect(response.status).toBe(200); + const rest = await response.json(); + expect(rest.data.nearbyStores.stores).toEqual([ + expect.objectContaining({ storeName: '강남예시점', stock: geocode ? 3 : -1 }), + ]); + const client = new Client({ name: 'test', version: '1' }); + await client.connect( + new StreamableHTTPClientTransport(new URL('https://local.test/mcp'), { + fetch: async (url, init) => app.request(new Request(url, init), undefined, env), + }), + ); + try { + const result = await client.callTool({ + name: 'cu_check_inventory', + arguments: { keyword: '커피', storeKeyword: '강남' }, + }); + expect(result.isError).not.toBe(true); + const content = result.content as Array<{ text: string }>; + expect(JSON.parse(content[0].text).nearbyStores.stores).toEqual([ + expect.objectContaining({ storeName: '강남예시점', stock: geocode ? 3 : -1 }), + ]); + const stockCalls = fetcher.mock.calls.filter(([url]) => url.endsWith('/cu-store')); + expect(stockCalls).toHaveLength(geocode ? 2 : 0); + for (const [, init] of stockCalls) + expect(JSON.parse(init!.body as string)).toMatchObject({ + latVal: '37.5', + longVal: '127.03', + searchWord: '강남', + itemCd: '123', + onItemNo: '456', + isRecommend: 'Y', + recommendId: 'stock', + }); + } finally { + await client.close(); + } + }, +); From 49adb35bf21988f28c3b49c3d7c3357417e223f9 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 5 Oct 2026 11:35:41 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20CU=20=EC=A7=80=EC=97=AD=20=EC=9E=AC?= =?UTF-8?q?=EA=B3=A0=20=EC=88=98=EC=A0=95=20=ED=9B=84=20=EA=B8=B0=EC=A1=B4?= =?UTF-8?q?=20=EC=BA=90=EC=8B=9C=20=EC=9A=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/routes/cuRoutes.ts | 4 ++-- tests/api/cu-relay-keyword.test.ts | 35 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/api/routes/cuRoutes.ts b/src/api/routes/cuRoutes.ts index d0f029b5..fa35e579 100644 --- a/src/api/routes/cuRoutes.ts +++ b/src/api/routes/cuRoutes.ts @@ -14,7 +14,7 @@ export function registerCuRoutes(app: Hono<{ Bindings: AppBindings }>): void { { ttlSeconds: 60 * 60 * 24, staleWhileRevalidateSeconds: 60 * 5, - keyPrefix: 'cu-stores-v2', + keyPrefix: 'cu-stores-v3', }, () => handleCuFindStores(c), ), @@ -26,7 +26,7 @@ export function registerCuRoutes(app: Hono<{ Bindings: AppBindings }>): void { { ttlSeconds: 60 * 10, staleWhileRevalidateSeconds: 60, - keyPrefix: 'cu-inventory-v2', + keyPrefix: 'cu-inventory-v3', }, () => handleCuCheckInventory(c), ), diff --git a/tests/api/cu-relay-keyword.test.ts b/tests/api/cu-relay-keyword.test.ts index 4ee34bbe..9f5541fa 100644 --- a/tests/api/cu-relay-keyword.test.ts +++ b/tests/api/cu-relay-keyword.test.ts @@ -117,3 +117,38 @@ it.each([true, false])( } }, ); +it.each([ + ['/api/cu/stores?keyword=강남', 'cu-stores', 86400, 300], + ['/api/cu/inventory?keyword=커피&storeKeyword=강남', 'cu-inventory', 600, 60], +])( + 'CU 수정 후 일반 사용자 URL의 기존 잘못된 캐시를 우회한다 (%s)', + async (path, prefix, ttl, swr) => { + const fetcher = prepare(); + const url = new URL(path as string, 'https://local.test'); + const oldKey = new URL(url); + oldKey.searchParams.append('__cache_prefix', `${prefix}-v2`); + const entries = new Map([ + [oldKey.href, Response.json({ success: true, data: { storeName: '시청예시점', stock: 0 } })], + ]); + const match = vi.fn(async (key: Request) => entries.get(key.url)?.clone()); + const put = vi.fn(async (key: Request, response: Response) => { + entries.set(key.url, response.clone()); + }); + vi.stubGlobal('caches', { default: { match, put } }); + const response = await app.request(url.href, undefined, env); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).toContain('강남예시점'); + expect(text).not.toContain('시청예시점'); + const newKey = new URL(url); + newKey.searchParams.append('__cache_prefix', `${prefix}-v3`); + expect(match.mock.calls[0][0].url).toBe(newKey.href); + expect(put.mock.calls[0][0].url).toBe(newKey.href); + expect(response.headers.get('Cache-Control')).toContain(`max-age=${ttl}`); + expect(response.headers.get('Cache-Control')).toContain(`stale-while-revalidate=${swr}`); + const calls = fetcher.mock.calls.length; + expect(await (await app.request(url.href, undefined, env)).text()).toBe(text); + expect(fetcher.mock.calls).toHaveLength(calls); + expect(entries.has(oldKey.href)).toBe(true); + }, +); From 8fefa895876d98650ec103933dbb61dd313fe3c5 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 5 Oct 2026 11:40:59 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20CU=20=EC=A0=90=ED=8F=AC=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A3=BC=EC=86=8C=EC=9D=98=20=EA=B1=B4=EB=AC=BC=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/convenience-relay.md | 2 +- src/services/cu/client.ts | 16 +++++++++++++++- tests/api/cu-relay-keyword.test.ts | 6 +++--- tests/services/cu/client.test.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/docs/convenience-relay.md b/docs/convenience-relay.md index aeb1a419..d1e92ad1 100644 --- a/docs/convenience-relay.md +++ b/docs/convenience-relay.md @@ -48,7 +48,7 @@ GS25 재고는 별도 공식 앱 인증키가 필요합니다. `GS25_API_KEY`를 ## 제한과 결과 해석 -CU 매장 키워드만 입력한 조회는 공식 웹에서 해당 지역 점포를 찾은 뒤 주소를 좌표로 변환하고, 상품 코드와 함께 PocketCU 중계에 전달합니다. 주소 좌표를 확인하지 못하면 해당 지역의 점포 정보만 반환하고 수량은 `-1`(미확인)로 표시합니다. 기본 서울 중심 좌표로 다른 지역의 재고를 대신 조회하지 않습니다. +CU 매장 키워드만 입력한 조회는 공식 웹에서 해당 지역 점포를 찾은 뒤 주소의 도로명·건물 번호 또는 지번을 보존하면서 층·호수·건물명 상세를 제외한 본주소를 좌표로 변환하고, 상품 코드와 함께 PocketCU 중계에 전달합니다. 주소 좌표를 확인하지 못하면 해당 지역의 점포 정보만 반환하고 수량은 `-1`(미확인)로 표시합니다. 기본 서울 중심 좌표로 다른 지역의 재고를 대신 조회하지 않습니다. 본문 16 KiB, 원본 응답 2 MiB, 동시 원본 호출 4개, 전체 요청 32개를 제한합니다. 본문·대기·원장·원본 처리를 합쳐 15초 기한을 적용하며 취소된 대기 요청은 예산을 소비하지 않습니다. 인증·입력 검증 뒤 성공한 상품·메타정보·초기화·카탈로그는 5분, 매장·실재고는 30초 동안 최대 256개·16 MiB의 메모리 캐시에 저장합니다. 캐시 적중은 호출 예산을 소비하지 않습니다. 오류는 캐시하지 않습니다. diff --git a/src/services/cu/client.ts b/src/services/cu/client.ts index 9d194ef5..aeceffe2 100644 --- a/src/services/cu/client.ts +++ b/src/services/cu/client.ts @@ -144,8 +144,22 @@ async function requestCuWebHtml( throw new Error(`API 요청 실패: ${response.status} ${response.statusText}`); } +/** 점포 상세 주소에서 확인 가능한 도로명·지번 본주소만 위치 검색에 사용합니다. */ +function cuGeocodeAddress(address: string): string { + const trimmed = address.trim(); + const base = trimmed.match( + /^((?:[가-힣]+(?:시|도)|서울|부산|대구|인천|광주|대전|울산|세종|경기|강원|충북|충남|전북|전남|경북|경남|제주)\s+.+?(?:(?:대로|로|길)\s+\d+(?:-\d+)?|(?:읍|면|동|리)\s+(?:산\s*)?\d+(?:-\d+)?))(?=\s|$)/u, + ); + if (!base) return address; + const detail = trimmed.slice(base[1].length); + // 층·호수·건물명·괄호 상세만 제거하고 다른 숫자 주소가 섞이면 원문을 유지합니다. + return /^(?:\s+(?:\d+(?:층|호)|[A-Za-z가-힣][A-Za-z가-힣0-9]*|\([^)]*\)))*\s*$/u.test(detail) + ? base[1] + : address; +} + export async function geocodeCuAddress(address: string, options: RequestOptions = {}) { - const location = await geocodeLocation(address, options); + const location = await geocodeLocation(cuGeocodeAddress(address), options); return location ? { latitude: location.latitude, longitude: location.longitude } : null; } diff --git a/tests/api/cu-relay-keyword.test.ts b/tests/api/cu-relay-keyword.test.ts index 9f5541fa..fe787bad 100644 --- a/tests/api/cu-relay-keyword.test.ts +++ b/tests/api/cu-relay-keyword.test.ts @@ -8,7 +8,7 @@ const env = { CONVENIENCE_RELAY_TOKEN: 'token', KAKAO_REST_API_KEY: 'test-key', }; -const html = `
강남예시점
서울 강남구 테헤란로 1
`; +const html = `
강남예시점
서울특별시 강남구 테헤란로63길 9 1층 105호
`; afterEach(() => vi.unstubAllGlobals()); function prepare(geocode = true) { const fetcher = vi.fn(async (url: string, init?: RequestInit) => { @@ -16,7 +16,7 @@ function prepare(geocode = true) { if (url.startsWith('https://dapi.kakao.com/')) return Response.json({ documents: geocode - ? [{ x: '127.03', y: '37.5', address_name: '서울 강남구 테헤란로 1' }] + ? [{ x: '127.03', y: '37.5', address_name: '서울 강남구 테헤란로63길 9' }] : [], }); if (url.endsWith('/cu-prime')) return Response.json({}); @@ -61,7 +61,7 @@ it('중계 설정이 있어도 좌표 없는 CU 매장 키워드는 공식 웹 expect(result.stores).toEqual([ expect.objectContaining({ storeName: '강남예시점', - address: '서울 강남구 테헤란로 1', + address: '서울특별시 강남구 테헤란로63길 9 1층 105호', stock: -1, }), ]); diff --git a/tests/services/cu/client.test.ts b/tests/services/cu/client.test.ts index c0e3f971..7e474d42 100644 --- a/tests/services/cu/client.test.ts +++ b/tests/services/cu/client.test.ts @@ -561,3 +561,29 @@ it.each([400, 403, 429])('키가 없는 재고 원본 %i 차단은 조회 불가 expect(result.unavailableReason).toContain(`${status} Request Blocked`); expect(mockFetch).toHaveBeenCalledTimes(2); }); + +it.each([ + ['서울특별시 강남구 테헤란로63길 9 1층 105호', '서울특별시 강남구 테헤란로63길 9', '서울 강남구 테헤란로63길 9'], + ['서울특별시 강남구 도산대로 529 KRA프라자 (청담동) KRA프라자', '서울특별시 강남구 도산대로 529', '서울 강남구 도산대로 529'], + ['서울특별시 강남구 논현로 201 (도곡동)', '서울특별시 강남구 논현로 201', '서울 강남구 논현로 201'], + ['경기도 성남시 분당구 정자동 12-3 2층 201호', '경기도 성남시 분당구 정자동 12-3', '경기 성남시 분당구 정자동 12-3'], +])('CU 상세 주소의 건물 위치만 요청하되 도로·건물 번호를 보존한다 (%s)', async (address, query, canonical) => { + mockFetch.mockResolvedValue(Response.json({ documents: [{ x: '127.03', y: '37.5', address_name: canonical }] })); + expect(await geocodeCuAddress(address, { kakaoRestApiKey: 'test-key' })).toEqual({ latitude: 37.5, longitude: 127.03 }); + expect(new URL(mockFetch.mock.calls[0][0]).searchParams.get('query')).toBe(query); +}); +it.each(['서울 강남구 테헤란로64길 9', '서울 강남구 테헤란로63길 10', '서울 서초구 테헤란로63길 9'])('CU 상세 주소도 다른 도로·건물·지역을 거절한다 (%s)', async (canonical) => { + mockFetch.mockResolvedValue(Response.json({ documents: [{ x: '127.03', y: '37.5', address_name: canonical }] })); + expect(await geocodeCuAddress('서울특별시 강남구 테헤란로63길 9 1층 105호', { kakaoRestApiKey: 'test-key' })).toBeNull(); +}); +it('CU 위치를 확인할 수 없는 주소는 원문을 그대로 조회한다', async () => { + mockFetch.mockResolvedValue(Response.json({ documents: [] })); + expect(await geocodeCuAddress('알수없는 건물 105호', { kakaoRestApiKey: 'test-key' })).toBeNull(); + expect(new URL(mockFetch.mock.calls[0][0]).searchParams.get('query')).toBe('알수없는 건물 105호'); +}); +it('CU 주소에 두 도로 번호가 함께 있으면 임의로 첫 위치만 선택하지 않는다', async () => { + mockFetch.mockResolvedValue(Response.json({ documents: [] })); + const address = '서울특별시 강남구 테헤란로63길 9 강남대로 111'; + expect(await geocodeCuAddress(address, { kakaoRestApiKey: 'test-key' })).toBeNull(); + expect(new URL(mockFetch.mock.calls[0][0]).searchParams.get('query')).toBe(address); +}); From 03077e78c3ebd5a658d264640145673b684b3a5b Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 5 Oct 2026 11:45:01 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20CU=20=EA=B4=84=ED=98=B8=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EC=9D=98=20=EB=8B=A4=EB=A5=B8=20=EC=A3=BC=EC=86=8C=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/cu/client.ts | 1 + tests/services/cu/client.test.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/services/cu/client.ts b/src/services/cu/client.ts index aeceffe2..12513cc6 100644 --- a/src/services/cu/client.ts +++ b/src/services/cu/client.ts @@ -152,6 +152,7 @@ function cuGeocodeAddress(address: string): string { ); if (!base) return address; const detail = trimmed.slice(base[1].length); + if (/(?:대로|로|길)\s+\d|(?:읍|면|동|리)\s+(?:산\s*)?\d/u.test(detail)) return address; // 층·호수·건물명·괄호 상세만 제거하고 다른 숫자 주소가 섞이면 원문을 유지합니다. return /^(?:\s+(?:\d+(?:층|호)|[A-Za-z가-힣][A-Za-z가-힣0-9]*|\([^)]*\)))*\s*$/u.test(detail) ? base[1] diff --git a/tests/services/cu/client.test.ts b/tests/services/cu/client.test.ts index 7e474d42..d210885f 100644 --- a/tests/services/cu/client.test.ts +++ b/tests/services/cu/client.test.ts @@ -587,3 +587,17 @@ it('CU 주소에 두 도로 번호가 함께 있으면 임의로 첫 위치만 expect(await geocodeCuAddress(address, { kakaoRestApiKey: 'test-key' })).toBeNull(); expect(new URL(mockFetch.mock.calls[0][0]).searchParams.get('query')).toBe(address); }); +it.each([ + '서울특별시 강남구 테헤란로63길 9 (서울특별시 서초구 강남대로 111)', + '서울특별시 강남구 테헤란로63길 9 (경기도 성남시 분당구 정자동 12-3)', +])('CU 괄호 안의 다른 도로·지번 주소를 상세 정보로 제거하지 않는다 (%s)', async (address) => { + mockFetch.mockResolvedValue(Response.json({ documents: [{ x: '127.03', y: '37.5', address_name: '서울 강남구 테헤란로63길 9' }] })); + expect(await geocodeCuAddress(address, { kakaoRestApiKey: 'test-key' })).toBeNull(); + expect(new URL(mockFetch.mock.calls[0][0]).searchParams.get('query')).toBe(address); +}); +it('CU 주소의 인식할 수 없는 숫자 상세는 원문에 남긴다', async () => { + const address = '서울특별시 강남구 테헤란로63길 9 105'; + mockFetch.mockResolvedValue(Response.json({ documents: [{ x: '127.03', y: '37.5', address_name: '서울 강남구 테헤란로63길 9' }] })); + expect(await geocodeCuAddress(address, { kakaoRestApiKey: 'test-key' })).toBeNull(); + expect(new URL(mockFetch.mock.calls[0][0]).searchParams.get('query')).toBe(address); +});