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();
+ });
+});