From 9af48126d2a6f88386ce5319a8df0e84fed29a0d Mon Sep 17 00:00:00 2001 From: betterthanhajin Date: Wed, 4 Mar 2026 18:56:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20CGV=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EB=B0=94=EC=9D=B4=EB=8D=94=20=EB=B0=8F=20?= =?UTF-8?q?=EB=8F=84=EA=B5=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cgv-network-analysis-result.md | 66 +++++++ src/api/cgvHandlers.ts | 123 +++++++++++++ src/api/routes/cgvRoutes.ts | 46 +++++ src/index.ts | 4 + src/pages/openapiSpec.ts | 3 + src/pages/openapiSpecComponents.ts | 118 +++++++++++++ src/pages/openapiSpecPathsCgv.ts | 150 ++++++++++++++++ src/pages/prompt.ts | 56 ++++++ src/services/cgv/api.ts | 10 ++ src/services/cgv/client.ts | 163 +++++++++++++++++ src/services/cgv/index.ts | 30 ++++ src/services/cgv/tools/findTheaters.ts | 56 ++++++ src/services/cgv/tools/getTimetable.ts | 76 ++++++++ src/services/cgv/tools/searchMovies.ts | 52 ++++++ src/services/cgv/types.ts | 71 ++++++++ tests/api/cgv-handlers.test.ts | 165 ++++++++++++++++++ tests/app/app-api-cgv.test.ts | 82 +++++++++ tests/app/app-info-pages.test.ts | 18 ++ tests/pages/openapi.test.ts | 3 + tests/pages/prompt.test.ts | 11 ++ tests/services/cgv/client.test.ts | 150 ++++++++++++++++ tests/services/cgv/index.test.ts | 37 ++++ tests/services/cgv/tools/findTheaters.test.ts | 49 ++++++ tests/services/cgv/tools/getTimetable.test.ts | 83 +++++++++ tests/services/cgv/tools/searchMovies.test.ts | 56 ++++++ 25 files changed, 1678 insertions(+) create mode 100644 docs/cgv-network-analysis-result.md create mode 100644 src/api/cgvHandlers.ts create mode 100644 src/api/routes/cgvRoutes.ts create mode 100644 src/pages/openapiSpecPathsCgv.ts create mode 100644 src/services/cgv/api.ts create mode 100644 src/services/cgv/client.ts create mode 100644 src/services/cgv/index.ts create mode 100644 src/services/cgv/tools/findTheaters.ts create mode 100644 src/services/cgv/tools/getTimetable.ts create mode 100644 src/services/cgv/tools/searchMovies.ts create mode 100644 src/services/cgv/types.ts create mode 100644 tests/api/cgv-handlers.test.ts create mode 100644 tests/app/app-api-cgv.test.ts create mode 100644 tests/services/cgv/client.test.ts create mode 100644 tests/services/cgv/index.test.ts create mode 100644 tests/services/cgv/tools/findTheaters.test.ts create mode 100644 tests/services/cgv/tools/getTimetable.test.ts create mode 100644 tests/services/cgv/tools/searchMovies.test.ts diff --git a/docs/cgv-network-analysis-result.md b/docs/cgv-network-analysis-result.md new file mode 100644 index 0000000..af07fb7 --- /dev/null +++ b/docs/cgv-network-analysis-result.md @@ -0,0 +1,66 @@ +# CGV 네트워크 분석 결과 (1차 구현용) + +작성일: 2026-03-04 (KST) +대상: `https://m.cgv.co.kr` + +## 목적 + +플러그인 아키텍처에 CGV 서비스를 1차 추가하기 위한 최소 엔드포인트를 정리합니다. + +## 1차 구현 기준 엔드포인트 + +- `POST /WebAPP/ReservationV5/Reservation.aspx/GetTheaterList` + - 사용 목적: 상영 가능 극장 목록 조회 + - 입력 필드(예시): `PlayYMD`, `AreaCd` + +- `POST /WebAPP/ReservationV5/Reservation.aspx/GetMovieList` + - 사용 목적: 날짜/극장 기준 영화 목록 조회 + - 입력 필드(예시): `PlayYMD`, `TheaterCd` + +- `POST /WebAPP/ReservationV5/Reservation.aspx/GetTimeTableList` + - 사용 목적: 날짜/극장/영화 기준 상영 시간표 조회 + - 입력 필드(예시): `PlayYMD`, `TheaterCd`, `MovieCd` + +## 응답 정규화 규칙 + +### 극장 목록 + +- 입력 필드: `TheaterCd`, `TheaterName`, `AreaCd` +- 정규화: `theaterCode`, `theaterName`, `regionCode` + +### 영화 목록 + +- 입력 필드: `MovieCd`, `MovieName`, `Grade` +- 정규화: `movieCode`, `movieName`, `rating` + +### 시간표 + +- 입력 필드: + - `ScheduleNo`, `MovieCd`, `MovieName` + - `TheaterCd`, `TheaterName` + - `PlayYmd`, `StartTime`, `EndTime` + - `TotalSeat`, `RemainSeat` +- 정규화: + - `scheduleId`, `movieCode`, `movieName` + - `theaterCode`, `theaterName` + - `playDate`, `startTime`, `endTime` + - `totalSeats`, `remainingSeats` + +## 도구 매핑 + +- `cgv_find_theaters`: 극장 목록 조회 +- `cgv_search_movies`: 영화 목록 조회 +- `cgv_get_timetable`: 시간표/잔여좌석 조회 + +## 1차 구현 범위 + +- MCP 도구 3종 등록 +- GET API 3종 제공 (`/api/cgv/theaters`, `/api/cgv/movies`, `/api/cgv/timetable`) +- OpenAPI/프롬프트 문서 반영 +- 단위 테스트 및 통합 테스트 추가 + +## 후속 개선 항목 + +- 지역 코드 매핑 테이블 고도화 +- 상영 포맷(2D/IMAX/4DX) 상세 필드 정규화 +- 실좌석 선택 단계 API 분석 및 좌석맵 제공 diff --git a/src/api/cgvHandlers.ts b/src/api/cgvHandlers.ts new file mode 100644 index 0000000..c0f946b --- /dev/null +++ b/src/api/cgvHandlers.ts @@ -0,0 +1,123 @@ +/** + * CGV GET API 핸들러 + */ + +import { fetchCgvMovies, fetchCgvTheaters, fetchCgvTimetable, toYyyymmdd } from '../services/cgv/client.js'; +import { type ApiContext, errorResponse, successResponse } from './response.js'; + +/** + * CGV 극장 목록 조회 API 핸들러 + * GET /api/cgv/theaters?playDate={YYYYMMDD}®ionCode={지역코드} + */ +export async function handleCgvFindTheaters(c: ApiContext) { + const playDate = c.req.query('playDate') || toYyyymmdd(); + const regionCode = c.req.query('regionCode') || undefined; + const limit = parseInt(c.req.query('limit') || '30'); + const timeoutMs = parseInt(c.req.query('timeoutMs') || '15000'); + + try { + const theaters = await fetchCgvTheaters({ + playDate, + regionCode, + timeout: timeoutMs, + }); + + const sliced = theaters.slice(0, limit); + + return successResponse( + c, + { + playDate, + filters: { + regionCode: regionCode || null, + }, + theaters: sliced, + }, + { total: sliced.length, pageSize: limit }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; + return errorResponse(c, 'CGV_THEATER_SEARCH_FAILED', message, 500); + } +} + +/** + * CGV 영화 목록 조회 API 핸들러 + * GET /api/cgv/movies?playDate={YYYYMMDD}&theaterCode={극장코드} + */ +export async function handleCgvSearchMovies(c: ApiContext) { + const playDate = c.req.query('playDate') || toYyyymmdd(); + const theaterCode = c.req.query('theaterCode') || undefined; + const timeoutMs = parseInt(c.req.query('timeoutMs') || '15000'); + + try { + const movies = await fetchCgvMovies({ + playDate, + theaterCode, + timeout: timeoutMs, + }); + + return successResponse( + c, + { + playDate, + filters: { + theaterCode: theaterCode || null, + }, + movies, + }, + { total: movies.length }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; + return errorResponse(c, 'CGV_MOVIE_SEARCH_FAILED', message, 500); + } +} + +/** + * CGV 시간표 조회 API 핸들러 + * GET /api/cgv/timetable?playDate={YYYYMMDD}&theaterCode={극장코드}&movieCode={영화코드} + */ +export async function handleCgvGetTimetable(c: ApiContext) { + const playDate = c.req.query('playDate') || toYyyymmdd(); + const theaterCode = c.req.query('theaterCode') || undefined; + const movieCode = c.req.query('movieCode') || undefined; + const limit = parseInt(c.req.query('limit') || '50'); + const timeoutMs = parseInt(c.req.query('timeoutMs') || '15000'); + + try { + const timetable = await fetchCgvTimetable({ + playDate, + theaterCode, + movieCode, + timeout: timeoutMs, + }); + + const filtered = timetable + .filter((item) => (theaterCode ? item.theaterCode === theaterCode : true)) + .filter((item) => (movieCode ? item.movieCode === movieCode : true)) + .sort((a, b) => { + if (a.startTime === b.startTime) { + return a.theaterName.localeCompare(b.theaterName); + } + return a.startTime.localeCompare(b.startTime); + }) + .slice(0, limit); + + return successResponse( + c, + { + playDate, + filters: { + theaterCode: theaterCode || null, + movieCode: movieCode || null, + }, + timetable: filtered, + }, + { total: filtered.length, pageSize: limit }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; + return errorResponse(c, 'CGV_TIMETABLE_FETCH_FAILED', message, 500); + } +} diff --git a/src/api/routes/cgvRoutes.ts b/src/api/routes/cgvRoutes.ts new file mode 100644 index 0000000..5723aef --- /dev/null +++ b/src/api/routes/cgvRoutes.ts @@ -0,0 +1,46 @@ +/** + * CGV GET API 라우트 등록 + */ + +import type { Hono } from 'hono'; +import { withEdgeCache } from '../../utils/cache.js'; +import { handleCgvFindTheaters, handleCgvGetTimetable, handleCgvSearchMovies } from '../cgvHandlers.js'; +import type { AppBindings } from '../response.js'; + +export function registerCgvRoutes(app: Hono<{ Bindings: AppBindings }>): void { + app.get('/api/cgv/theaters', async (c) => + withEdgeCache( + c.req.url, + { + ttlSeconds: 60 * 60 * 24, + staleWhileRevalidateSeconds: 60 * 5, + keyPrefix: 'cgv-theaters-v1', + }, + () => handleCgvFindTheaters(c), + ), + ); + + app.get('/api/cgv/movies', async (c) => + withEdgeCache( + c.req.url, + { + ttlSeconds: 60 * 10, + staleWhileRevalidateSeconds: 60, + keyPrefix: 'cgv-movies-v1', + }, + () => handleCgvSearchMovies(c), + ), + ); + + app.get('/api/cgv/timetable', async (c) => + withEdgeCache( + c.req.url, + { + ttlSeconds: 60 * 3, + staleWhileRevalidateSeconds: 30, + keyPrefix: 'cgv-timetable-v1', + }, + () => handleCgvGetTimetable(c), + ), + ); +} diff --git a/src/index.ts b/src/index.ts index 9d6184c..2e0b429 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { ServiceRegistry } from './core/registry.js'; import { createDaisoService } from './services/daiso/index.js'; import { createOliveyoungService } from './services/oliveyoung/index.js'; import { createMegaboxService } from './services/megabox/index.js'; +import { createCgvService } from './services/cgv/index.js'; import { createPromptResponse } from './pages/prompt.js'; import { createOpenApiJsonResponse, createOpenApiYamlResponse } from './pages/openapi.js'; import { createPrivacyResponse } from './pages/privacy.js'; @@ -21,6 +22,7 @@ import type { AppBindings } from './api/response.js'; import { registerDaisoRoutes } from './api/routes/daisoRoutes.js'; import { registerOliveyoungRoutes } from './api/routes/oliveyoungRoutes.js'; import { registerMegaboxRoutes } from './api/routes/megaboxRoutes.js'; +import { registerCgvRoutes } from './api/routes/cgvRoutes.js'; // 서버 메타데이터 const SERVER_NAME = 'multi-service-mcp'; @@ -35,6 +37,7 @@ const createRegistry = (bindings?: AppBindings) => { registry.registerAll([ createDaisoService, createMegaboxService, + createCgvService, () => createOliveyoungService({ zyteApiKey: bindings?.ZYTE_API_KEY, @@ -132,6 +135,7 @@ app.get('/privacy', (c) => { registerDaisoRoutes(app); registerOliveyoungRoutes(app); registerMegaboxRoutes(app); +registerCgvRoutes(app); // MCP 엔드포인트 app.all('/mcp', async (c) => { diff --git a/src/pages/openapiSpec.ts b/src/pages/openapiSpec.ts index a9e76ca..153af29 100644 --- a/src/pages/openapiSpec.ts +++ b/src/pages/openapiSpec.ts @@ -4,6 +4,7 @@ import { OPENAPI_PATHS_DAISO_OLIVEYOUNG } from './openapiSpecPathsDaisoOliveyoung.js'; import { OPENAPI_PATHS_MEGABOX } from './openapiSpecPathsMegabox.js'; +import { OPENAPI_PATHS_CGV } from './openapiSpecPathsCgv.js'; import { OPENAPI_COMPONENTS } from './openapiSpecComponents.js'; /** @@ -21,6 +22,7 @@ export function generateOpenApiSpec(baseUrl: string): object { - 🏪 **매장 찾기**: 지역/키워드로 매장 검색 - 📦 **재고 확인**: 온라인 및 오프라인 매장 재고 조회 - 🎬 **메가박스 지점/영화 조회**: 주변 지점, 상영 목록, 잔여 좌석 조회 +- 🎥 **CGV 지점/영화 조회**: 극장 목록, 영화 목록, 시간표 조회 ## 사용 팁 1. 한글 검색어는 URL 인코딩이 자동 처리됩니다 @@ -36,6 +38,7 @@ export function generateOpenApiSpec(baseUrl: string): object { paths: { ...OPENAPI_PATHS_DAISO_OLIVEYOUNG, ...OPENAPI_PATHS_MEGABOX, + ...OPENAPI_PATHS_CGV, }, components: OPENAPI_COMPONENTS, }; diff --git a/src/pages/openapiSpecComponents.ts b/src/pages/openapiSpecComponents.ts index b485c60..1b7448d 100644 --- a/src/pages/openapiSpecComponents.ts +++ b/src/pages/openapiSpecComponents.ts @@ -397,6 +397,124 @@ export const OPENAPI_COMPONENTS = { }, }, }, + CgvTheater: { + type: 'object', + properties: { + theaterCode: { type: 'string', example: '0056' }, + theaterName: { type: 'string', example: 'CGV강남' }, + regionCode: { type: 'string', example: '01' }, + }, + }, + CgvMovie: { + type: 'object', + properties: { + movieCode: { type: 'string', example: '200001' }, + movieName: { type: 'string', example: '테스트 영화' }, + rating: { type: 'string', example: '12' }, + }, + }, + CgvTimetable: { + type: 'object', + properties: { + scheduleId: { type: 'string', example: 'SCH1' }, + movieCode: { type: 'string', example: '200001' }, + movieName: { type: 'string', example: '테스트 영화' }, + theaterCode: { type: 'string', example: '0056' }, + theaterName: { type: 'string', example: 'CGV강남' }, + playDate: { type: 'string', example: '20260304' }, + startTime: { type: 'string', example: '09:30' }, + endTime: { type: 'string', example: '11:20' }, + totalSeats: { type: 'integer', example: 150 }, + remainingSeats: { type: 'integer', example: 42 }, + }, + }, + CgvTheaterSearchResponse: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + data: { + type: 'object', + properties: { + playDate: { type: 'string' }, + filters: { + type: 'object', + properties: { + regionCode: { type: 'string', nullable: true }, + }, + }, + theaters: { + type: 'array', + items: { $ref: '#/components/schemas/CgvTheater' }, + }, + }, + }, + meta: { + type: 'object', + properties: { + total: { type: 'integer' }, + pageSize: { type: 'integer' }, + }, + }, + }, + }, + CgvMovieSearchResponse: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + data: { + type: 'object', + properties: { + playDate: { type: 'string' }, + filters: { + type: 'object', + properties: { + theaterCode: { type: 'string', nullable: true }, + }, + }, + movies: { + type: 'array', + items: { $ref: '#/components/schemas/CgvMovie' }, + }, + }, + }, + meta: { + type: 'object', + properties: { + total: { type: 'integer' }, + }, + }, + }, + }, + CgvTimetableResponse: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + data: { + type: 'object', + properties: { + playDate: { type: 'string' }, + filters: { + type: 'object', + properties: { + theaterCode: { type: 'string', nullable: true }, + movieCode: { type: 'string', nullable: true }, + }, + }, + timetable: { + type: 'array', + items: { $ref: '#/components/schemas/CgvTimetable' }, + }, + }, + }, + meta: { + type: 'object', + properties: { + total: { type: 'integer' }, + pageSize: { type: 'integer' }, + }, + }, + }, + }, ErrorResponse: { type: 'object', properties: { diff --git a/src/pages/openapiSpecPathsCgv.ts b/src/pages/openapiSpecPathsCgv.ts new file mode 100644 index 0000000..61c2e87 --- /dev/null +++ b/src/pages/openapiSpecPathsCgv.ts @@ -0,0 +1,150 @@ +/** + * OpenAPI 경로 정의 (CGV) + */ + +export const OPENAPI_PATHS_CGV = { + '/api/cgv/theaters': { + get: { + operationId: 'cgvFindTheaters', + summary: 'CGV 극장 목록 조회', + description: '날짜/지역 코드 조건으로 CGV 극장 목록을 조회합니다.', + parameters: [ + { + name: 'playDate', + in: 'query', + required: false, + description: '조회 날짜 (YYYYMMDD)', + schema: { type: 'string', example: '20260304' }, + }, + { + name: 'regionCode', + in: 'query', + required: false, + description: '지역 코드 (예: 01 서울)', + schema: { type: 'string', example: '01' }, + }, + { + name: 'limit', + in: 'query', + required: false, + description: '최대 결과 수', + schema: { type: 'integer', default: 30, minimum: 1, maximum: 100 }, + }, + ], + responses: { + '200': { + description: '조회 성공', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/CgvTheaterSearchResponse' }, + }, + }, + }, + '500': { + description: 'CGV API 호출 실패', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ErrorResponse' }, + }, + }, + }, + }, + }, + }, + '/api/cgv/movies': { + get: { + operationId: 'cgvSearchMovies', + summary: 'CGV 영화 목록 조회', + description: '날짜/극장 조건으로 CGV 영화 목록을 조회합니다.', + parameters: [ + { + name: 'playDate', + in: 'query', + required: false, + description: '조회 날짜 (YYYYMMDD)', + schema: { type: 'string', example: '20260304' }, + }, + { + name: 'theaterCode', + in: 'query', + required: false, + description: '극장 코드 (예: 0056)', + schema: { type: 'string', example: '0056' }, + }, + ], + responses: { + '200': { + description: '조회 성공', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/CgvMovieSearchResponse' }, + }, + }, + }, + '500': { + description: 'CGV API 호출 실패', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ErrorResponse' }, + }, + }, + }, + }, + }, + }, + '/api/cgv/timetable': { + get: { + operationId: 'cgvGetTimetable', + summary: 'CGV 시간표 조회', + description: '날짜/극장/영화 조건으로 CGV 상영 시간표를 조회합니다.', + parameters: [ + { + name: 'playDate', + in: 'query', + required: false, + description: '조회 날짜 (YYYYMMDD)', + schema: { type: 'string', example: '20260304' }, + }, + { + name: 'theaterCode', + in: 'query', + required: false, + description: '극장 코드', + schema: { type: 'string' }, + }, + { + name: 'movieCode', + in: 'query', + required: false, + description: '영화 코드', + schema: { type: 'string' }, + }, + { + name: 'limit', + in: 'query', + required: false, + description: '최대 결과 수', + schema: { type: 'integer', default: 50, minimum: 1, maximum: 200 }, + }, + ], + responses: { + '200': { + description: '조회 성공', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/CgvTimetableResponse' }, + }, + }, + }, + '500': { + description: 'CGV API 호출 실패', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ErrorResponse' }, + }, + }, + }, + }, + }, + }, +}; diff --git a/src/pages/prompt.ts b/src/pages/prompt.ts index 506c7d0..3598958 100644 --- a/src/pages/prompt.ts +++ b/src/pages/prompt.ts @@ -284,6 +284,56 @@ Base URL: ${baseUrl} --- +### 10. CGV 극장 검색 + +**설명**: 지역 코드 기준으로 CGV 극장 목록을 조회합니다. + +**URL**: ${baseUrl}/api/cgv/theaters?playDate={YYYYMMDD} + +**선택 파라미터**: +- playDate: 조회 날짜 (YYYYMMDD, 기본값: 오늘) +- regionCode: 지역 코드 (예: 01) +- limit: 최대 결과 수 (기본값: 30) + +**예시**: +- ${baseUrl}/api/cgv/theaters?playDate=20260304®ionCode=01 +- ${baseUrl}/api/cgv/theaters?playDate=20260304&limit=10 + +--- + +### 11. CGV 영화 검색 + +**설명**: 날짜/극장 조건으로 CGV 영화 목록을 조회합니다. + +**URL**: ${baseUrl}/api/cgv/movies?playDate={YYYYMMDD} + +**선택 파라미터**: +- playDate: 조회 날짜 (YYYYMMDD, 기본값: 오늘) +- theaterCode: 극장 코드 (예: 0056) + +**예시**: +- ${baseUrl}/api/cgv/movies?playDate=20260304&theaterCode=0056 + +--- + +### 12. CGV 시간표 조회 + +**설명**: 날짜/극장/영화 조건으로 CGV 상영 시간표를 조회합니다. + +**URL**: ${baseUrl}/api/cgv/timetable?playDate={YYYYMMDD} + +**선택 파라미터**: +- playDate: 조회 날짜 (YYYYMMDD, 기본값: 오늘) +- theaterCode: 극장 코드 (예: 0056) +- movieCode: 영화 코드 +- limit: 최대 결과 수 (기본값: 50) + +**예시**: +- ${baseUrl}/api/cgv/timetable?playDate=20260304&theaterCode=0056 +- ${baseUrl}/api/cgv/timetable?playDate=20260304&movieCode=200001 + +--- + ## 응답 형식 ### 성공 응답 @@ -324,6 +374,9 @@ Base URL: ${baseUrl} | MEGABOX_THEATER_SEARCH_FAILED | 메가박스 지점 조회 실패 | | MEGABOX_MOVIE_LIST_FAILED | 메가박스 영화 목록 조회 실패 | | MEGABOX_SEAT_LIST_FAILED | 메가박스 좌석 조회 실패 | +| CGV_THEATER_SEARCH_FAILED | CGV 극장 조회 실패 | +| CGV_MOVIE_SEARCH_FAILED | CGV 영화 목록 조회 실패 | +| CGV_TIMETABLE_FETCH_FAILED | CGV 시간표 조회 실패 | --- @@ -354,6 +407,9 @@ MCP 연결 정보: ${baseUrl}/mcp - megabox_find_nearby_theaters: 메가박스 주변 지점 탐색 - megabox_list_now_showing: 메가박스 영화 목록 조회 - megabox_get_remaining_seats: 메가박스 잔여 좌석 조회 +- cgv_find_theaters: CGV 극장 검색 +- cgv_search_movies: CGV 영화 검색 +- cgv_get_timetable: CGV 시간표 조회 `; } diff --git a/src/services/cgv/api.ts b/src/services/cgv/api.ts new file mode 100644 index 0000000..837feda --- /dev/null +++ b/src/services/cgv/api.ts @@ -0,0 +1,10 @@ +/** + * CGV API 엔드포인트 중앙 관리 + */ + +export const CGV_API = { + BASE_URL: 'https://m.cgv.co.kr', + THEATER_LIST_PATH: '/WebAPP/ReservationV5/Reservation.aspx/GetTheaterList', + MOVIE_LIST_PATH: '/WebAPP/ReservationV5/Reservation.aspx/GetMovieList', + TIMETABLE_PATH: '/WebAPP/ReservationV5/Reservation.aspx/GetTimeTableList', +} as const; diff --git a/src/services/cgv/client.ts b/src/services/cgv/client.ts new file mode 100644 index 0000000..554cc8e --- /dev/null +++ b/src/services/cgv/client.ts @@ -0,0 +1,163 @@ +/** + * CGV API 클라이언트 + */ + +import { CGV_API } from './api.js'; +import type { + CgvMovie, + CgvMovieListResponse, + CgvTheater, + CgvTheaterListResponse, + CgvTimetable, + CgvTimetableResponse, +} from './types.js'; + +interface CommonFetchParams { + playDate?: string; + theaterCode?: string; + movieCode?: string; + regionCode?: string; + timeout?: number; +} + +function toNumber(value: number | string | undefined): number { + if (typeof value === 'number') { + return value; + } + + if (typeof value === 'string') { + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : 0; + } + + return 0; +} + +function formatTime(raw: string | undefined): string { + if (!raw) { + return ''; + } + + if (raw.includes(':')) { + return raw; + } + + if (raw.length === 4) { + return `${raw.slice(0, 2)}:${raw.slice(2)}`; + } + + return raw; +} + +function createController(timeout: number): { controller: AbortController; timeoutId: ReturnType } { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + return { controller, timeoutId }; +} + +async function postCgv( + path: string, + payload: Record, + timeout = 15000, +): Promise { + const { controller, timeoutId } = createController(timeout); + + try { + const response = await fetch(`${CGV_API.BASE_URL}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Accept: 'application/json, text/plain, */*', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`CGV API 호출 실패: ${response.status}`); + } + + return (await response.json()) as TResponse; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('CGV API 요청 시간 초과'); + } + + throw error; + } finally { + clearTimeout(timeoutId); + } +} + +export async function fetchCgvTheaters(params: CommonFetchParams): Promise { + const response = await postCgv( + CGV_API.THEATER_LIST_PATH, + { + PlayYMD: params.playDate || toYyyymmdd(), + AreaCd: params.regionCode || '', + }, + params.timeout, + ); + + return (response.d?.TheaterList || []) + .filter((item) => item.TheaterCd && item.TheaterName) + .map((item) => ({ + theaterCode: item.TheaterCd as string, + theaterName: item.TheaterName as string, + regionCode: item.AreaCd || undefined, + })); +} + +export async function fetchCgvMovies(params: CommonFetchParams): Promise { + const response = await postCgv( + CGV_API.MOVIE_LIST_PATH, + { + PlayYMD: params.playDate || toYyyymmdd(), + TheaterCd: params.theaterCode || '', + }, + params.timeout, + ); + + return (response.d?.MovieList || []) + .filter((item) => item.MovieCd && item.MovieName) + .map((item) => ({ + movieCode: item.MovieCd as string, + movieName: item.MovieName as string, + rating: item.Grade || undefined, + })); +} + +export async function fetchCgvTimetable(params: CommonFetchParams): Promise { + const response = await postCgv( + CGV_API.TIMETABLE_PATH, + { + PlayYMD: params.playDate || toYyyymmdd(), + TheaterCd: params.theaterCode || '', + MovieCd: params.movieCode || '', + }, + params.timeout, + ); + + return (response.d?.TimeTableList || []) + .filter((item) => item.ScheduleNo && item.MovieCd && item.TheaterCd) + .map((item) => ({ + scheduleId: item.ScheduleNo as string, + movieCode: item.MovieCd as string, + movieName: item.MovieName || '', + theaterCode: item.TheaterCd as string, + theaterName: item.TheaterName || '', + playDate: item.PlayYmd || params.playDate || toYyyymmdd(), + startTime: formatTime(item.StartTime), + endTime: formatTime(item.EndTime), + totalSeats: toNumber(item.TotalSeat), + remainingSeats: toNumber(item.RemainSeat), + })); +} + +export function toYyyymmdd(value: Date = new Date()): string { + const year = value.getFullYear(); + const month = `${value.getMonth() + 1}`.padStart(2, '0'); + const day = `${value.getDate()}`.padStart(2, '0'); + return `${year}${month}${day}`; +} diff --git a/src/services/cgv/index.ts b/src/services/cgv/index.ts new file mode 100644 index 0000000..6a4910e --- /dev/null +++ b/src/services/cgv/index.ts @@ -0,0 +1,30 @@ +/** + * CGV 서비스 프로바이더 + */ + +import type { ServiceProvider } from '../../core/interfaces.js'; +import type { ServiceMetadata, ToolRegistration } from '../../core/types.js'; +import { createFindTheatersTool } from './tools/findTheaters.js'; +import { createSearchMoviesTool } from './tools/searchMovies.js'; +import { createGetTimetableTool } from './tools/getTimetable.js'; + +const CGV_METADATA: ServiceMetadata = { + id: 'cgv', + name: 'CGV', + version: '1.0.0', + description: 'CGV 극장 검색, 영화 검색, 시간표 조회 서비스', +}; + +class CgvService implements ServiceProvider { + readonly metadata = CGV_METADATA; + + getTools(): ToolRegistration[] { + return [createFindTheatersTool(), createSearchMoviesTool(), createGetTimetableTool()]; + } +} + +export function createCgvService(): ServiceProvider { + return new CgvService(); +} + +export * from './types.js'; diff --git a/src/services/cgv/tools/findTheaters.ts b/src/services/cgv/tools/findTheaters.ts new file mode 100644 index 0000000..f8dea71 --- /dev/null +++ b/src/services/cgv/tools/findTheaters.ts @@ -0,0 +1,56 @@ +/** + * CGV 극장 검색 도구 + */ + +import * as z from 'zod'; +import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; +import { fetchCgvTheaters, toYyyymmdd } from '../client.js'; + +interface FindTheatersArgs { + playDate?: string; + regionCode?: string; + limit?: number; + timeoutMs?: number; +} + +async function findTheaters(args: FindTheatersArgs): Promise { + const { playDate = toYyyymmdd(), regionCode, limit = 30, timeoutMs = 15000 } = args; + + const theaters = await fetchCgvTheaters({ + playDate, + regionCode, + timeout: timeoutMs, + }); + + const sliced = theaters.slice(0, limit); + const result = { + playDate, + filters: { + regionCode: regionCode || null, + limit, + }, + count: sliced.length, + theaters: sliced, + }; + + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; +} + +export function createFindTheatersTool(): ToolRegistration { + return { + name: 'cgv_find_theaters', + metadata: { + title: 'CGV 극장 검색', + description: '지역 코드 기준으로 CGV 극장 목록을 조회합니다.', + inputSchema: { + playDate: z.string().optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), + regionCode: z.string().optional().describe('지역 코드 (예: 01 서울)'), + limit: z.number().optional().default(30).describe('최대 결과 수 (기본값: 30)'), + timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), + }, + }, + handler: findTheaters as (args: unknown) => Promise, + }; +} diff --git a/src/services/cgv/tools/getTimetable.ts b/src/services/cgv/tools/getTimetable.ts new file mode 100644 index 0000000..2631311 --- /dev/null +++ b/src/services/cgv/tools/getTimetable.ts @@ -0,0 +1,76 @@ +/** + * CGV 시간표 조회 도구 + */ + +import * as z from 'zod'; +import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; +import { fetchCgvTimetable, toYyyymmdd } from '../client.js'; + +interface GetTimetableArgs { + playDate?: string; + theaterCode?: string; + movieCode?: string; + limit?: number; + timeoutMs?: number; +} + +async function getTimetable(args: GetTimetableArgs): Promise { + const { + playDate = toYyyymmdd(), + theaterCode, + movieCode, + limit = 50, + timeoutMs = 15000, + } = args; + + const timetable = await fetchCgvTimetable({ + playDate, + theaterCode, + movieCode, + timeout: timeoutMs, + }); + + const filtered = timetable + .filter((item) => (theaterCode ? item.theaterCode === theaterCode : true)) + .filter((item) => (movieCode ? item.movieCode === movieCode : true)) + .sort((a, b) => { + if (a.startTime === b.startTime) { + return a.theaterName.localeCompare(b.theaterName); + } + return a.startTime.localeCompare(b.startTime); + }) + .slice(0, limit); + + const result = { + playDate, + filters: { + theaterCode: theaterCode || null, + movieCode: movieCode || null, + limit, + }, + count: filtered.length, + timetable: filtered, + }; + + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; +} + +export function createGetTimetableTool(): ToolRegistration { + return { + name: 'cgv_get_timetable', + metadata: { + title: 'CGV 시간표 조회', + description: '날짜/극장/영화 조건으로 CGV 상영 시간표를 조회합니다.', + inputSchema: { + playDate: z.string().optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), + theaterCode: z.string().optional().describe('CGV 극장 코드 (예: 0056)'), + movieCode: z.string().optional().describe('CGV 영화 코드'), + limit: z.number().optional().default(50).describe('최대 결과 수 (기본값: 50)'), + timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), + }, + }, + handler: getTimetable as (args: unknown) => Promise, + }; +} diff --git a/src/services/cgv/tools/searchMovies.ts b/src/services/cgv/tools/searchMovies.ts new file mode 100644 index 0000000..2b3546b --- /dev/null +++ b/src/services/cgv/tools/searchMovies.ts @@ -0,0 +1,52 @@ +/** + * CGV 영화 검색 도구 + */ + +import * as z from 'zod'; +import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; +import { fetchCgvMovies, toYyyymmdd } from '../client.js'; + +interface SearchMoviesArgs { + playDate?: string; + theaterCode?: string; + timeoutMs?: number; +} + +async function searchMovies(args: SearchMoviesArgs): Promise { + const { playDate = toYyyymmdd(), theaterCode, timeoutMs = 15000 } = args; + + const movies = await fetchCgvMovies({ + playDate, + theaterCode, + timeout: timeoutMs, + }); + + const result = { + playDate, + filters: { + theaterCode: theaterCode || null, + }, + count: movies.length, + movies, + }; + + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; +} + +export function createSearchMoviesTool(): ToolRegistration { + return { + name: 'cgv_search_movies', + metadata: { + title: 'CGV 영화 검색', + description: 'CGV 상영 영화 목록을 조회합니다.', + inputSchema: { + playDate: z.string().optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), + theaterCode: z.string().optional().describe('CGV 극장 코드 (예: 0056)'), + timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), + }, + }, + handler: searchMovies as (args: unknown) => Promise, + }; +} diff --git a/src/services/cgv/types.ts b/src/services/cgv/types.ts new file mode 100644 index 0000000..68afcdc --- /dev/null +++ b/src/services/cgv/types.ts @@ -0,0 +1,71 @@ +/** + * CGV 서비스 전용 타입 정의 + */ + +export interface CgvTheater { + theaterCode: string; + theaterName: string; + regionCode?: string; +} + +export interface CgvMovie { + movieCode: string; + movieName: string; + rating?: string; +} + +export interface CgvTimetable { + scheduleId: string; + movieCode: string; + movieName: string; + theaterCode: string; + theaterName: string; + playDate: string; + startTime: string; + endTime: string; + totalSeats: number; + remainingSeats: number; +} + +interface CgvTheaterItem { + TheaterCd?: string; + TheaterName?: string; + AreaCd?: string; +} + +interface CgvMovieItem { + MovieCd?: string; + MovieName?: string; + Grade?: string; +} + +interface CgvTimetableItem { + ScheduleNo?: string; + MovieCd?: string; + MovieName?: string; + TheaterCd?: string; + TheaterName?: string; + PlayYmd?: string; + StartTime?: string; + EndTime?: string; + TotalSeat?: number | string; + RemainSeat?: number | string; +} + +export interface CgvTheaterListResponse { + d?: { + TheaterList?: CgvTheaterItem[]; + }; +} + +export interface CgvMovieListResponse { + d?: { + MovieList?: CgvMovieItem[]; + }; +} + +export interface CgvTimetableResponse { + d?: { + TimeTableList?: CgvTimetableItem[]; + }; +} diff --git a/tests/api/cgv-handlers.test.ts b/tests/api/cgv-handlers.test.ts new file mode 100644 index 0000000..c718b59 --- /dev/null +++ b/tests/api/cgv-handlers.test.ts @@ -0,0 +1,165 @@ +/** + * CGV API 핸들러 테스트 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + handleCgvFindTheaters, + handleCgvSearchMovies, + handleCgvGetTimetable, +} from '../../src/api/cgvHandlers.js'; + +const mockFetch = vi.fn(); + +beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function createMockContext(query: Record = {}) { + return { + env: {}, + req: { + query: (key: string) => query[key], + param: () => undefined, + }, + json: vi.fn().mockImplementation((data, status) => ({ + data, + status: status || 200, + })), + } as unknown as Parameters[0]; +} + +describe('handleCgvFindTheaters', () => { + it('CGV 극장 목록을 반환한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + d: { + TheaterList: [{ TheaterCd: '0056', TheaterName: 'CGV강남', AreaCd: '01' }], + }, + }), + ), + ); + + const ctx = createMockContext({ playDate: '20260304', regionCode: '01' }); + await handleCgvFindTheaters(ctx); + + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ theaters: expect.any(Array) }), + }), + ); + }); + + it('CGV 극장 조회 에러를 처리한다', async () => { + mockFetch.mockRejectedValue(new Error('cgv theaters fail')); + + const ctx = createMockContext({}); + await handleCgvFindTheaters(ctx); + + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: { code: 'CGV_THEATER_SEARCH_FAILED', message: 'cgv theaters fail' }, + }), + 500, + ); + }); +}); + +describe('handleCgvSearchMovies', () => { + it('CGV 영화 목록을 반환한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + d: { + MovieList: [{ MovieCd: '200001', MovieName: '영화A', Grade: '12' }], + }, + }), + ), + ); + + const ctx = createMockContext({ playDate: '20260304', theaterCode: '0056' }); + await handleCgvSearchMovies(ctx); + + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ movies: expect.any(Array) }), + }), + ); + }); + + it('CGV 영화 조회 에러를 처리한다', async () => { + mockFetch.mockRejectedValue(new Error('cgv movies fail')); + + const ctx = createMockContext({}); + await handleCgvSearchMovies(ctx); + + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: { code: 'CGV_MOVIE_SEARCH_FAILED', message: 'cgv movies fail' }, + }), + 500, + ); + }); +}); + +describe('handleCgvGetTimetable', () => { + it('CGV 시간표를 반환한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + d: { + TimeTableList: [ + { + ScheduleNo: 'SCH1', + MovieCd: 'M1', + MovieName: '영화A', + TheaterCd: '0056', + TheaterName: 'CGV강남', + PlayYmd: '20260304', + StartTime: '0930', + EndTime: '1130', + TotalSeat: 100, + RemainSeat: 30, + }, + ], + }, + }), + ), + ); + + const ctx = createMockContext({ playDate: '20260304', theaterCode: '0056', movieCode: 'M1' }); + await handleCgvGetTimetable(ctx); + + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ timetable: expect.any(Array) }), + }), + ); + }); + + it('CGV 시간표 조회 에러를 처리한다', async () => { + mockFetch.mockRejectedValue(new Error('cgv timetable fail')); + + const ctx = createMockContext({}); + await handleCgvGetTimetable(ctx); + + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: { code: 'CGV_TIMETABLE_FETCH_FAILED', message: 'cgv timetable fail' }, + }), + 500, + ); + }); +}); diff --git a/tests/app/app-api-cgv.test.ts b/tests/app/app-api-cgv.test.ts new file mode 100644 index 0000000..adc3f17 --- /dev/null +++ b/tests/app/app-api-cgv.test.ts @@ -0,0 +1,82 @@ +/** + * 앱 통합 테스트 - CGV API + */ + +import { describe, expect, it, vi } from 'vitest'; +import app from '../../src/index.js'; +import { setupFetchMock } from './testHelpers.js'; + +const mockFetch = vi.fn(); +setupFetchMock(mockFetch); + +describe('GET /api/cgv/theaters', () => { + it('CGV 극장 목록을 반환한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + d: { TheaterList: [{ TheaterCd: '0056', TheaterName: 'CGV강남', AreaCd: '01' }] }, + }), + ), + ); + + const res = await app.request('/api/cgv/theaters?playDate=20260304®ionCode=01'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.data.theaters).toHaveLength(1); + }); +}); + +describe('GET /api/cgv/movies', () => { + it('CGV 영화 목록을 반환한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + d: { MovieList: [{ MovieCd: '200001', MovieName: '영화A', Grade: '12' }] }, + }), + ), + ); + + const res = await app.request('/api/cgv/movies?playDate=20260304&theaterCode=0056'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.data.movies).toHaveLength(1); + }); +}); + +describe('GET /api/cgv/timetable', () => { + it('CGV 시간표를 반환한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + d: { + TimeTableList: [ + { + ScheduleNo: 'SCH1', + MovieCd: 'M1', + MovieName: '영화A', + TheaterCd: '0056', + TheaterName: 'CGV강남', + PlayYmd: '20260304', + StartTime: '0930', + EndTime: '1130', + TotalSeat: 100, + RemainSeat: 30, + }, + ], + }, + }), + ), + ); + + const res = await app.request('/api/cgv/timetable?playDate=20260304&theaterCode=0056'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.data.timetable).toHaveLength(1); + }); +}); diff --git a/tests/app/app-info-pages.test.ts b/tests/app/app-info-pages.test.ts index 8c6b6e3..c2ea14f 100644 --- a/tests/app/app-info-pages.test.ts +++ b/tests/app/app-info-pages.test.ts @@ -51,6 +51,15 @@ describe('GET /', () => { expect(megaboxService.name).toBe('메가박스'); }); + it('CGV 서비스가 등록되어 있다', async () => { + const res = await app.request('/'); + const data = await res.json(); + + const cgvService = data.services.find((s: { id: string }) => s.id === 'cgv'); + expect(cgvService).toBeDefined(); + expect(cgvService.name).toBe('CGV'); + }); + it('다이소 도구들이 포함되어 있다', async () => { const res = await app.request('/'); const data = await res.json(); @@ -78,6 +87,15 @@ describe('GET /', () => { expect(data.tools).toContain('megabox_get_remaining_seats'); }); + it('CGV 도구들이 포함되어 있다', async () => { + const res = await app.request('/'); + const data = await res.json(); + + expect(data.tools).toContain('cgv_find_theaters'); + expect(data.tools).toContain('cgv_search_movies'); + expect(data.tools).toContain('cgv_get_timetable'); + }); + it('엔드포인트 정보를 포함한다', async () => { const res = await app.request('/'); const data = await res.json(); diff --git a/tests/pages/openapi.test.ts b/tests/pages/openapi.test.ts index 6eec961..724f7ca 100644 --- a/tests/pages/openapi.test.ts +++ b/tests/pages/openapi.test.ts @@ -25,6 +25,9 @@ describe('OpenAPI 페이지', () => { expect(spec.paths['/api/megabox/theaters']).toBeDefined(); expect(spec.paths['/api/megabox/movies']).toBeDefined(); expect(spec.paths['/api/megabox/seats']).toBeDefined(); + expect(spec.paths['/api/cgv/theaters']).toBeDefined(); + expect(spec.paths['/api/cgv/movies']).toBeDefined(); + expect(spec.paths['/api/cgv/timetable']).toBeDefined(); }); it('OpenAPI JSON 응답을 생성한다', async () => { diff --git a/tests/pages/prompt.test.ts b/tests/pages/prompt.test.ts index 808aeb9..6a31178 100644 --- a/tests/pages/prompt.test.ts +++ b/tests/pages/prompt.test.ts @@ -37,6 +37,11 @@ describe('generatePromptText', () => { expect(text).toContain('/api/megabox/theaters'); expect(text).toContain('/api/megabox/movies'); expect(text).toContain('/api/megabox/seats'); + + // CGV API + expect(text).toContain('/api/cgv/theaters'); + expect(text).toContain('/api/cgv/movies'); + expect(text).toContain('/api/cgv/timetable'); }); it('파라미터 설명을 포함한다', () => { @@ -72,6 +77,9 @@ describe('generatePromptText', () => { expect(text).toContain('MEGABOX_THEATER_SEARCH_FAILED'); expect(text).toContain('MEGABOX_MOVIE_LIST_FAILED'); expect(text).toContain('MEGABOX_SEAT_LIST_FAILED'); + expect(text).toContain('CGV_THEATER_SEARCH_FAILED'); + expect(text).toContain('CGV_MOVIE_SEARCH_FAILED'); + expect(text).toContain('CGV_TIMETABLE_FETCH_FAILED'); }); it('MCP 연결 정보를 포함한다', () => { @@ -87,6 +95,9 @@ describe('generatePromptText', () => { expect(text).toContain('megabox_find_nearby_theaters'); expect(text).toContain('megabox_list_now_showing'); expect(text).toContain('megabox_get_remaining_seats'); + expect(text).toContain('cgv_find_theaters'); + expect(text).toContain('cgv_search_movies'); + expect(text).toContain('cgv_get_timetable'); }); it('사용 팁을 포함한다', () => { diff --git a/tests/services/cgv/client.test.ts b/tests/services/cgv/client.test.ts new file mode 100644 index 0000000..45f5da6 --- /dev/null +++ b/tests/services/cgv/client.test.ts @@ -0,0 +1,150 @@ +/** + * CGV 클라이언트 테스트 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + fetchCgvMovies, + fetchCgvTheaters, + fetchCgvTimetable, + toYyyymmdd, +} from '../../../src/services/cgv/client.js'; + +const mockFetch = vi.fn(); + +beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('fetchCgvTheaters', () => { + it('극장 목록을 정규화한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + d: { + TheaterList: [ + { TheaterCd: '0056', TheaterName: 'CGV강남', AreaCd: '01' }, + { TheaterCd: '0041', TheaterName: 'CGV용산아이파크몰', AreaCd: '01' }, + ], + }, + }), + ), + ); + + const result = await fetchCgvTheaters({ playDate: '20260304', regionCode: '01' }); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ theaterCode: '0056', theaterName: 'CGV강남', regionCode: '01' }); + }); + + it('HTTP 에러를 처리한다', async () => { + mockFetch.mockResolvedValue(new Response('fail', { status: 500 })); + + await expect(fetchCgvTheaters({})).rejects.toThrow('CGV API 호출 실패: 500'); + }); + + it('AbortError를 시간 초과 에러로 변환한다', async () => { + mockFetch.mockRejectedValue(new DOMException('aborted', 'AbortError')); + + await expect(fetchCgvTheaters({})).rejects.toThrow('CGV API 요청 시간 초과'); + }); +}); + +describe('fetchCgvMovies', () => { + it('영화 목록을 정규화한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + d: { + MovieList: [ + { MovieCd: '200001', MovieName: '테스트 영화', Grade: '12' }, + { MovieCd: '200002', MovieName: '테스트 영화2', Grade: '15' }, + ], + }, + }), + ), + ); + + const result = await fetchCgvMovies({ playDate: '20260304', theaterCode: '0056' }); + + expect(result).toHaveLength(2); + expect(result[0].movieCode).toBe('200001'); + expect(result[0].rating).toBe('12'); + }); +}); + +describe('fetchCgvTimetable', () => { + it('시간표를 정규화하고 시간 포맷을 변환한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + d: { + TimeTableList: [ + { + ScheduleNo: 'SCH1', + MovieCd: '200001', + MovieName: '테스트 영화', + TheaterCd: '0056', + TheaterName: 'CGV강남', + PlayYmd: '20260304', + StartTime: '0930', + EndTime: '1120', + TotalSeat: '150', + RemainSeat: '42', + }, + ], + }, + }), + ), + ); + + const result = await fetchCgvTimetable({ playDate: '20260304' }); + + expect(result).toHaveLength(1); + expect(result[0].startTime).toBe('09:30'); + expect(result[0].endTime).toBe('11:20'); + expect(result[0].totalSeats).toBe(150); + expect(result[0].remainingSeats).toBe(42); + }); + + it('비정상 좌석 값은 0으로 처리한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + d: { + TimeTableList: [ + { + ScheduleNo: 'SCH2', + MovieCd: '200001', + TheaterCd: '0056', + StartTime: '9', + EndTime: '', + TotalSeat: 'abc', + RemainSeat: undefined, + }, + ], + }, + }), + ), + ); + + const result = await fetchCgvTimetable({ playDate: '20260304' }); + + expect(result[0].startTime).toBe('9'); + expect(result[0].endTime).toBe(''); + expect(result[0].totalSeats).toBe(0); + expect(result[0].remainingSeats).toBe(0); + }); +}); + +describe('toYyyymmdd', () => { + it('Date를 YYYYMMDD로 변환한다', () => { + const value = toYyyymmdd(new Date('2026-03-04T00:00:00.000Z')); + expect(value).toBe('20260304'); + }); +}); diff --git a/tests/services/cgv/index.test.ts b/tests/services/cgv/index.test.ts new file mode 100644 index 0000000..948942a --- /dev/null +++ b/tests/services/cgv/index.test.ts @@ -0,0 +1,37 @@ +/** + * CGV 서비스 테스트 + */ + +import { describe, expect, it } from 'vitest'; +import { createCgvService } from '../../../src/services/cgv/index.js'; + +describe('createCgvService', () => { + it('ServiceProvider 인터페이스를 구현한 객체를 반환한다', () => { + const service = createCgvService(); + + expect(service.metadata).toBeDefined(); + expect(service.getTools).toBeDefined(); + expect(typeof service.getTools).toBe('function'); + }); + + it('올바른 메타데이터를 가진다', () => { + const service = createCgvService(); + + expect(service.metadata.id).toBe('cgv'); + expect(service.metadata.name).toBe('CGV'); + expect(service.metadata.version).toBe('1.0.0'); + expect(service.metadata.description).toBeDefined(); + }); + + it('3개의 도구를 반환한다', () => { + const service = createCgvService(); + const tools = service.getTools(); + + expect(tools).toHaveLength(3); + expect(tools.map((tool) => tool.name)).toEqual([ + 'cgv_find_theaters', + 'cgv_search_movies', + 'cgv_get_timetable', + ]); + }); +}); diff --git a/tests/services/cgv/tools/findTheaters.test.ts b/tests/services/cgv/tools/findTheaters.test.ts new file mode 100644 index 0000000..31bbf83 --- /dev/null +++ b/tests/services/cgv/tools/findTheaters.test.ts @@ -0,0 +1,49 @@ +/** + * CGV 극장 검색 도구 테스트 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createFindTheatersTool } from '../../../../src/services/cgv/tools/findTheaters.js'; + +const mockFetch = vi.fn(); + +beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('createFindTheatersTool', () => { + it('올바른 도구 정의를 반환한다', () => { + const tool = createFindTheatersTool(); + + expect(tool.name).toBe('cgv_find_theaters'); + expect(tool.metadata.title).toBe('CGV 극장 검색'); + }); + + it('극장 목록을 제한 개수로 반환한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + d: { + TheaterList: [ + { TheaterCd: '0056', TheaterName: 'CGV강남', AreaCd: '01' }, + { TheaterCd: '0041', TheaterName: 'CGV용산아이파크몰', AreaCd: '01' }, + ], + }, + }), + ), + ); + + const tool = createFindTheatersTool(); + const result = await tool.handler({ playDate: '20260304', regionCode: '01', limit: 1 }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.count).toBe(1); + expect(parsed.theaters[0].theaterCode).toBe('0056'); + expect(parsed.filters.regionCode).toBe('01'); + }); +}); diff --git a/tests/services/cgv/tools/getTimetable.test.ts b/tests/services/cgv/tools/getTimetable.test.ts new file mode 100644 index 0000000..40ee291 --- /dev/null +++ b/tests/services/cgv/tools/getTimetable.test.ts @@ -0,0 +1,83 @@ +/** + * CGV 시간표 조회 도구 테스트 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createGetTimetableTool } from '../../../../src/services/cgv/tools/getTimetable.js'; + +const mockFetch = vi.fn(); + +beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('createGetTimetableTool', () => { + it('올바른 도구 정의를 반환한다', () => { + const tool = createGetTimetableTool(); + + expect(tool.name).toBe('cgv_get_timetable'); + expect(tool.metadata.title).toBe('CGV 시간표 조회'); + }); + + it('시간표를 시간순으로 반환한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + d: { + TimeTableList: [ + { + ScheduleNo: 'SCH2', + MovieCd: 'M1', + MovieName: '영화A', + TheaterCd: '0056', + TheaterName: 'CGV강남', + PlayYmd: '20260304', + StartTime: '1200', + EndTime: '1400', + TotalSeat: 120, + RemainSeat: 40, + }, + { + ScheduleNo: 'SCH1', + MovieCd: 'M1', + MovieName: '영화A', + TheaterCd: '0056', + TheaterName: 'CGV강남', + PlayYmd: '20260304', + StartTime: '0930', + EndTime: '1130', + TotalSeat: 120, + RemainSeat: 50, + }, + ], + }, + }), + ), + ); + + const tool = createGetTimetableTool(); + const result = await tool.handler({ playDate: '20260304', theaterCode: '0056', movieCode: 'M1' }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.count).toBe(2); + expect(parsed.timetable[0].scheduleId).toBe('SCH1'); + expect(parsed.filters.theaterCode).toBe('0056'); + expect(parsed.filters.movieCode).toBe('M1'); + }); + + it('필터가 없으면 null로 반환한다', async () => { + mockFetch.mockResolvedValue(new Response(JSON.stringify({ d: { TimeTableList: [] } }))); + + const tool = createGetTimetableTool(); + const result = await tool.handler({ playDate: '20260304' }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.filters.theaterCode).toBeNull(); + expect(parsed.filters.movieCode).toBeNull(); + }); +}); diff --git a/tests/services/cgv/tools/searchMovies.test.ts b/tests/services/cgv/tools/searchMovies.test.ts new file mode 100644 index 0000000..9aabf49 --- /dev/null +++ b/tests/services/cgv/tools/searchMovies.test.ts @@ -0,0 +1,56 @@ +/** + * CGV 영화 검색 도구 테스트 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSearchMoviesTool } from '../../../../src/services/cgv/tools/searchMovies.js'; + +const mockFetch = vi.fn(); + +beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('createSearchMoviesTool', () => { + it('올바른 도구 정의를 반환한다', () => { + const tool = createSearchMoviesTool(); + + expect(tool.name).toBe('cgv_search_movies'); + expect(tool.metadata.title).toBe('CGV 영화 검색'); + }); + + it('영화 목록을 반환한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + d: { + MovieList: [{ MovieCd: '200001', MovieName: '테스트 영화', Grade: '12' }], + }, + }), + ), + ); + + const tool = createSearchMoviesTool(); + const result = await tool.handler({ playDate: '20260304', theaterCode: '0056' }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.count).toBe(1); + expect(parsed.movies[0].movieName).toBe('테스트 영화'); + expect(parsed.filters.theaterCode).toBe('0056'); + }); + + it('필터가 없으면 null로 반환한다', async () => { + mockFetch.mockResolvedValue(new Response(JSON.stringify({ d: { MovieList: [] } }))); + + const tool = createSearchMoviesTool(); + const result = await tool.handler({ playDate: '20260304' }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.filters.theaterCode).toBeNull(); + }); +});