From 5f26386c246ba8434953c455e396ae90d8389613 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Tue, 6 Oct 2026 07:57:14 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=8B=A4=EC=9D=B4=EC=86=8C=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EA=B3=BC=20=EB=B9=88=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A9=94=EA=B0=80=EB=B0=95=EC=8A=A4=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/daiso-input-validation.md | 14 ++ docs/plans/2026-10-06-daiso-boundaries.md | 22 +++ src/api/daisoHandlers.ts | 31 +++- src/api/routes/daisoRoutes.ts | 6 +- src/api/routes/megaboxRoutes.ts | 6 +- src/pages/openapiSpecPathsDaiso.ts | 18 +- src/services/daiso/tools/checkInventory.ts | 42 +++-- .../daiso/tools/findInventoryByName.ts | 45 +++-- src/services/daiso/tools/findStores.ts | 13 +- src/services/daiso/tools/searchProducts.ts | 11 +- src/services/daiso/validation.ts | 42 +++++ src/services/megabox/client.ts | 40 ++++- tests/api/daiso-cache-version.test.ts | 15 ++ tests/fixtures/megabox/booking-theaters.json | 67 +++++++ .../megabox/theater-0061-location.html | 11 ++ .../megabox/theater-0079-location.html | 11 ++ .../megabox/theater-0082-location.html | 11 ++ tests/pages/openapi-daiso-boundaries.test.ts | 26 +++ tests/services/daiso/boundaries.test.ts | 148 ++++++++++++++++ tests/services/megabox/display.test.ts | 164 ++++++++++++++++++ 20 files changed, 686 insertions(+), 57 deletions(-) create mode 100644 docs/daiso-input-validation.md create mode 100644 docs/plans/2026-10-06-daiso-boundaries.md create mode 100644 src/services/daiso/validation.ts create mode 100644 tests/api/daiso-cache-version.test.ts create mode 100644 tests/fixtures/megabox/booking-theaters.json create mode 100644 tests/fixtures/megabox/theater-0061-location.html create mode 100644 tests/fixtures/megabox/theater-0079-location.html create mode 100644 tests/fixtures/megabox/theater-0082-location.html create mode 100644 tests/pages/openapi-daiso-boundaries.test.ts create mode 100644 tests/services/daiso/boundaries.test.ts create mode 100644 tests/services/megabox/display.test.ts diff --git a/docs/daiso-input-validation.md b/docs/daiso-input-validation.md new file mode 100644 index 00000000..18dfc611 --- /dev/null +++ b/docs/daiso-input-validation.md @@ -0,0 +1,14 @@ +# 다이소 조회 입력과 빈 페이지 처리 + +다이소 REST·MCP 조회는 외부 API를 호출하기 전에 입력을 검증합니다. + +- `page`, `pageSize`, 매장 `limit`, 상품 후보 `productLimit`은 유한한 양의 정수입니다. 0·음수·소수·NaN·Infinity·숫자 뒤 문자를 거부합니다. +- 재고 좌표는 위도 -90~90, 경도 -180~180의 유한한 숫자 두 값을 함께 입력합니다. 둘 다 생략하면 기존 서울 시청 기본값을 사용합니다. 한쪽만 입력해 나머지를 기본값으로 채우지 않습니다. +- REST는 잘못된 입력에 HTTP 400/`INVALID_PARAMS`를 반환합니다. MCP는 입력 오류로 실패하며 재고 0으로 바꾸지 않습니다. +- 재고 검색어에 매장이 있으면 결과 범위를 벗어난 페이지는 빈 목록과 실제 전체 매장 수를 반환합니다. 빈 페이지라는 이유로 다른 검색어 변형에 넘어가지 않습니다. 검색 자체가 빈 경우의 검색어 보정은 유지합니다. + +`keyword=강남`은 매장명·주소의 키워드 검색이며 강남구 행정 경계나 반경 필터를 의미하지 않습니다. `inStockCount`는 반환된 페이지의 재고 보유 매장 수입니다. `totalStores`는 검색된 전체 매장 수이며 서로 다른 범위의 수입니다. + +다이소 상품/매장/재고 캐시는 v3로 변경해 이전 입력·페이지 오응답을 무효화했습니다. 상세·진열 위치 캐시 및 중계 인증·호출 예산은 변경하지 않습니다. + +함께 수정한 메가박스 표시: 공식 상영 목록의 HTML entity를 극장·영화 이름에서 해석하며, 공식 지점 HTML의 도로명주소 li/span과 기존 dt/dd 형식을 읽습니다. 원본에 없는 주소는 빈 값으로 유지합니다. 관련 REST 캐시 v4로 이전 이름·주소 응답을 무효화하며 외부 서비스의 시간 초과 정책은 그대로입니다. diff --git a/docs/plans/2026-10-06-daiso-boundaries.md b/docs/plans/2026-10-06-daiso-boundaries.md new file mode 100644 index 00000000..b8c3c7f1 --- /dev/null +++ b/docs/plans/2026-10-06-daiso-boundaries.md @@ -0,0 +1,22 @@ +# 다이소 입력·페이지 및 메가박스 표시 개선 계획 + +사용자 승인: 2026-10-06 전체 감사에서 발견한 항목을 모두 개선한다. 정상 조회·인증·중계 예산은 보존하며 기존 병합·배포·CLI 공개 범위에서 검증 후 반영한다. + +## 처리 계약 + +- 다이소 REST·MCP는 유한한 양의 정수 page/pageSize/limit을 허용한다. 숫자 일부만 parseInt/parseFloat로 인정하지 않는다. 좌표는 둘 다 생략 시 기존 기본값, 지정 시 두 값이 함께 유한하며 위도[-90,90]/경도[-180,180] 범위여야 한다. handler 직접 호출에도 같은 기준을 적용한다. 실패는 외부 호출 전 REST400·MCP입력오류다. +- 재고 검색어에 매장이 존재하면 빈 후속 페이지는 stores[]와 실제 totalCount를 유지한다. 페이지 부족으로 다른 검색어 변형에 넘어가지 않는다. 검색 자체가 빈 경우의 기존 검색어 보정은 보존한다. +- 메가박스 이름의 HTML entity는 표시 문자열로 변환한다. 지점 주소는 현재 공식 HTML과 기존 형식을 모두 해석한다. 원본에 없는 주소는 만들지 않는다. +- 이전 오응답을 재사용하지 않도록 관련 REST 캐시 버전을 올린다. 새 라이브러리·인증·예산·영화관 타임아웃 정책은 추가하지 않는다. + +## 실행 및 검증 + +- [x] 기존 2,806 테스트 baseline 확인. +- [x] 다이소 잘못된 페이지/좌표·키워드 보정·빈 페이지의 REST/MCP 회귀 테스트 RED → GREEN. +- [x] 실제 메가박스 HTML 확보, 이름/주소의 fixture 회귀 테스트 RED → GREEN. +- [x] 전체 check/coverage100/build/audit 및 독립 검토. +- [ ] PR/CI 병합과 동일 main 배포 확인. CLI 계약·게시 검증이 필요한 경우 버전 패치 후 공개. +- [ ] 운영 REST·MCP·게시 CLI 정상 조회와 오류 조건, 빈 페이지 전체수·메가박스 실제 주소 재검증. +- [ ] 보고서·PROJECT 기록 및 완료 알림. + +검증: 243파일·2,968tests, statements/branches/functions/lines100%, check/build/audit0 및 독립검토 통과. 상세 OpenAPI JSON/YAML 생성 응답을 검증했다. 저장소 openapi.json/yaml은 축약 Actions 스펙이라 이번 개별 경로 변경의 diff가 없으며 /openapi-full.json/yaml에서 새 제약을 확인한다. diff --git a/src/api/daisoHandlers.ts b/src/api/daisoHandlers.ts index 3925c3a4..ef1c27f1 100644 --- a/src/api/daisoHandlers.ts +++ b/src/api/daisoHandlers.ts @@ -2,6 +2,13 @@ * 다이소 GET API 핸들러 */ +import { + DaisoValidationError, + parseNumericQuery, + validatePagination, + validatePositiveInteger, + resolveCoordinates, +} from '../services/daiso/validation.js'; import { getImageUrl } from '../services/daiso/api.js'; import { toProductSummary } from '../services/daiso/product.js'; import { fetchOnlineStock, fetchStoreInventory } from '../services/daiso/tools/checkInventory.js'; @@ -13,18 +20,21 @@ import { type ApiContext, errorResponse, successResponse } from './response.js'; export async function handleSearchProducts(c: ApiContext) { const query = c.req.query('q'); - const page = parseInt(c.req.query('page') || '1'); - const pageSize = parseInt(c.req.query('pageSize') || '30'); + const page = parseNumericQuery(c.req.query('page'), 1) as number; + const pageSize = parseNumericQuery(c.req.query('pageSize'), 30) as number; if (!query || query.trim().length === 0) { return errorResponse(c, 'MISSING_QUERY', '검색어(q)를 입력해주세요.'); } try { + validatePagination(page, pageSize); const { products, totalCount } = await fetchProducts(query, page, pageSize); return successResponse(c, { products }, { total: totalCount, page, pageSize }); } catch (error) { + if (error instanceof DaisoValidationError) + return errorResponse(c, 'INVALID_PARAMS', error.message); const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'SEARCH_FAILED', message, 500); } @@ -67,18 +77,21 @@ export async function handleFindStores(c: ApiContext) { const sido = c.req.query('sido'); const gugun = c.req.query('gugun'); const dong = c.req.query('dong'); - const limit = parseInt(c.req.query('limit') || '50'); + const limit = parseNumericQuery(c.req.query('limit'), 50) as number; if (!keyword && !sido) { return errorResponse(c, 'MISSING_PARAMS', '검색어(keyword) 또는 지역(sido)을 입력해주세요.'); } try { + validatePositiveInteger(limit, 'limit'); const stores = await fetchStores(keyword, sido, gugun, dong); const limitedStores = stores.slice(0, limit); return successResponse(c, { stores: limitedStores }, { total: stores.length }); } catch (error) { + if (error instanceof DaisoValidationError) + return errorResponse(c, 'INVALID_PARAMS', error.message); const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'SEARCH_FAILED', message, 500); } @@ -86,17 +99,19 @@ export async function handleFindStores(c: ApiContext) { export async function handleCheckInventory(c: ApiContext) { const productId = c.req.query('productId'); - const lat = parseFloat(c.req.query('lat') || '37.5665'); - const lng = parseFloat(c.req.query('lng') || '126.978'); + const inputLat = parseNumericQuery(c.req.query('lat')); + const inputLng = parseNumericQuery(c.req.query('lng')); const keyword = c.req.query('keyword') || ''; - const page = parseInt(c.req.query('page') || '1'); - const pageSize = parseInt(c.req.query('pageSize') || '30'); + const page = parseNumericQuery(c.req.query('page'), 1) as number; + const pageSize = parseNumericQuery(c.req.query('pageSize'), 30) as number; if (!productId) { return errorResponse(c, 'MISSING_PRODUCT_ID', '제품 ID(productId)를 입력해주세요.'); } try { + validatePagination(page, pageSize); + const { latitude: lat, longitude: lng } = resolveCoordinates(inputLat, inputLng); const [onlineStock, storeResult, productDoc] = await Promise.all([ fetchOnlineStock(productId), fetchStoreInventory(productId, lat, lng, page, pageSize, keyword), @@ -121,6 +136,8 @@ export async function handleCheckInventory(c: ApiContext) { return successResponse(c, result, { total: storeResult.totalCount, page, pageSize }); } catch (error) { + if (error instanceof DaisoValidationError) + return errorResponse(c, 'INVALID_PARAMS', error.message); const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'INVENTORY_CHECK_FAILED', message, 500); } diff --git a/src/api/routes/daisoRoutes.ts b/src/api/routes/daisoRoutes.ts index bb1acfbb..6075761a 100644 --- a/src/api/routes/daisoRoutes.ts +++ b/src/api/routes/daisoRoutes.ts @@ -20,7 +20,7 @@ export function registerDaisoRoutes(app: Hono<{ Bindings: AppBindings }>): void { ttlSeconds: 60 * 30, staleWhileRevalidateSeconds: 60 * 3, - keyPrefix: 'daiso-products-v2', + keyPrefix: 'daiso-products-v3', }, () => handleSearchProducts(c), ), @@ -44,7 +44,7 @@ export function registerDaisoRoutes(app: Hono<{ Bindings: AppBindings }>): void { ttlSeconds: 60 * 60 * 24, staleWhileRevalidateSeconds: 60 * 5, - keyPrefix: 'daiso-stores-v2', + keyPrefix: 'daiso-stores-v3', }, () => handleFindStores(c), ), @@ -56,7 +56,7 @@ export function registerDaisoRoutes(app: Hono<{ Bindings: AppBindings }>): void { ttlSeconds: 60 * 10, staleWhileRevalidateSeconds: 60, - keyPrefix: 'daiso-inventory-v2', + keyPrefix: 'daiso-inventory-v3', }, () => handleCheckInventory(c), ), diff --git a/src/api/routes/megaboxRoutes.ts b/src/api/routes/megaboxRoutes.ts index cfa7d772..3280c11a 100644 --- a/src/api/routes/megaboxRoutes.ts +++ b/src/api/routes/megaboxRoutes.ts @@ -18,7 +18,7 @@ export function registerMegaboxRoutes(app: Hono<{ Bindings: AppBindings }>): voi { ttlSeconds: 60 * 60 * 24, staleWhileRevalidateSeconds: 60 * 5, - keyPrefix: 'megabox-theaters-v3', + keyPrefix: 'megabox-theaters-v4', }, () => handleMegaboxFindNearbyTheaters(c), ), @@ -30,7 +30,7 @@ export function registerMegaboxRoutes(app: Hono<{ Bindings: AppBindings }>): voi { ttlSeconds: 60 * 10, staleWhileRevalidateSeconds: 60, - keyPrefix: 'megabox-movies-v3', + keyPrefix: 'megabox-movies-v4', }, () => handleMegaboxListNowShowing(c), ), @@ -42,7 +42,7 @@ export function registerMegaboxRoutes(app: Hono<{ Bindings: AppBindings }>): voi { ttlSeconds: 60 * 3, staleWhileRevalidateSeconds: 30, - keyPrefix: 'megabox-seats-v3', + keyPrefix: 'megabox-seats-v4', }, () => handleMegaboxGetRemainingSeats(c), ), diff --git a/src/pages/openapiSpecPathsDaiso.ts b/src/pages/openapiSpecPathsDaiso.ts index 568fd3f8..63bfb4e3 100644 --- a/src/pages/openapiSpecPathsDaiso.ts +++ b/src/pages/openapiSpecPathsDaiso.ts @@ -30,7 +30,7 @@ export const OPENAPI_PATHS_DAISO = { in: 'query', required: false, description: '페이지당 결과 수', - schema: { type: 'integer', default: 30, minimum: 1, maximum: 100 }, + schema: { type: 'integer', default: 30, minimum: 1 }, }, ], responses: { @@ -43,7 +43,7 @@ export const OPENAPI_PATHS_DAISO = { }, }, '400': { - description: '잘못된 요청 (검색어 누락)', + description: '잘못된 요청 (검색어 누락 또는 유효하지 않은 페이지)', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' }, @@ -131,7 +131,7 @@ export const OPENAPI_PATHS_DAISO = { in: 'query', required: false, description: '최대 결과 수', - schema: { type: 'integer', default: 50, minimum: 1, maximum: 100 }, + schema: { type: 'integer', default: 50, minimum: 1 }, }, ], responses: { @@ -173,16 +173,16 @@ export const OPENAPI_PATHS_DAISO = { name: 'lat', in: 'query', required: false, - description: '위도 (기본값: 서울 시청 37.5665)', - schema: { type: 'number', format: 'float', default: 37.5665 }, + description: '경도와 함께 입력하는 위도. 둘 다 생략하면 서울 시청 37.5665를 사용합니다.', + schema: { type: 'number', format: 'float', minimum: -90, maximum: 90 }, example: 37.4979, }, { name: 'lng', in: 'query', required: false, - description: '경도 (기본값: 서울 시청 126.978)', - schema: { type: 'number', format: 'float', default: 126.978 }, + description: '위도와 함께 입력하는 경도. 둘 다 생략하면 서울 시청 126.978을 사용합니다.', + schema: { type: 'number', format: 'float', minimum: -180, maximum: 180 }, example: 127.0276, }, { @@ -204,7 +204,7 @@ export const OPENAPI_PATHS_DAISO = { in: 'query', required: false, description: '페이지당 결과 수', - schema: { type: 'integer', default: 30, minimum: 1, maximum: 100 }, + schema: { type: 'integer', default: 30, minimum: 1 }, }, ], responses: { @@ -217,7 +217,7 @@ export const OPENAPI_PATHS_DAISO = { }, }, '400': { - description: '잘못된 요청 (제품 ID 누락)', + description: '잘못된 요청 (제품 ID 누락 또는 유효하지 않은 페이지·좌표)', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' }, diff --git a/src/services/daiso/tools/checkInventory.ts b/src/services/daiso/tools/checkInventory.ts index 6deb8217..221c379d 100644 --- a/src/services/daiso/tools/checkInventory.ts +++ b/src/services/daiso/tools/checkInventory.ts @@ -6,6 +6,13 @@ import { parseInventoryQuantity } from '../../inventoryQuantity.js'; import * as z from 'zod'; +import { + positiveIntegerSchema, + latitudeSchema, + longitudeSchema, + validatePagination, + resolveCoordinates, +} from '../validation.js'; import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; import type { ProductSummary, @@ -66,8 +73,10 @@ export async function fetchStoreInventory( lng: number, page: number = 1, pageSize: number = 30, - keyword: string = '' + keyword: string = '', ): Promise<{ stores: StoreInventory[]; totalCount: number }> { + validatePagination(page, pageSize); + resolveCoordinates(lat, lng); const searchKeywords = keyword ? buildDaisoStoreKeywordVariants(keyword) : ['']; for (const searchKeyword of searchKeywords) { @@ -84,13 +93,14 @@ export async function fetchStoreInventory( if (storeSearch.success === false) throw new Error('다이소 매장 검색에 실패했습니다.'); const allStores = storeSearch.data || []; - const searchedStores = allStores.slice((page - 1) * pageSize, page * pageSize); - if (searchedStores.length === 0) { + if (allStores.length === 0) { if (searchKeyword === searchKeywords[searchKeywords.length - 1]) { return { stores: [], totalCount: 0 }; } continue; } + const searchedStores = allStores.slice((page - 1) * pageSize, page * pageSize); + if (searchedStores.length === 0) return { stores: [], totalCount: allStores.length }; const inventoryResponse = await fetchDaisoJsonWithAuth( DAISOMALL_API.STORE_INVENTORY_V2, @@ -106,7 +116,8 @@ export async function fetchStoreInventory( }, ); - if (inventoryResponse.success === false) throw new Error('다이소 매장 재고 조회에 실패했습니다.'); + if (inventoryResponse.success === false) + throw new Error('다이소 매장 재고 조회에 실패했습니다.'); const quantities = new Map( (inventoryResponse.data || []).map((item) => [item.strCd, parseInventoryQuantity(item.stck)]), ); @@ -150,8 +161,8 @@ async function checkInventory(args: CheckInventoryArgs): Promise Promise, diff --git a/src/services/daiso/tools/findInventoryByName.ts b/src/services/daiso/tools/findInventoryByName.ts index 5c7c5ad9..32649784 100644 --- a/src/services/daiso/tools/findInventoryByName.ts +++ b/src/services/daiso/tools/findInventoryByName.ts @@ -5,6 +5,14 @@ */ import * as z from 'zod'; +import { + positiveIntegerSchema, + latitudeSchema, + longitudeSchema, + validatePagination, + validatePositiveInteger, + resolveCoordinates, +} from '../validation.js'; import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; import type { Product, ProductSummary } from '../types.js'; import { fetchProducts } from './searchProducts.js'; @@ -42,7 +50,8 @@ function buildEmptySummary(query: string, storeQuery: string): Record ({ @@ -149,6 +162,9 @@ async function findInventoryByName(args: FindInventoryByNameArgs): Promise { const searchKeywords = - keyword && !sido && !gugun && !dong - ? buildDaisoStoreKeywordVariants(keyword) - : [keyword || '']; + keyword && !sido && !gugun && !dong ? buildDaisoStoreKeywordVariants(keyword) : [keyword || '']; for (const searchKeyword of searchKeywords) { const url = new URL(DAISO_WEB_API.SHOP_SEARCH); @@ -160,6 +159,7 @@ async function findStores(args: FindStoresArgs): Promise { throw new Error('검색어(keyword) 또는 지역(sido)을 입력해주세요.'); } + validatePositiveInteger(limit, 'limit'); const stores = await fetchStores(keyword, sido, gugun, dong); const limitedStores = stores.slice(0, limit); @@ -190,7 +190,10 @@ export function createFindStoresTool(): ToolRegistration { sido: z.string().optional().describe('시/도 (예: 서울, 경기, 부산)'), gugun: z.string().optional().describe('구/군 (예: 강남구, 마포구)'), dong: z.string().optional().describe('동 (예: 역삼동, 합정동)'), - limit: z.number().optional().default(50).describe('반환할 최대 매장 수 (기본값: 50)'), + limit: positiveIntegerSchema + .optional() + .default(50) + .describe('반환할 최대 매장 수 (기본값: 50)'), }, }, handler: findStores as (args: unknown) => Promise, diff --git a/src/services/daiso/tools/searchProducts.ts b/src/services/daiso/tools/searchProducts.ts index c951e677..d67743ff 100644 --- a/src/services/daiso/tools/searchProducts.ts +++ b/src/services/daiso/tools/searchProducts.ts @@ -5,6 +5,7 @@ */ import * as z from 'zod'; +import { positiveIntegerSchema, validatePagination } from '../validation.js'; import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; import type { Product, ProductSearchResponse } from '../types.js'; import { DAISOMALL_API, getImageUrl } from '../api.js'; @@ -23,8 +24,9 @@ interface SearchProductsArgs { export async function fetchProducts( query: string, page: number = 1, - pageSize: number = 30 + pageSize: number = 30, ): Promise<{ products: Product[]; totalCount: number }> { + validatePagination(page, pageSize); const url = new URL(DAISOMALL_API.SEARCH_PRODUCTS); url.searchParams.set('searchTerm', query); url.searchParams.set('cntPerPage', pageSize.toString()); @@ -91,8 +93,11 @@ export function createSearchProductsTool(): ToolRegistration { description: '다이소 제품을 검색합니다. 키워드로 제품을 검색할 수 있습니다.', inputSchema: { query: z.string().describe('검색할 제품명 또는 키워드'), - page: z.number().optional().default(1).describe('페이지 번호 (기본값: 1)'), - pageSize: z.number().optional().default(30).describe('페이지당 결과 수 (기본값: 30)'), + page: positiveIntegerSchema.optional().default(1).describe('페이지 번호 (기본값: 1)'), + pageSize: positiveIntegerSchema + .optional() + .default(30) + .describe('페이지당 결과 수 (기본값: 30)'), }, }, handler: searchProducts as (args: unknown) => Promise, diff --git a/src/services/daiso/validation.ts b/src/services/daiso/validation.ts new file mode 100644 index 00000000..582fc129 --- /dev/null +++ b/src/services/daiso/validation.ts @@ -0,0 +1,42 @@ +/** 다이소 REST/MCP 공통 입력 검증 */ +import * as z from 'zod'; + +export const positiveIntegerSchema = z.number().finite().int().positive(); +export const latitudeSchema = z.number().finite().min(-90).max(90); +export const longitudeSchema = z.number().finite().min(-180).max(180); + +export class DaisoValidationError extends Error {} + +export function validatePositiveInteger(value: number, name: string): void { + if (!positiveIntegerSchema.safeParse(value).success) { + throw new DaisoValidationError(`${name}는 유한한 양의 정수여야 합니다.`); + } +} + +export function validatePagination(page: number, pageSize: number): void { + validatePositiveInteger(page, 'page'); + validatePositiveInteger(pageSize, 'pageSize'); +} + +export function resolveCoordinates(latitude?: number, longitude?: number) { + if (latitude === undefined && longitude === undefined) { + return { latitude: 37.5665, longitude: 126.978 }; + } + if ( + !latitudeSchema.safeParse(latitude).success || + !longitudeSchema.safeParse(longitude).success + ) { + throw new DaisoValidationError( + '위도(-90~90)와 경도(-180~180)를 유한한 숫자로 함께 입력해주세요.', + ); + } + return { latitude: latitude as number, longitude: longitude as number }; +} + +export function parseNumericQuery( + value: string | undefined, + fallback?: number, +): number | undefined { + if (value === undefined) return fallback; + return value.trim().length === 0 ? NaN : Number(value); +} diff --git a/src/services/megabox/client.ts b/src/services/megabox/client.ts index 3770a004..65c99924 100644 --- a/src/services/megabox/client.ts +++ b/src/services/megabox/client.ts @@ -21,6 +21,26 @@ interface FetchBookingListParams { timeout?: number; } +function decodeHtmlEntities(value: string): string { + const named: Record = { + amp: '&', + quot: '"', + apos: "'", + lt: '<', + gt: '>', + nbsp: ' ', + }; + return value.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (entity: string, code: string) => { + if (!code.startsWith('#')) { + const name = code.toLowerCase(); + return Object.hasOwn(named, name) ? named[name] : entity; + } + const hex = code[1].toLowerCase() === 'x'; + const point = parseInt(code.slice(hex ? 2 : 1), hex ? 16 : 10); + return point <= 0x10ffff ? String.fromCodePoint(point) : entity; + }); +} + export async function fetchMegaboxBookingList( params: FetchBookingListParams, ): Promise<{ theaters: MegaboxTheater[]; movies: MegaboxMovie[]; showtimes: MegaboxShowtime[] }> { @@ -66,14 +86,14 @@ export async function fetchMegaboxBookingList( .filter((item) => item.brchNo && item.brchNm) .map((item) => ({ theaterId: item.brchNo as string, - theaterName: item.brchNm as string, + theaterName: decodeHtmlEntities(item.brchNm as string), })); const movies = (body.movieList || []) .filter((item) => item.movieNo && item.movieNm) .map((item) => ({ movieId: item.movieNo as string, - movieName: item.movieNm as string, + movieName: decodeHtmlEntities(item.movieNm as string), movieStatus: item.movieStatCdNm || undefined, })); @@ -82,9 +102,9 @@ export async function fetchMegaboxBookingList( .map((item) => ({ scheduleId: item.playSchdlNo as string, movieId: item.movieNo as string, - movieName: item.movieNm || '', + movieName: decodeHtmlEntities(item.movieNm || ''), theaterId: item.brchNo as string, - theaterName: item.brchNm || '', + theaterName: decodeHtmlEntities(item.brchNm || ''), playDate: item.playDe || params.playDate, startTime: formatTime(item.playStartTime), endTime: formatTime(item.playEndTime), @@ -118,14 +138,22 @@ function parseCoordinates(html: string): { latitude: number | null; longitude: n } function parseAddress(html: string): string { + // 공식 지점 페이지는 도로명주소를 라벨 span 뒤의 li 본문에 제공합니다. + const currentRoadAddress = html.match( + /
  • \s*]*>도로명주소\s*:\s*<\/span>([^<]*)<\/li>/i, + ); + if (currentRoadAddress) { + return decodeHtmlEntities(currentRoadAddress[1]).trim(); + } + const roadAddressMatch = html.match(/도로명주소<\/dt>\s*
    ([^<]+)<\/dd>/i); if (roadAddressMatch) { - return roadAddressMatch[1].trim(); + return decodeHtmlEntities(roadAddressMatch[1]).trim(); } const addressMatch = html.match(/주소<\/dt>\s*
    ([^<]+)<\/dd>/i); if (addressMatch) { - return addressMatch[1].trim(); + return decodeHtmlEntities(addressMatch[1]).trim(); } return ''; diff --git a/tests/api/daiso-cache-version.test.ts b/tests/api/daiso-cache-version.test.ts new file mode 100644 index 00000000..7247e514 --- /dev/null +++ b/tests/api/daiso-cache-version.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; +import { registerDaisoRoutes } from '../../src/api/routes/daisoRoutes.js'; +const { cache } = vi.hoisted(() => ({ cache: vi.fn() })); +vi.mock('../../src/utils/cache.js', () => ({ withEdgeCache: cache })); + +describe('다이소 입력 검증 캐시 갱신', () => { + it.each([['products?q=test', 'daiso-products-v3'], ['stores?keyword=test', 'daiso-stores-v3'], ['inventory?productId=1', 'daiso-inventory-v3']])('%s 이전 캐시를 재사용하지 않는다', async (path, keyPrefix) => { + cache.mockReset().mockResolvedValue(new Response('{}')); + const app = new Hono(); + registerDaisoRoutes(app); + await app.request(`/api/daiso/${path}`); + expect(cache).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ keyPrefix }), expect.any(Function)); + }); +}); diff --git a/tests/fixtures/megabox/booking-theaters.json b/tests/fixtures/megabox/booking-theaters.json new file mode 100644 index 00000000..99cc33ce --- /dev/null +++ b/tests/fixtures/megabox/booking-theaters.json @@ -0,0 +1,67 @@ +{ + "areaBrchList": [ + { + "playDe": "20261006", + "areaCd": "55", + "areaCdNm": "부산/대구/경상", + "areaCdEngNm": "부산/대구/경상", + "areaBrchCnt": 25, + "formBrchCnt": 25, + "areaOnlineExpoAt": "N", + "brchNo": "0061", + "brchNm": "부산대", + "brchEngNm": "Busan ​​National University", + "brchFormAt": "Y", + "brchOnlineExpoAt": "N", + "brchOnlineExpoStatCd": "OES02", + "brchOnlineExpoStatCdNm": "리뉴얼오픈", + "brchBokdUnableAt": "N", + "brchPopupAt": "Y", + "brchPopupNo": 2401, + "brchFavorAt": "N", + "spcExistsAt": "N" + }, + { + "playDe": "20261006", + "areaCd": "55", + "areaCdNm": "부산/대구/경상", + "areaCdEngNm": "부산/대구/경상", + "areaBrchCnt": 25, + "formBrchCnt": 25, + "areaOnlineExpoAt": "N", + "brchNo": "0082", + "brchNm": "서면대한", + "brchEngNm": "Seomyeon Daehan", + "brchFormAt": "Y", + "brchOnlineExpoAt": "N", + "brchOnlineExpoStatCd": "OES01", + "brchOnlineExpoStatCdNm": "신규오픈", + "brchBokdUnableAt": "N", + "brchPopupAt": "N", + "brchPopupNo": 0, + "brchFavorAt": "N", + "spcExistsAt": "N" + }, + { + "playDe": "20261006", + "areaCd": "55", + "areaCdNm": "부산/대구/경상", + "areaCdEngNm": "부산/대구/경상", + "areaBrchCnt": 25, + "formBrchCnt": 25, + "areaOnlineExpoAt": "N", + "brchNo": "0079", + "brchNm": "해운대(장산)", + "brchEngNm": "Haeundae(Jangsan)", + "brchFormAt": "Y", + "brchOnlineExpoAt": "N", + "brchOnlineExpoStatCd": "OES01", + "brchOnlineExpoStatCdNm": "신규오픈", + "brchBokdUnableAt": "N", + "brchPopupAt": "N", + "brchPopupNo": 0, + "brchFavorAt": "N", + "spcExistsAt": "N" + } + ] +} diff --git a/tests/fixtures/megabox/theater-0061-location.html b/tests/fixtures/megabox/theater-0061-location.html new file mode 100644 index 00000000..81bacfb0 --- /dev/null +++ b/tests/fixtures/megabox/theater-0061-location.html @@ -0,0 +1,11 @@ + +
      +
    • 도로명주소 : 부산광역시 금정구 장전로 12번길 55, (장전동) 라퓨타 아일랜드 4층
    • + +
    + + diff --git a/tests/fixtures/megabox/theater-0079-location.html b/tests/fixtures/megabox/theater-0079-location.html new file mode 100644 index 00000000..d0a1e60f --- /dev/null +++ b/tests/fixtures/megabox/theater-0079-location.html @@ -0,0 +1,11 @@ + +
      +
    • 도로명주소 : 부산광역시 해운대구 해운대로 813 (좌동, NC백화점 8층) 메가박스 해운대장산지점
    • + +
    + + diff --git a/tests/fixtures/megabox/theater-0082-location.html b/tests/fixtures/megabox/theater-0082-location.html new file mode 100644 index 00000000..8b7c0fed --- /dev/null +++ b/tests/fixtures/megabox/theater-0082-location.html @@ -0,0 +1,11 @@ + +
      +
    • 도로명주소 : 부산광역시 부산진구 중앙대로692번길 16(부전동), 대한프라자 3층
    • + +
    + + diff --git a/tests/pages/openapi-daiso-boundaries.test.ts b/tests/pages/openapi-daiso-boundaries.test.ts new file mode 100644 index 00000000..0025926b --- /dev/null +++ b/tests/pages/openapi-daiso-boundaries.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { OPENAPI_PATHS_DAISO } from '../../src/pages/openapiSpecPathsDaiso.js'; + +describe('다이소 공개 입력 계약', () => { + it('좌표 범위와 두 값 동시 생략 기본값을 설명하고 개별 기본값은 주입하지 않는다', () => { + const params = OPENAPI_PATHS_DAISO['/api/daiso/inventory'].get.parameters; + const lat = params.find((p) => p.name === 'lat')!; + const lng = params.find((p) => p.name === 'lng')!; + expect(lat.schema).toMatchObject({ minimum: -90, maximum: 90 }); + expect(lng.schema).toMatchObject({ minimum: -180, maximum: 180 }); + expect(lat.schema).not.toHaveProperty('default'); + expect(lng.schema).not.toHaveProperty('default'); + expect(lat.description).toContain('함께'); + expect(lng.description).toContain('함께'); + }); + + it('페이지와 매장 개수는 양의 정수이며 구현에 없는 100 상한을 광고하지 않는다', () => { + for (const path of ['/api/daiso/products', '/api/daiso/stores', '/api/daiso/inventory'] as const) { + for (const param of OPENAPI_PATHS_DAISO[path].get.parameters) { + if (!['page', 'pageSize', 'limit'].includes(param.name)) continue; + expect(param.schema).toMatchObject({ type: 'integer', minimum: 1 }); + expect(param.schema).not.toHaveProperty('maximum'); + } + } + }); +}); diff --git a/tests/services/daiso/boundaries.test.ts b/tests/services/daiso/boundaries.test.ts new file mode 100644 index 00000000..5056afec --- /dev/null +++ b/tests/services/daiso/boundaries.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as z from 'zod'; +import { fetchProducts, createSearchProductsTool } from '../../../src/services/daiso/tools/searchProducts.js'; +import { fetchStoreInventory, createCheckInventoryTool } from '../../../src/services/daiso/tools/checkInventory.js'; +import { createFindInventoryByNameTool } from '../../../src/services/daiso/tools/findInventoryByName.js'; +import { createFindStoresTool } from '../../../src/services/daiso/tools/findStores.js'; +import { handleSearchProducts, handleFindStores, handleCheckInventory } from '../../../src/api/daisoHandlers.js'; +import { createMockContext } from '../../api/testHelpers.js'; + +const { json, auth, html } = vi.hoisted(() => ({ json: vi.fn(), auth: vi.fn(), html: vi.fn() })); +vi.mock('../../../src/services/daiso/client.js', () => ({ fetchDaisoJson: json, fetchDaisoJsonWithAuth: auth, fetchDaisoHtml: html })); +beforeEach(() => { + vi.resetAllMocks(); + json.mockResolvedValue({ data: [], resultSet: { result: [{ resultDocuments: [], totalSize: 0 }] } }); + html.mockResolvedValue(''); +}); +const invalidNumbers = [0, -1, 1.5, NaN, Infinity, -Infinity]; +const badQueries = ['0', '-1', '1.5', 'bad', '2abc', 'Infinity', '', ' ']; + +describe('다이소 숫자 경계', () => { + for (const field of ['page', 'pageSize'] as const) { + it.each(invalidNumbers)(`상품 검색 ${field}=%s 거부`, async (value) => { + await expect(fetchProducts('컵', field === 'page' ? value : 1, field === 'pageSize' ? value : 30)).rejects.toThrow(); + expect(json).not.toHaveBeenCalled(); + }); + it.each(badQueries)(`REST 검색 ${field}=%s 거부`, async (value) => { + const c = createMockContext({ q: '컵', [field]: value }); + await handleSearchProducts(c); + expect(c.json).toHaveBeenCalledWith(expect.objectContaining({ success: false }), 400); + expect(json).not.toHaveBeenCalled(); + }); + it.each(badQueries)(`REST 재고 ${field}=%s 거부`, async (value) => { + const c = createMockContext({ productId: '1', [field]: value }); + await handleCheckInventory(c); + expect(c.json).toHaveBeenCalledWith(expect.objectContaining({ success: false }), 400); + expect(json).not.toHaveBeenCalled(); + }); + } + it.each(badQueries)('REST 매장 limit=%s 거부', async (limit) => { + const c = createMockContext({ keyword: '강남', limit }); + await handleFindStores(c); + expect(c.json).toHaveBeenCalledWith(expect.objectContaining({ success: false }), 400); + expect(html).not.toHaveBeenCalled(); + }); + const tools = [ + [createSearchProductsTool(), { query: '컵' }, ['page', 'pageSize']], + [createCheckInventoryTool(), { productId: '1' }, ['page', 'pageSize']], + [createFindInventoryByNameTool(), { query: '컵' }, ['page', 'pageSize', 'productLimit']], + [createFindStoresTool(), { keyword: '강남' }, ['limit']], + ] as const; + for (const [tool, base, fields] of tools) { + for (const field of fields) { + it.each(invalidNumbers)(`${tool.name} ${field}=%s 직접 호출/스키마 거부`, async (value) => { + await expect(tool.handler({ ...base, [field]: value })).rejects.toThrow(); + expect(z.object(tool.metadata.inputSchema).safeParse({ ...base, [field]: value }).success).toBe(false); + expect(json).not.toHaveBeenCalled(); + expect(html).not.toHaveBeenCalled(); + }); + } + } +}); + +describe('다이소 위치 경계', () => { + const coordinates = [ + { latitude: 91, longitude: 127 }, { latitude: -91, longitude: 127 }, + { latitude: 37, longitude: 181 }, { latitude: 37, longitude: -181 }, + { latitude: NaN, longitude: 127 }, { latitude: 37, longitude: Infinity }, + { latitude: Infinity, longitude: 127 }, { latitude: 37, longitude: NaN }, + { latitude: 37 }, { longitude: 127 }, + ]; + for (const tool of [createCheckInventoryTool(), createFindInventoryByNameTool()]) { + it.each(coordinates)(`${tool.name} 잘못된 위치 %j 거부`, async (location) => { + await expect(tool.handler({ productId: '1', query: '컵', ...location })).rejects.toThrow(); + expect(json).not.toHaveBeenCalled(); + expect(html).not.toHaveBeenCalled(); + }); + } + it.each([{ lat: '91', lng: '127' }, { lat: '37', lng: '181' }, { lat: 'NaN', lng: '127' }, { lat: '37abc', lng: '127' }, { lat: '', lng: '127' }, { lat: '37' }, { lng: '127' }])('REST 잘못된 위치 %j 거부', async (location) => { + const c = createMockContext({ productId: '1', ...location }); + await handleCheckInventory(c); + expect(c.json).toHaveBeenCalledWith(expect.objectContaining({ success: false }), 400); + expect(json).not.toHaveBeenCalled(); + }); + it.each([[91, 127, 1, 30], [37, 181, 1, 30], [37, 127, 0, 30], [37, 127, 1, 1.5]])('직접 재고 조회 경계 거부 %j', async (lat, lng, page, pageSize) => { + await expect(fetchStoreInventory('1', lat, lng, page, pageSize)).rejects.toThrow(); + expect(json).not.toHaveBeenCalled(); + }); + it('기본 위치를 보존하며 큰 유효 페이지를 허용한다', async () => { + const result = await createCheckInventoryTool().handler({ productId: '1', page: 999, pageSize: 1000 }); + expect(JSON.parse(result.content[0].text as string).location).toEqual({ latitude: 37.5665, longitude: 126.978 }); + expect(json).toHaveBeenCalledWith(expect.stringContaining('/ms/msg/selStr'), expect.objectContaining({ body: expect.stringContaining('37.5665') })); + }); + it('MCP 스키마를 거친 반쪽 위치도 거부한다', async () => { + const tool = createCheckInventoryTool(); + const args = z.object(tool.metadata.inputSchema).parse({ productId: '1', latitude: 37 }); + await expect(tool.handler(args)).rejects.toThrow(); + expect(json).not.toHaveBeenCalled(); + }); +}); + +describe('다이소 마지막 페이지 이후', () => { + it.each(['', '안산 중앙역'])('검색어 %s 결과의 총수를 보존하고 변형으로 넘어가지 않는다', async (keyword) => { + json.mockResolvedValue({ data: Array.from({ length: 23 }, (_, i) => ({ strCd: String(i) })) }); + expect(await fetchStoreInventory('1', 37, 127, 999, 30, keyword)).toEqual({ stores: [], totalCount: 23 }); + expect(json).toHaveBeenCalledTimes(1); + expect(auth).not.toHaveBeenCalled(); + }); + it('첫 변형 결과가 없으면 다음 변형의 총수를 보존한다', async () => { + json.mockResolvedValueOnce({ data: [] }).mockResolvedValueOnce({ data: [{ strCd: '1' }] }); + expect(await fetchStoreInventory('1', 37, 127, 999, 30, '안산 중앙역')).toEqual({ stores: [], totalCount: 1 }); + expect(json).toHaveBeenCalledTimes(2); + expect(auth).not.toHaveBeenCalled(); + }); +}); + +describe('다이소 유효 경계와 upstream 응답', () => { + it.each([[-90, -180], [90, 180], [0, 0]])('좌표 경계 %s/%s 허용', async (latitude, longitude) => { + const c = createMockContext({ productId: '1', lat: String(latitude), lng: String(longitude) }); + await handleCheckInventory(c); + expect(c.json).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ location: { latitude, longitude } }) })); + }); + it('상품명 조회의 기본 위치 유지', async () => { + json.mockImplementation(async (url: string) => url.includes('FindStoreGoods') + ? { resultSet: { result: [{ resultDocuments: [{ PD_NO: '1', PDNM: '컵', PD_PRC: '1000' }], totalSize: 1 }] } } + : { data: [] }); + const result = await createFindInventoryByNameTool().handler({ query: '컵' }); + expect(JSON.parse(result.content[0].text as string).location).toEqual({ latitude: 37.5665, longitude: 126.978, source: 'default' }); + }); + it('누락된 매장 데이터는 빈 결과', async () => { + json.mockResolvedValue({ success: true }); + expect(await fetchStoreInventory('1', 37, 127)).toEqual({ stores: [], totalCount: 0 }); + }); + it('명시적 매장 검색 실패를 성공으로 바꾸지 않는다', async () => { + json.mockResolvedValue({ success: false }); + await expect(fetchStoreInventory('1', 37, 127)).rejects.toThrow('매장 검색'); + }); + it('명시적 재고 조회 실패를 성공으로 바꾸지 않는다', async () => { + json.mockResolvedValue({ data: [{ strCd: '1' }] }); + auth.mockResolvedValue({ success: false }); + await expect(fetchStoreInventory('1', 37, 127)).rejects.toThrow('매장 재고'); + }); + it('재고 미확인 매장을 양수 재고에 포함하지 않는다', async () => { + json.mockResolvedValue({ data: [{ strCd: '1' }] }); + auth.mockResolvedValue({ data: [] }); + const result = await createCheckInventoryTool().handler({ productId: '1' }); + expect(JSON.parse(result.content[0].text as string).storeInventory).toEqual(expect.objectContaining({ totalStores: 1, inStockCount: 0, unknownStockCount: 1 })); + }); +}); diff --git a/tests/services/megabox/display.test.ts b/tests/services/megabox/display.test.ts new file mode 100644 index 00000000..91fa591d --- /dev/null +++ b/tests/services/megabox/display.test.ts @@ -0,0 +1,164 @@ +/** 메가박스 공식 응답의 표시 문자열 회귀 테스트 */ +import { readFileSync } from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + fetchMegaboxBookingList, + fetchMegaboxTheaterInfo, +} from '../../../src/services/megabox/client.js'; +import { + fetchMegaboxNearbyTheaters, + resolveMegaboxLocation, +} from '../../../src/services/megabox/location.js'; +import { createListNowShowingTool } from '../../../src/services/megabox/tools/listNowShowing.js'; +import { createGetRemainingSeatsTool } from '../../../src/services/megabox/tools/getRemainingSeats.js'; + +const mockFetch = vi.fn(); +const fixture = (name: string) => + readFileSync(new URL(`../../fixtures/megabox/${name}`, import.meta.url), 'utf8'); +beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockFetch.mockReset(); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe('공식 위치 응답', () => { + it.each([ + [ + '0079', + '부산광역시 해운대구 해운대로 813 (좌동, NC백화점 8층) 메가박스 해운대장산지점', + 35.1705551, + 129.177058, + ], + [ + '0082', + '부산광역시 부산진구 중앙대로692번길 16(부전동), 대한프라자 3층', + 35.1542007, + 129.0600191, + ], + [ + '0061', + '부산광역시 금정구 장전로 12번길 55, (장전동) 라퓨타 아일랜드 4층', + 35.2301274, + 129.0880723, + ], + ])('%s 지점의 실제 주소와 좌표를 반환한다', async (id, address, latitude, longitude) => { + mockFetch.mockResolvedValue(new Response(fixture(`theater-${id}-location.html`))); + expect(await fetchMegaboxTheaterInfo(id as string)).toEqual({ + theaterId: id, + address, + latitude, + longitude, + }); + }); + + it('근처 극장 응답도 표시 이름과 공식 주소를 제공한다', async () => { + mockFetch.mockResolvedValueOnce(new Response(fixture('booking-theaters.json'))); + for (const id of ['0061', '0082', '0079']) + mockFetch.mockResolvedValueOnce(new Response(fixture(`theater-${id}-location.html`))); + const result = await fetchMegaboxNearbyTheaters({ + latitude: 35.1631, + longitude: 129.1635, + playDate: '20261006', + }); + expect(result.theaters.find((v) => v.theaterId === '0079')).toMatchObject({ + theaterName: '해운대(장산)', + address: '부산광역시 해운대구 해운대로 813 (좌동, NC백화점 8층) 메가박스 해운대장산지점', + }); + expect(result.theaters.every((v) => v.address.length > 0)).toBe(true); + }); + + it('위치 원문이 없으면 주소를 만들지 않으며 근처 극장에서도 제외한다', async () => { + mockFetch.mockResolvedValueOnce(new Response('
  • 교통안내 주소 없음
  • ')); + expect(await fetchMegaboxTheaterInfo('missing')).toEqual({ + theaterId: 'missing', + address: '', + latitude: null, + longitude: null, + }); + mockFetch + .mockResolvedValueOnce( + new Response(JSON.stringify({ areaBrchList: [{ brchNo: 'missing', brchNm: '극장' }] })), + ) + .mockResolvedValueOnce(new Response('
    위치 없음
    ')); + expect((await fetchMegaboxNearbyTheaters({ playDate: '20261006' })).theaters).toEqual([]); + }); + + it('한국 밖 좌표는 조회 전에 거절한다', async () => { + await expect(resolveMegaboxLocation({ latitude: 1, longitude: 1 })).rejects.toThrow( + '유효한 위도/경도', + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it.each([createListNowShowingTool, createGetRemainingSeatsTool])( + '좌표 없는 지점만 있으면 위치 기반 도구가 실패한다', + async (createTool) => { + mockFetch + .mockResolvedValueOnce( + new Response(JSON.stringify({ areaBrchList: [{ brchNo: 'missing', brchNm: '극장' }] })), + ) + .mockResolvedValueOnce(new Response('
    위치 없음
    ')); + await expect( + createTool().handler({ playDate: '20261006', latitude: 35.1631, longitude: 129.1635 }), + ).rejects.toThrow('요청 위치의 메가박스 극장을 찾을 수 없습니다.'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }, + ); + + it.each([ + ['
    도로명주소
    서울 & 경기
    주소
    다른 주소
    ', '서울 & 경기'], + ['
    주소
    서울 (중구)
    ', '서울 (중구)'], + ['
  • 도로명주소 : 부산 해운대구
  • ', '부산 해운대구'], + ])('기존 주소 형식과 엔티티를 보존한다', async (html, address) => { + mockFetch.mockResolvedValue(new Response(html)); + expect((await fetchMegaboxTheaterInfo('legacy')).address).toBe(address); + }); +}); + +describe('상영 목록 표시 이름', () => { + it('지원하는 엔티티는 해제하고 알 수 없거나 범위를 벗어난 엔티티는 보존한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + movieList: [ + { + movieNo: 'M1', + movieNm: '<영화> 'A' 🎬 &unknown; &constructor; �', + }, + ], + }), + ), + ); + expect((await fetchMegaboxBookingList({ playDate: '20261006' })).movies[0].movieName).toBe( + "<영화> 'A' 🎬 &unknown; &constructor; �", + ); + }); + it('극장과 영화, 상영시간 이름의 엔티티를 해제한다', async () => { + const theaterName = '해운대(장산)'; + const movieName = 'A & B (특별판) "영화" '2''; + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + areaBrchList: [{ brchNo: '0079', brchNm: theaterName }], + movieList: [{ movieNo: 'M1', movieNm: movieName }], + movieFormList: [ + { + playSchdlNo: 'S1', + movieNo: 'M1', + brchNo: '0079', + movieNm: movieName, + brchNm: theaterName, + }, + ], + }), + ), + ); + const result = await fetchMegaboxBookingList({ playDate: '20261006' }); + expect(result.theaters[0].theaterName).toBe('해운대(장산)'); + expect(result.movies[0].movieName).toBe('A & B (특별판) "영화" \'2\''); + expect(result.showtimes[0]).toMatchObject({ + theaterName: '해운대(장산)', + movieName: result.movies[0].movieName, + }); + }); +});