From 74d2e5261f9ce68fce3b9d28f033f2162217112d Mon Sep 17 00:00:00 2001 From: bgmbgm94 Date: Wed, 19 Aug 2026 20:20:11 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EB=94=94=ED=8A=B8=EB=A6=AD?= =?UTF-8?q?=EC=8A=A4=20=EC=98=88=EC=88=A0=EC=98=81=ED=99=94=EA=B4=80=20?= =?UTF-8?q?=EC=83=81=EC=98=81=EC=8B=9C=EA=B0=84=ED=91=9C=C2=B7=EC=9E=94?= =?UTF-8?q?=EC=97=AC=20=EC=A2=8C=EC=84=9D=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 국내 독립·예술영화관 다수가 공용으로 사용하는 디트릭스(Dtryx) 예매 플랫폼 어댑터를 추가합니다. 기존 CGV·롯데시네마·메가박스로는 조회되지 않던 예술영화 전용관 22곳의 상영 정보를 조회할 수 있습니다. - MCP 도구 3종: dtryx_list_cinemas / dtryx_list_now_showing / dtryx_get_remaining_seats - GET API 3종: /api/dtryx/cinemas, /api/dtryx/movies, /api/dtryx/seats - 공개 엔드포인트로 직접 호출하므로 Zyte 를 경유하지 않고 API 키도 필요 없습니다. - PlaySDT 는 YYYY-MM-DD 로만 동작하고 YYYYMMDD 는 조용히 0건을 반환하므로 클라이언트에서 항상 변환합니다. - 극장 목록 반환 엔드포인트가 없어 확인된 극장을 catalog.ts 상수로 관리합니다. - src/index.ts 가 이미 449줄이어서 서비스 추가 시 450줄 제한을 넘기므로 도메인 라우트 등록을 registerServiceRoutes.ts 로 분리했습니다. Co-Authored-By: Claude Opus 5 --- src/api/dtryxHandlers.ts | 171 ++++++++++++++ src/api/routes/dtryxRoutes.ts | 50 ++++ src/api/routes/registerServiceRoutes.ts | 41 ++++ src/index.ts | 32 +-- src/services/dtryx/api.ts | 26 +++ src/services/dtryx/catalog.ts | 57 +++++ src/services/dtryx/client.ts | 191 ++++++++++++++++ src/services/dtryx/index.ts | 33 +++ src/services/dtryx/tools/getRemainingSeats.ts | 156 +++++++++++++ src/services/dtryx/tools/listCinemas.ts | 60 +++++ src/services/dtryx/tools/listNowShowing.ts | 121 ++++++++++ src/services/dtryx/types.ts | 88 ++++++++ tests/api/dtryx-handlers.test.ts | 156 +++++++++++++ tests/services/dtryx/catalog.test.ts | 47 ++++ tests/services/dtryx/client.test.ts | 213 ++++++++++++++++++ tests/services/dtryx/index.test.ts | 36 +++ tests/services/dtryx/tools/dtryxTools.test.ts | 170 ++++++++++++++ 17 files changed, 1620 insertions(+), 28 deletions(-) create mode 100644 src/api/dtryxHandlers.ts create mode 100644 src/api/routes/dtryxRoutes.ts create mode 100644 src/api/routes/registerServiceRoutes.ts create mode 100644 src/services/dtryx/api.ts create mode 100644 src/services/dtryx/catalog.ts create mode 100644 src/services/dtryx/client.ts create mode 100644 src/services/dtryx/index.ts create mode 100644 src/services/dtryx/tools/getRemainingSeats.ts create mode 100644 src/services/dtryx/tools/listCinemas.ts create mode 100644 src/services/dtryx/tools/listNowShowing.ts create mode 100644 src/services/dtryx/types.ts create mode 100644 tests/api/dtryx-handlers.test.ts create mode 100644 tests/services/dtryx/catalog.test.ts create mode 100644 tests/services/dtryx/client.test.ts create mode 100644 tests/services/dtryx/index.test.ts create mode 100644 tests/services/dtryx/tools/dtryxTools.test.ts diff --git a/src/api/dtryxHandlers.ts b/src/api/dtryxHandlers.ts new file mode 100644 index 00000000..f0cc72c8 --- /dev/null +++ b/src/api/dtryxHandlers.ts @@ -0,0 +1,171 @@ +/** + * 디트릭스 GET API 핸들러 + */ + +import { + DTRYX_CINEMAS, + findCinemaByCode, + findCinemasByKeyword, +} from '../services/dtryx/catalog.js'; +import { + fetchDtryxNowShowing, + fetchDtryxPlayDates, + fetchDtryxTimetable, + toYyyymmdd, +} from '../services/dtryx/client.js'; +import type { DtryxCinema } from '../services/dtryx/types.js'; +import { type ApiContext, errorResponse, successResponse } from './response.js'; + +/** 요청 파라미터로 조회 대상 극장을 결정합니다. */ +function resolveCinemas( + cinemaCode: string | undefined, + keyword: string | undefined, + brandCode: string | undefined, +): DtryxCinema[] { + if (cinemaCode) { + const known = findCinemaByCode(cinemaCode); + if (known) { + return [known]; + } + + return brandCode ? [{ brandCode, cinemaCode, cinemaName: '' }] : []; + } + + if (keyword) { + return findCinemasByKeyword(keyword); + } + + return [...DTRYX_CINEMAS]; +} + +export async function handleDtryxListCinemas(c: ApiContext) { + const keyword = c.req.query('keyword') || undefined; + const brandCode = c.req.query('brandCode') || undefined; + const limit = parseInt(c.req.query('limit') || '50', 10); + + const cinemas = (keyword ? findCinemasByKeyword(keyword) : [...DTRYX_CINEMAS]) + .filter((cinema) => (brandCode ? cinema.brandCode === brandCode : true)) + .slice(0, limit); + + return successResponse( + c, + { + filters: { keyword: keyword || null, brandCode: brandCode || null, limit }, + count: cinemas.length, + cinemas, + }, + { total: cinemas.length, pageSize: limit }, + ); +} + +export async function handleDtryxListNowShowing(c: ApiContext) { + const cinemaCode = c.req.query('cinemaCode') || undefined; + const keyword = c.req.query('keyword') || undefined; + const brandCode = c.req.query('brandCode') || undefined; + const includePlayDates = c.req.query('includePlayDates') === 'true'; + const timeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10); + const cinema = resolveCinemas(cinemaCode, keyword, brandCode)[0]; + + if (!cinema) { + return errorResponse( + c, + 'DTRYX_CINEMA_NOT_FOUND', + '극장을 찾을 수 없습니다. cinemaCode 또는 keyword 를 확인하세요.', + 404, + ); + } + + try { + const request = { + brandCode: cinema.brandCode, + cinemaCode: cinema.cinemaCode, + timeout: timeoutMs, + }; + const [movies, playDates] = await Promise.all([ + fetchDtryxNowShowing(request), + includePlayDates ? fetchDtryxPlayDates(request) : Promise.resolve([]), + ]); + + return successResponse( + c, + { + cinema, + count: movies.length, + movies, + playDates: includePlayDates ? playDates : undefined, + }, + { total: movies.length }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; + return errorResponse(c, 'DTRYX_NOW_SHOWING_FAILED', message, 500); + } +} + +export async function handleDtryxGetRemainingSeats(c: ApiContext) { + const playDate = c.req.query('playDate') || toYyyymmdd(); + const cinemaCode = c.req.query('cinemaCode') || undefined; + const keyword = c.req.query('keyword') || undefined; + const brandCode = c.req.query('brandCode') || undefined; + const movieName = c.req.query('movieName') || undefined; + const limit = parseInt(c.req.query('limit') || '50', 10); + const timeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10); + const cinemas = resolveCinemas(cinemaCode, keyword, brandCode); + + if (cinemas.length === 0) { + return errorResponse( + c, + 'DTRYX_CINEMA_NOT_FOUND', + '극장을 찾을 수 없습니다. cinemaCode 또는 keyword 를 확인하세요.', + 404, + ); + } + + try { + const settled = await Promise.allSettled( + cinemas.map((cinema) => + fetchDtryxTimetable({ + brandCode: cinema.brandCode, + cinemaCode: cinema.cinemaCode, + playDate, + timeout: timeoutMs, + }), + ), + ); + const failedCinemas = cinemas + .filter((_, index) => settled[index]?.status === 'rejected') + .map((cinema) => cinema.cinemaName || cinema.cinemaCode); + const normalize = (value: string) => value.replace(/\s+/g, '').toLowerCase(); + const showtimes = settled + .flatMap((result) => (result.status === 'fulfilled' ? result.value : [])) + .filter((item) => + movieName ? normalize(item.movieName).includes(normalize(movieName)) : true, + ) + .sort((a, b) => + a.startTime === b.startTime + ? a.cinemaName.localeCompare(b.cinemaName) + : a.startTime.localeCompare(b.startTime), + ) + .slice(0, limit); + + return successResponse( + c, + { + playDate, + filters: { + cinemaCode: cinemaCode || null, + keyword: keyword || null, + movieName: movieName || null, + }, + searchedCinemaCount: cinemas.length, + failedCinemas, + count: showtimes.length, + showtimes, + }, + { total: showtimes.length, pageSize: limit }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; + return errorResponse(c, 'DTRYX_SEATS_FETCH_FAILED', message, 500); + } +} diff --git a/src/api/routes/dtryxRoutes.ts b/src/api/routes/dtryxRoutes.ts new file mode 100644 index 00000000..1131f471 --- /dev/null +++ b/src/api/routes/dtryxRoutes.ts @@ -0,0 +1,50 @@ +/** + * 디트릭스 GET API 라우트 등록 + */ + +import type { Hono } from 'hono'; +import { withEdgeCache } from '../../utils/cache.js'; +import { + handleDtryxGetRemainingSeats, + handleDtryxListCinemas, + handleDtryxListNowShowing, +} from '../dtryxHandlers.js'; +import type { AppBindings } from '../response.js'; + +export function registerDtryxRoutes(app: Hono<{ Bindings: AppBindings }>): void { + app.get('/api/dtryx/cinemas', async (c) => + withEdgeCache( + c.req.url, + { + ttlSeconds: 60 * 60 * 24, + staleWhileRevalidateSeconds: 60 * 5, + keyPrefix: 'dtryx-cinemas-v1', + }, + () => handleDtryxListCinemas(c), + ), + ); + + app.get('/api/dtryx/movies', async (c) => + withEdgeCache( + c.req.url, + { + ttlSeconds: 60 * 10, + staleWhileRevalidateSeconds: 60, + keyPrefix: 'dtryx-movies-v1', + }, + () => handleDtryxListNowShowing(c), + ), + ); + + app.get('/api/dtryx/seats', async (c) => + withEdgeCache( + c.req.url, + { + ttlSeconds: 60 * 3, + staleWhileRevalidateSeconds: 30, + keyPrefix: 'dtryx-seats-v1', + }, + () => handleDtryxGetRemainingSeats(c), + ), + ); +} diff --git a/src/api/routes/registerServiceRoutes.ts b/src/api/routes/registerServiceRoutes.ts new file mode 100644 index 00000000..e6228426 --- /dev/null +++ b/src/api/routes/registerServiceRoutes.ts @@ -0,0 +1,41 @@ +/** + * 도메인별 GET API 라우트 일괄 등록 + * + * `src/index.ts` 의 길이 제한(450줄)을 유지하기 위해 분리했습니다. + */ + +import type { Hono } from 'hono'; +import type { AppBindings } from '../response.js'; +import { registerCgvRoutes } from './cgvRoutes.js'; +import { registerCompareRoutes } from './compareRoutes.js'; +import { registerCuRoutes } from './cuRoutes.js'; +import { registerDaisoRoutes } from './daisoRoutes.js'; +import { registerDtryxRoutes } from './dtryxRoutes.js'; +import { registerEmart24Routes } from './emart24Routes.js'; +import { registerFeedbackRoutes } from './feedbackRoutes.js'; +import { registerGs25Routes } from './gs25Routes.js'; +import { registerLotteCinemaRoutes } from './lottecinemaRoutes.js'; +import { registerLotteMartRoutes } from './lottemartRoutes.js'; +import { registerMegaboxRoutes } from './megaboxRoutes.js'; +import { registerOliveyoungRoutes } from './oliveyoungRoutes.js'; +import { registerOpinetRoutes } from './opinetRoutes.js'; +import { registerPlacesRoutes } from './placesRoutes.js'; +import { registerSevenElevenRoutes } from './sevenelevenRoutes.js'; + +export function registerServiceRoutes(app: Hono<{ Bindings: AppBindings }>): void { + registerDaisoRoutes(app); + registerGs25Routes(app); + registerSevenElevenRoutes(app); + registerCompareRoutes(app); + registerFeedbackRoutes(app); + registerPlacesRoutes(app); + registerOpinetRoutes(app); + registerCuRoutes(app); + registerEmart24Routes(app); + registerLotteMartRoutes(app); + registerOliveyoungRoutes(app); + registerDtryxRoutes(app); + registerMegaboxRoutes(app); + registerLotteCinemaRoutes(app); + registerCgvRoutes(app); +} diff --git a/src/index.ts b/src/index.ts index d13f3b11..c6f6975e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/ import { ServiceRegistry } from './core/registry.js'; import { createDaisoService } from './services/daiso/index.js'; import { createOliveyoungService } from './services/oliveyoung/index.js'; +import { createDtryxService } from './services/dtryx/index.js'; import { createMegaboxService } from './services/megabox/index.js'; import { createCgvService } from './services/cgv/index.js'; import { createLotteCinemaService } from './services/lottecinema/index.js'; @@ -35,21 +36,8 @@ import { import { createPrivacyResponse } from './pages/privacy.js'; import type { AppBindings } from './api/response.js'; import { buildActionQueryTargetUrl } from './api/actionsProxy.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'; -import { registerLotteCinemaRoutes } from './api/routes/lottecinemaRoutes.js'; -import { registerLotteMartRoutes } from './api/routes/lottemartRoutes.js'; -import { registerCuRoutes } from './api/routes/cuRoutes.js'; -import { registerEmart24Routes } from './api/routes/emart24Routes.js'; -import { registerGs25Routes } from './api/routes/gs25Routes.js'; -import { registerSevenElevenRoutes } from './api/routes/sevenelevenRoutes.js'; -import { registerPlacesRoutes } from './api/routes/placesRoutes.js'; -import { registerOpinetRoutes } from './api/routes/opinetRoutes.js'; -import { registerCompareRoutes } from './api/routes/compareRoutes.js'; -import { registerFeedbackRoutes } from './api/routes/feedbackRoutes.js'; import { registerHealthRoutes } from './api/routes/healthRoutes.js'; +import { registerServiceRoutes } from './api/routes/registerServiceRoutes.js'; import { registerRateLimitStatsRoutes } from './api/routes/rateLimitStatsRoutes.js'; import { buildConfigStatus } from './api/configStatus.js'; import { createDailyRateLimitMiddleware } from './middleware/dailyRateLimit.js'; @@ -112,6 +100,7 @@ const createRegistry = (bindings?: AppBindings) => { zyteApiKey: bindings?.ZYTE_API_KEY, }), createMegaboxService, + createDtryxService, () => createLotteCinemaService({ googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY, @@ -354,6 +343,7 @@ app.on(['POST', 'DELETE'], '/', handleRootMcpRequest); app.get('/health', (c) => c.json({ status: 'ok', config: buildConfigStatus(c.env) })); registerHealthRoutes(app); registerRateLimitStatsRoutes(app); +registerServiceRoutes(app); // 프롬프트 페이지 (MCP 미지원 에이전트용) app.get('/prompt', (c) => { @@ -389,20 +379,6 @@ app.get('/privacy', (c) => { }); // GET API 엔드포인트 (MCP 미지원 에이전트용) -registerDaisoRoutes(app); -registerGs25Routes(app); -registerSevenElevenRoutes(app); -registerCompareRoutes(app); -registerFeedbackRoutes(app); -registerPlacesRoutes(app); -registerOpinetRoutes(app); -registerCuRoutes(app); -registerEmart24Routes(app); -registerLotteMartRoutes(app); -registerOliveyoungRoutes(app); -registerMegaboxRoutes(app); -registerLotteCinemaRoutes(app); -registerCgvRoutes(app); app.get('/api/actions/query', async (c) => { try { diff --git a/src/services/dtryx/api.ts b/src/services/dtryx/api.ts new file mode 100644 index 00000000..4ce91196 --- /dev/null +++ b/src/services/dtryx/api.ts @@ -0,0 +1,26 @@ +/** + * 디트릭스(Dtryx) API 엔드포인트 및 상수 + * + * 디트릭스는 국내 독립·예술영화관 다수가 공용으로 사용하는 예매 플랫폼입니다. + * 공개 조회 엔드포인트이며 별도 인증 키가 필요하지 않습니다. + */ + +export const DTRYX_API = { + BASE_URL: 'https://api.dtryx.com:30443', + THIRDPARTY_PATH: '/dtryx/cms/thirdparty/movie', + CHANNEL_CODE: 'homepage', + ENDPOINTS: { + TIMETABLE_LIST: 'third-party-type2-timetable-list', + PLAY_DATE_LIST: 'third-party-type2-timetable-play-date-list', + MOVIE_NOW: 'third-party-type2-movie-now', + }, + /** + * 엔드포인트별 고정 작업 식별자입니다. + * 각 극장 홈페이지에 공개된 값과 동일합니다. + */ + WORK_GUIDS: { + TIMETABLE_LIST: '37E0BA0F-DA5F-4376-9BA4-B5D27286AB87', + PLAY_DATE_LIST: '324A2914-AB19-42A3-BDFE-58A08B2DC35D', + MOVIE_NOW: '6D741A36-A973-4BC4-87B4-2475E6654114', + }, +} as const; diff --git a/src/services/dtryx/catalog.ts b/src/services/dtryx/catalog.ts new file mode 100644 index 00000000..3de8a594 --- /dev/null +++ b/src/services/dtryx/catalog.ts @@ -0,0 +1,57 @@ +/** + * 디트릭스 극장 카탈로그 + * + * 디트릭스는 브랜드/극장 목록을 반환하는 공개 엔드포인트를 제공하지 않습니다. + * 그래서 조회로 확인된 극장을 상수로 관리합니다. + * + * 아래 목록은 상영시간표 응답의 `CinemaNm` 으로 확인한 값만 담았습니다. + * `indieart/000068`, `etc/000086` 두 코드는 상영 가능 날짜는 있으나 + * 아직 편성 회차가 없어 극장명을 확인하지 못해 제외했습니다. + * + * `BrandCd` 는 조회 시 반드시 일치해야 하며, 값이 다르면 오류 없이 0건이 반환됩니다. + */ + +import type { DtryxCinema } from './types.js'; + +export const DTRYX_CINEMAS: readonly DtryxCinema[] = [ + { brandCode: 'indieart', cinemaCode: '000053', cinemaName: '씨네아트 리좀' }, + { brandCode: 'indieart', cinemaCode: '000057', cinemaName: '인디플러스포항' }, + { brandCode: 'indieart', cinemaCode: '000059', cinemaName: '오오극장' }, + { brandCode: 'indieart', cinemaCode: '000061', cinemaName: '전주디지털독립영화관' }, + { brandCode: 'indieart', cinemaCode: '000065', cinemaName: '더숲아트시네마' }, + { brandCode: 'indieart', cinemaCode: '000066', cinemaName: '광주극장' }, + { brandCode: 'indieart', cinemaCode: '000067', cinemaName: '아트하우스모모' }, + { brandCode: 'indieart', cinemaCode: '000069', cinemaName: '에무시네마' }, + { brandCode: 'indieart', cinemaCode: '000071', cinemaName: '헤이리시네마' }, + { brandCode: 'spacedog', cinemaCode: '000072', cinemaName: '라이카시네마' }, + { brandCode: 'etc', cinemaCode: '000083', cinemaName: '천안인생극장' }, + { brandCode: 'etc', cinemaCode: '000084', cinemaName: '광주자동차극장' }, + { brandCode: 'etc', cinemaCode: '000088', cinemaName: '아리랑시네센터' }, + { brandCode: 'etc', cinemaCode: '000092', cinemaName: '밀양시네마' }, + { brandCode: 'etc', cinemaCode: '000097', cinemaName: '모퉁이극장' }, + { brandCode: 'etc', cinemaCode: '000098', cinemaName: '씨네인디U' }, + { brandCode: 'etc', cinemaCode: '000100', cinemaName: '애관극장' }, + { brandCode: 'etc', cinemaCode: '000107', cinemaName: '금성시네마' }, + { brandCode: 'etc', cinemaCode: '000113', cinemaName: '낭만극장' }, + { brandCode: 'etc', cinemaCode: '000115', cinemaName: '허리우드클래식' }, + { brandCode: 'etc', cinemaCode: '000116', cinemaName: '명화극장' }, + { brandCode: 'etc', cinemaCode: '000117', cinemaName: '자유로자동차극장' }, +]; + +/** 극장명 부분 일치로 카탈로그를 검색합니다. */ +export function findCinemasByKeyword(keyword: string): DtryxCinema[] { + const normalized = keyword.replace(/\s+/g, '').toLowerCase(); + + if (!normalized) { + return [...DTRYX_CINEMAS]; + } + + return DTRYX_CINEMAS.filter((cinema) => + cinema.cinemaName.replace(/\s+/g, '').toLowerCase().includes(normalized), + ); +} + +/** 극장 코드로 카탈로그 항목을 찾습니다. */ +export function findCinemaByCode(cinemaCode: string): DtryxCinema | undefined { + return DTRYX_CINEMAS.find((cinema) => cinema.cinemaCode === cinemaCode); +} diff --git a/src/services/dtryx/client.ts b/src/services/dtryx/client.ts new file mode 100644 index 00000000..cdc71d21 --- /dev/null +++ b/src/services/dtryx/client.ts @@ -0,0 +1,191 @@ +/** + * 디트릭스 API 클라이언트 + */ + +import { formatTime, toNumber, toYyyymmdd } from '../../utils/format.js'; +import { createTimeoutController } from '../../utils/http.js'; +import { DTRYX_API } from './api.js'; +import type { + DtryxMovie, + DtryxMovieResponse, + DtryxPlayDate, + DtryxPlayDateResponse, + DtryxShowtime, + DtryxTimetableResponse, +} from './types.js'; + +export interface DtryxRequestParams { + brandCode: string; + cinemaCode: string; + playDate?: string; + timeout?: number; +} + +const DEFAULT_TIMEOUT_MS = 15000; + +/** + * 디트릭스는 `PlaySDT` 를 `YYYY-MM-DD` 로만 받습니다. + * `YYYYMMDD` 로 보내면 오류 없이 `RecordCount: 0` 이 반환되므로 항상 변환합니다. + */ +export function toDashedDate(playDate: string): string { + if (/^\d{8}$/.test(playDate)) { + return `${playDate.slice(0, 4)}-${playDate.slice(4, 6)}-${playDate.slice(6, 8)}`; + } + + return playDate; +} + +/** 출력용으로 `YYYYMMDD` 형태로 되돌립니다. */ +export function toCompactDate(playDate: string): string { + return playDate.replace(/-/g, ''); +} + +async function requestDtryx( + endpoint: string, + workGuid: string, + params: Record, + timeout = DEFAULT_TIMEOUT_MS, +): Promise { + const { controller, timeoutId } = createTimeoutController(timeout); + const query = new URLSearchParams({ + ChannelCd: DTRYX_API.CHANNEL_CODE, + EngVerYn: 'N', + WorkGuID: workGuid, + ...params, + }); + const url = `${DTRYX_API.BASE_URL}${DTRYX_API.THIRDPARTY_PATH}/${endpoint}?${query.toString()}`; + + try { + const response = await fetch(url, { + method: 'GET', + headers: { + Accept: 'application/json, text/javascript, */*; q=0.01', + }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`디트릭스 API 호출 실패: ${response.status}`); + } + + return (await response.json()) as T; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('디트릭스 API 호출 시간 초과'); + } + + throw error; + } finally { + clearTimeout(timeoutId); + } +} + +function buildScheduleId( + cinemaCode: string, + playDate: string, + screenCode: string, + showSeq: string, +): string { + return `${playDate}-${cinemaCode}-${screenCode}-${showSeq}`; +} + +/** 상영 회차와 좌석 현황을 조회합니다. */ +export async function fetchDtryxTimetable(params: DtryxRequestParams): Promise { + const playDate = params.playDate || toYyyymmdd(); + const response = await requestDtryx( + DTRYX_API.ENDPOINTS.TIMETABLE_LIST, + DTRYX_API.WORK_GUIDS.TIMETABLE_LIST, + { + BrandCd: params.brandCode, + CinemaCd: params.cinemaCode, + PlaySDT: toDashedDate(playDate), + ImgSize: 'small', + }, + params.timeout, + ); + + return (response.Recordset || []) + .filter((item) => item.CinemaCd && item.MovieCd && item.ScreenCd) + .map((item) => { + const totalSeats = toNumber(item.TotalSeatCnt); + const remainingSeats = toNumber(item.RemainSeatCnt); + const compactDate = toCompactDate(item.PlaySDT || playDate); + const cinemaCode = String(item.CinemaCd); + const screenCode = String(item.ScreenCd); + const showSeq = String(item.ShowSeq ?? ''); + + return { + scheduleId: buildScheduleId(cinemaCode, compactDate, screenCode, showSeq), + brandCode: params.brandCode, + cinemaCode, + cinemaName: item.CinemaNm || '', + screenCode, + screenName: item.ScreenNm || '', + movieCode: String(item.MovieCd), + movieName: item.MovieNm || '', + playDate: compactDate, + startTime: formatTime(item.StartTime), + endTime: formatTime(item.EndTime), + runningMinutes: toNumber(item.RunningTime), + rating: item.RatingNm || '', + totalSeats, + remainingSeats, + bookedSeats: Math.max(totalSeats - remainingSeats, 0), + planStatus: item.PlanStatus || '', + }; + }); +} + +/** 예매 가능한 상영 날짜를 조회합니다. */ +export async function fetchDtryxPlayDates( + params: Omit, +): Promise { + const response = await requestDtryx( + DTRYX_API.ENDPOINTS.PLAY_DATE_LIST, + DTRYX_API.WORK_GUIDS.PLAY_DATE_LIST, + { + BrandCd: params.brandCode, + CinemaCd: params.cinemaCode, + MovieCd: '', + }, + params.timeout, + ); + + return (response.Recordset || []) + .filter((item) => item.PlaySDT) + .map((item) => ({ + playDate: toCompactDate(item.PlaySDT as string), + hidden: item.HiddenYn === 'Y', + rest: item.RestYn === 'Y', + })); +} + +/** 현재 상영작 목록을 조회합니다. */ +export async function fetchDtryxNowShowing( + params: Omit, +): Promise { + const response = await requestDtryx( + DTRYX_API.ENDPOINTS.MOVIE_NOW, + DTRYX_API.WORK_GUIDS.MOVIE_NOW, + { + BrandCd: params.brandCode, + CinemaCd: params.cinemaCode, + ImgSize: 'small', + }, + params.timeout, + ); + + return (response.Recordset || []) + .filter((item) => item.MovieCd && item.MovieNm) + .map((item) => ({ + movieCode: String(item.MovieCd), + movieName: item.MovieNm as string, + rating: item.RatingNm || undefined, + runningMinutes: + item.RunningTime === null || item.RunningTime === undefined + ? undefined + : toNumber(item.RunningTime), + })); +} + +export { toYyyymmdd }; diff --git a/src/services/dtryx/index.ts b/src/services/dtryx/index.ts new file mode 100644 index 00000000..aaf308cc --- /dev/null +++ b/src/services/dtryx/index.ts @@ -0,0 +1,33 @@ +/** + * 디트릭스(Dtryx) 서비스 프로바이더 + * + * 국내 독립·예술영화관 다수가 공용으로 사용하는 예매 플랫폼을 조회합니다. + */ + +import type { ServiceProvider } from '../../core/interfaces.js'; +import type { ServiceMetadata, ToolRegistration } from '../../core/types.js'; +import { createGetRemainingSeatsTool } from './tools/getRemainingSeats.js'; +import { createListCinemasTool } from './tools/listCinemas.js'; +import { createListNowShowingTool } from './tools/listNowShowing.js'; + +const DTRYX_METADATA: ServiceMetadata = { + id: 'dtryx', + name: '디트릭스', + version: '1.0.0', + description: + '디트릭스 예매 플랫폼 기반 독립·예술영화관 목록, 상영작, 상영시간표·잔여 좌석 조회 서비스', +}; + +class DtryxService implements ServiceProvider { + readonly metadata = DTRYX_METADATA; + + getTools(): ToolRegistration[] { + return [createListCinemasTool(), createListNowShowingTool(), createGetRemainingSeatsTool()]; + } +} + +export function createDtryxService(): ServiceProvider { + return new DtryxService(); +} + +export * from './types.js'; diff --git a/src/services/dtryx/tools/getRemainingSeats.ts b/src/services/dtryx/tools/getRemainingSeats.ts new file mode 100644 index 00000000..dd506eda --- /dev/null +++ b/src/services/dtryx/tools/getRemainingSeats.ts @@ -0,0 +1,156 @@ +/** + * 디트릭스 상영시간표·잔여 좌석 조회 도구 + */ + +import * as z from 'zod'; +import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; +import { DTRYX_CINEMAS, findCinemaByCode, findCinemasByKeyword } from '../catalog.js'; +import { fetchDtryxTimetable, toYyyymmdd } from '../client.js'; +import type { DtryxCinema, DtryxShowtime } from '../types.js'; + +interface GetRemainingSeatsArgs { + playDate?: string; + cinemaCode?: string; + keyword?: string; + brandCode?: string; + movieName?: string; + limit?: number; + timeoutMs?: number; +} + +/** 조회 대상 극장을 결정합니다. 지정이 없으면 카탈로그 전체를 훑습니다. */ +function resolveCinemas(args: GetRemainingSeatsArgs): DtryxCinema[] { + if (args.cinemaCode) { + const known = findCinemaByCode(args.cinemaCode); + if (known) { + return [known]; + } + + return args.brandCode + ? [{ brandCode: args.brandCode, cinemaCode: args.cinemaCode, cinemaName: '' }] + : []; + } + + if (args.keyword) { + return findCinemasByKeyword(args.keyword); + } + + return [...DTRYX_CINEMAS]; +} + +function matchesMovie(showtime: DtryxShowtime, movieName?: string): boolean { + if (!movieName) { + return true; + } + + const normalize = (value: string) => value.replace(/\s+/g, '').toLowerCase(); + return normalize(showtime.movieName).includes(normalize(movieName)); +} + +async function getRemainingSeats(args: GetRemainingSeatsArgs): Promise { + const { playDate = toYyyymmdd(), movieName, limit = 50, timeoutMs = 15000 } = args; + const cinemas = resolveCinemas(args); + + if (cinemas.length === 0) { + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + error: + '극장을 찾을 수 없습니다. 카탈로그에 없는 극장 코드는 brandCode 와 함께 지정해야 합니다. 목록은 dtryx_list_cinemas 로 확인할 수 있습니다.', + filters: { cinemaCode: args.cinemaCode || null, keyword: args.keyword || null }, + }, + null, + 2, + ), + }, + ], + isError: true, + }; + } + + const settled = await Promise.allSettled( + cinemas.map((cinema) => + fetchDtryxTimetable({ + brandCode: cinema.brandCode, + cinemaCode: cinema.cinemaCode, + playDate, + timeout: timeoutMs, + }), + ), + ); + + const failedCinemas = cinemas + .filter((_, index) => settled[index]?.status === 'rejected') + .map((cinema) => cinema.cinemaName || cinema.cinemaCode); + const showtimes = settled + .flatMap((result) => (result.status === 'fulfilled' ? result.value : [])) + .filter((showtime) => matchesMovie(showtime, movieName)) + .sort((a, b) => { + if (a.startTime === b.startTime) { + return a.cinemaName.localeCompare(b.cinemaName); + } + return a.startTime.localeCompare(b.startTime); + }) + .slice(0, limit); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + playDate, + filters: { + cinemaCode: args.cinemaCode || null, + keyword: args.keyword || null, + movieName: movieName || null, + limit, + }, + searchedCinemaCount: cinemas.length, + failedCinemas, + count: showtimes.length, + showtimes, + }, + null, + 2, + ), + }, + ], + }; +} + +export function createGetRemainingSeatsTool(): ToolRegistration { + return { + name: 'dtryx_get_remaining_seats', + metadata: { + title: '디트릭스 상영시간표·잔여 좌석 조회', + description: + '디트릭스 독립·예술영화관의 상영 회차와 잔여 좌석을 조회합니다. 극장을 지정하지 않으면 등록된 전체 극장을 조회하므로 특정 영화의 상영관을 찾을 때 movieName 을 함께 쓸 수 있습니다.', + inputSchema: { + playDate: z + .string() + .optional() + .describe('조회 날짜(YYYYMMDD 또는 YYYY-MM-DD, 기본값: 오늘)'), + cinemaCode: z.string().optional().describe('극장 코드 (예: 000067)'), + keyword: z.string().optional().describe('극장명 키워드 (예: 모모, 광주)'), + brandCode: z + .string() + .optional() + .describe('브랜드 코드. 카탈로그에 없는 극장 코드를 직접 조회할 때 함께 지정합니다.'), + movieName: z.string().optional().describe('영화명 부분 일치 필터 (예: 경멸)'), + limit: z.number().optional().default(50).describe('반환할 최대 회차 수 (기본값: 50)'), + timeoutMs: z + .number() + .optional() + .default(15000) + .describe('요청 제한 시간(ms, 기본값: 15000)'), + }, + }, + handler: ((args) => getRemainingSeats(args as GetRemainingSeatsArgs)) as ( + args: unknown, + ) => Promise, + }; +} diff --git a/src/services/dtryx/tools/listCinemas.ts b/src/services/dtryx/tools/listCinemas.ts new file mode 100644 index 00000000..17adf548 --- /dev/null +++ b/src/services/dtryx/tools/listCinemas.ts @@ -0,0 +1,60 @@ +/** + * 디트릭스 극장 목록 조회 도구 + */ + +import * as z from 'zod'; +import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; +import { DTRYX_CINEMAS, findCinemasByKeyword } from '../catalog.js'; + +interface ListCinemasArgs { + keyword?: string; + brandCode?: string; + limit?: number; +} + +async function listCinemas(args: ListCinemasArgs): Promise { + const { keyword, brandCode, limit = 50 } = args; + const matched = (keyword ? findCinemasByKeyword(keyword) : [...DTRYX_CINEMAS]) + .filter((cinema) => (brandCode ? cinema.brandCode === brandCode : true)) + .slice(0, limit); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + filters: { + keyword: keyword || null, + brandCode: brandCode || null, + limit, + }, + count: matched.length, + cinemas: matched, + }, + null, + 2, + ), + }, + ], + }; +} + +export function createListCinemasTool(): ToolRegistration { + return { + name: 'dtryx_list_cinemas', + metadata: { + title: '디트릭스 극장 목록 조회', + description: + '디트릭스 예매 플랫폼을 사용하는 독립·예술영화관 목록을 조회합니다. 극장명 키워드나 브랜드 코드로 걸러낼 수 있습니다.', + inputSchema: { + keyword: z.string().optional().describe('극장명 키워드 (예: 모모, 광주)'), + brandCode: z.string().optional().describe('브랜드 코드 (indieart, etc, spacedog 중 하나)'), + limit: z.number().optional().default(50).describe('반환할 최대 극장 수 (기본값: 50)'), + }, + }, + handler: ((args) => listCinemas(args as ListCinemasArgs)) as ( + args: unknown, + ) => Promise, + }; +} diff --git a/src/services/dtryx/tools/listNowShowing.ts b/src/services/dtryx/tools/listNowShowing.ts new file mode 100644 index 00000000..7c88b993 --- /dev/null +++ b/src/services/dtryx/tools/listNowShowing.ts @@ -0,0 +1,121 @@ +/** + * 디트릭스 현재 상영작 조회 도구 + */ + +import * as z from 'zod'; +import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; +import { findCinemaByCode, findCinemasByKeyword } from '../catalog.js'; +import { fetchDtryxNowShowing, fetchDtryxPlayDates } from '../client.js'; +import type { DtryxCinema } from '../types.js'; + +interface ListNowShowingArgs { + cinemaCode?: string; + keyword?: string; + brandCode?: string; + includePlayDates?: boolean; + timeoutMs?: number; +} + +function resolveCinema(args: ListNowShowingArgs): DtryxCinema | undefined { + if (args.cinemaCode) { + const known = findCinemaByCode(args.cinemaCode); + if (known) { + return known; + } + + return args.brandCode + ? { brandCode: args.brandCode, cinemaCode: args.cinemaCode, cinemaName: '' } + : undefined; + } + + if (args.keyword) { + return findCinemasByKeyword(args.keyword)[0]; + } + + return undefined; +} + +async function listNowShowing(args: ListNowShowingArgs): Promise { + const { includePlayDates = false, timeoutMs = 15000 } = args; + const cinema = resolveCinema(args); + + if (!cinema) { + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + error: + '극장을 찾을 수 없습니다. cinemaCode 를 직접 지정하거나 keyword 로 극장을 지정하세요. 목록은 dtryx_list_cinemas 로 확인할 수 있습니다.', + filters: { cinemaCode: args.cinemaCode || null, keyword: args.keyword || null }, + }, + null, + 2, + ), + }, + ], + isError: true, + }; + } + + const request = { + brandCode: cinema.brandCode, + cinemaCode: cinema.cinemaCode, + timeout: timeoutMs, + }; + const [movies, playDates] = await Promise.all([ + fetchDtryxNowShowing(request), + includePlayDates ? fetchDtryxPlayDates(request) : Promise.resolve([]), + ]); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + cinema, + count: movies.length, + movies, + playDates: includePlayDates ? playDates : undefined, + }, + null, + 2, + ), + }, + ], + }; +} + +export function createListNowShowingTool(): ToolRegistration { + return { + name: 'dtryx_list_now_showing', + metadata: { + title: '디트릭스 현재 상영작 조회', + description: + '디트릭스 독립·예술영화관의 현재 상영작을 조회합니다. 필요하면 예매 가능한 상영 날짜 목록도 함께 반환합니다.', + inputSchema: { + cinemaCode: z.string().optional().describe('극장 코드 (예: 000067)'), + keyword: z.string().optional().describe('극장명 키워드 (예: 모모, 라이카)'), + brandCode: z + .string() + .optional() + .describe('브랜드 코드. 카탈로그에 없는 극장 코드를 직접 조회할 때 함께 지정합니다.'), + includePlayDates: z + .boolean() + .optional() + .default(false) + .describe('예매 가능한 상영 날짜 목록 포함 여부 (기본값: false)'), + timeoutMs: z + .number() + .optional() + .default(15000) + .describe('요청 제한 시간(ms, 기본값: 15000)'), + }, + }, + handler: ((args) => listNowShowing(args as ListNowShowingArgs)) as ( + args: unknown, + ) => Promise, + }; +} diff --git a/src/services/dtryx/types.ts b/src/services/dtryx/types.ts new file mode 100644 index 00000000..a927eb34 --- /dev/null +++ b/src/services/dtryx/types.ts @@ -0,0 +1,88 @@ +/** + * 디트릭스 서비스 전용 타입 정의 + */ + +/** 디트릭스에 등록된 극장 */ +export interface DtryxCinema { + brandCode: string; + cinemaCode: string; + cinemaName: string; +} + +/** 상영 가능 날짜 */ +export interface DtryxPlayDate { + playDate: string; + hidden: boolean; + rest: boolean; +} + +/** 상영 회차 */ +export interface DtryxShowtime { + scheduleId: string; + brandCode: string; + cinemaCode: string; + cinemaName: string; + screenCode: string; + screenName: string; + movieCode: string; + movieName: string; + playDate: string; + startTime: string; + endTime: string; + runningMinutes: number; + rating: string; + totalSeats: number; + remainingSeats: number; + bookedSeats: number; + planStatus: string; +} + +/** 상영작 */ +export interface DtryxMovie { + movieCode: string; + movieName: string; + rating?: string; + runningMinutes?: number; +} + +interface DtryxEnvelope { + RetCode?: string | number; + RetMsg?: string; + RecordCount?: number; + Recordset?: TRecord[]; +} + +export interface DtryxTimetableItem { + CinemaCd?: string; + CinemaNm?: string; + ScreenCd?: string; + ScreenNm?: string; + PlaySDT?: string; + ShowSeq?: string | number; + PlanStatus?: string; + StartTime?: string; + EndTime?: string; + MovieCd?: string; + MovieNm?: string; + RunningTime?: string | number; + RatingNm?: string; + TotalSeatCnt?: string | number; + RemainSeatCnt?: string | number; +} + +export interface DtryxPlayDateItem { + PlaySDT?: string; + HiddenYn?: string; + RestYn?: string; +} + +export interface DtryxMovieItem { + MovieCd?: string; + MovieNm?: string; + RatingNm?: string; + RunningTime?: string | number; +} + +export type DtryxTimetableResponse = DtryxEnvelope; +export type DtryxPlayDateResponse = DtryxEnvelope; +export type DtryxMovieResponse = DtryxEnvelope; diff --git a/tests/api/dtryx-handlers.test.ts b/tests/api/dtryx-handlers.test.ts new file mode 100644 index 00000000..079aefd8 --- /dev/null +++ b/tests/api/dtryx-handlers.test.ts @@ -0,0 +1,156 @@ +/** + * 디트릭스 API 핸들러 테스트 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + handleDtryxGetRemainingSeats, + handleDtryxListCinemas, + handleDtryxListNowShowing, +} from '../../src/api/dtryxHandlers.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, + url: 'https://example.com/api/dtryx/seats', + }, + json: vi.fn().mockImplementation((data, status) => ({ + data, + status: status || 200, + })), + } as unknown as Parameters[0]; +} + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('GET /api/dtryx/cinemas', () => { + it('극장 목록을 반환한다', async () => { + const result = (await handleDtryxListCinemas(createMockContext())) as unknown as { + data: { success: boolean; data: { count: number } }; + }; + + expect(result.data.success).toBe(true); + expect(result.data.data.count).toBeGreaterThan(0); + }); + + it('브랜드 코드로 걸러낸다', async () => { + const result = (await handleDtryxListCinemas( + createMockContext({ brandCode: 'spacedog' }), + )) as unknown as { data: { data: { count: number } } }; + + expect(result.data.data.count).toBe(1); + }); +}); + +describe('GET /api/dtryx/movies', () => { + it('상영작을 반환한다', async () => { + mockFetch.mockResolvedValue(jsonResponse({ Recordset: [{ MovieCd: '1', MovieNm: '경멸' }] })); + + const result = (await handleDtryxListNowShowing( + createMockContext({ cinemaCode: '000067' }), + )) as unknown as { data: { success: boolean; data: { count: number } } }; + + expect(result.data.success).toBe(true); + expect(result.data.data.count).toBe(1); + }); + + it('극장을 찾지 못하면 404 로 응답한다', async () => { + const result = (await handleDtryxListNowShowing( + createMockContext({ keyword: '없는극장' }), + )) as unknown as { status: number }; + + expect(result.status).toBe(404); + }); + + it('업스트림 오류는 500 으로 감싼다', async () => { + mockFetch.mockResolvedValue(jsonResponse({}, 502)); + + const result = (await handleDtryxListNowShowing( + createMockContext({ cinemaCode: '000067' }), + )) as unknown as { status: number }; + + expect(result.status).toBe(500); + }); +}); + +describe('GET /api/dtryx/seats', () => { + it('회차와 좌석을 반환한다', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + Recordset: [ + { + CinemaCd: '000067', + CinemaNm: '아트하우스모모', + ScreenCd: '02', + MovieCd: '1', + MovieNm: '경멸', + PlaySDT: '2026-08-20', + StartTime: '13:30', + TotalSeatCnt: 138, + RemainSeatCnt: 128, + }, + ], + }), + ); + + const result = (await handleDtryxGetRemainingSeats( + createMockContext({ cinemaCode: '000067', playDate: '20260820' }), + )) as unknown as { + data: { data: { count: number; showtimes: Array<{ bookedSeats: number }> } }; + }; + + expect(result.data.data.count).toBe(1); + expect(result.data.data.showtimes[0]?.bookedSeats).toBe(10); + }); + + it('극장을 찾지 못하면 404 로 응답한다', async () => { + const result = (await handleDtryxGetRemainingSeats( + createMockContext({ cinemaCode: '000999' }), + )) as unknown as { status: number }; + + expect(result.status).toBe(404); + }); + + it('영화명 필터를 적용한다', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + Recordset: [ + { + CinemaCd: '000067', + ScreenCd: '02', + MovieCd: '1', + MovieNm: '경멸', + PlaySDT: '2026-08-20', + TotalSeatCnt: 10, + RemainSeatCnt: 5, + }, + ], + }), + ); + + const result = (await handleDtryxGetRemainingSeats( + createMockContext({ cinemaCode: '000067', movieName: '없는영화' }), + )) as unknown as { data: { data: { count: number } } }; + + expect(result.data.data.count).toBe(0); + }); +}); diff --git a/tests/services/dtryx/catalog.test.ts b/tests/services/dtryx/catalog.test.ts new file mode 100644 index 00000000..f36c4239 --- /dev/null +++ b/tests/services/dtryx/catalog.test.ts @@ -0,0 +1,47 @@ +/** + * 디트릭스 극장 카탈로그 테스트 + */ + +import { describe, expect, it } from 'vitest'; +import { + DTRYX_CINEMAS, + findCinemaByCode, + findCinemasByKeyword, +} from '../../../src/services/dtryx/catalog.js'; + +describe('디트릭스 극장 카탈로그', () => { + it('극장 코드가 중복되지 않는다', () => { + const codes = DTRYX_CINEMAS.map((cinema) => cinema.cinemaCode); + expect(new Set(codes).size).toBe(codes.length); + }); + + it('알려진 브랜드 코드만 사용한다', () => { + const brands = new Set(DTRYX_CINEMAS.map((cinema) => cinema.brandCode)); + expect([...brands].sort()).toEqual(['etc', 'indieart', 'spacedog']); + }); + + it('모든 항목에 극장명이 있다', () => { + expect(DTRYX_CINEMAS.every((cinema) => cinema.cinemaName.length > 0)).toBe(true); + }); + + it('키워드 부분 일치로 극장을 찾는다', () => { + expect(findCinemasByKeyword('모모').map((cinema) => cinema.cinemaCode)).toEqual(['000067']); + }); + + it('공백을 무시하고 비교한다', () => { + expect(findCinemasByKeyword('아트하우스 모모')).toHaveLength(1); + }); + + it('키워드가 비면 전체를 반환한다', () => { + expect(findCinemasByKeyword('')).toHaveLength(DTRYX_CINEMAS.length); + }); + + it('일치하는 극장이 없으면 빈 배열이다', () => { + expect(findCinemasByKeyword('존재하지않는극장')).toEqual([]); + }); + + it('극장 코드로 조회한다', () => { + expect(findCinemaByCode('000072')?.cinemaName).toBe('라이카시네마'); + expect(findCinemaByCode('999999')).toBeUndefined(); + }); +}); diff --git a/tests/services/dtryx/client.test.ts b/tests/services/dtryx/client.test.ts new file mode 100644 index 00000000..a239b512 --- /dev/null +++ b/tests/services/dtryx/client.test.ts @@ -0,0 +1,213 @@ +/** + * 디트릭스 클라이언트 테스트 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + fetchDtryxNowShowing, + fetchDtryxPlayDates, + fetchDtryxTimetable, + toCompactDate, + toDashedDate, +} from '../../../src/services/dtryx/client.js'; + +const mockFetch = vi.fn(); + +beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('디트릭스 날짜 변환', () => { + it('YYYYMMDD 를 대시 형식으로 바꾼다', () => { + expect(toDashedDate('20260820')).toBe('2026-08-20'); + }); + + it('이미 대시 형식이면 그대로 둔다', () => { + expect(toDashedDate('2026-08-20')).toBe('2026-08-20'); + }); + + it('대시를 제거해 compact 형식으로 되돌린다', () => { + expect(toCompactDate('2026-08-20')).toBe('20260820'); + }); +}); + +describe('fetchDtryxTimetable', () => { + it('상영 회차와 좌석 수를 변환한다', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + RetMsg: '정상 완료', + RecordCount: 1, + Recordset: [ + { + CinemaCd: '000067', + CinemaNm: '아트하우스모모', + ScreenCd: '02', + ScreenNm: '2관', + PlaySDT: '2026-08-20', + ShowSeq: 3, + PlanStatus: 'confirm', + StartTime: '13:30', + EndTime: '15:13', + MovieCd: '027030', + MovieNm: '콘크리트 녹색섬', + RunningTime: '103', + RatingNm: '전체관람가', + TotalSeatCnt: 138, + RemainSeatCnt: 128, + }, + ], + }), + ); + + const showtimes = await fetchDtryxTimetable({ + brandCode: 'indieart', + cinemaCode: '000067', + playDate: '20260820', + }); + + expect(showtimes).toHaveLength(1); + expect(showtimes[0]).toMatchObject({ + scheduleId: '20260820-000067-02-3', + brandCode: 'indieart', + cinemaName: '아트하우스모모', + screenName: '2관', + movieName: '콘크리트 녹색섬', + playDate: '20260820', + startTime: '13:30', + totalSeats: 138, + remainingSeats: 128, + bookedSeats: 10, + }); + }); + + it('PlaySDT 를 대시 형식으로 요청한다', async () => { + mockFetch.mockResolvedValue(jsonResponse({ Recordset: [] })); + + await fetchDtryxTimetable({ + brandCode: 'indieart', + cinemaCode: '000067', + playDate: '20260820', + }); + + const requestedUrl = String(mockFetch.mock.calls[0]?.[0]); + expect(requestedUrl).toContain('PlaySDT=2026-08-20'); + expect(requestedUrl).not.toContain('PlaySDT=20260820'); + }); + + it('잔여가 총좌석보다 크면 예매 수를 0 으로 둔다', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + Recordset: [ + { + CinemaCd: '000067', + ScreenCd: '01', + MovieCd: '1', + PlaySDT: '2026-08-20', + TotalSeatCnt: 10, + RemainSeatCnt: 20, + }, + ], + }), + ); + + const showtimes = await fetchDtryxTimetable({ + brandCode: 'indieart', + cinemaCode: '000067', + playDate: '20260820', + }); + + expect(showtimes[0]?.bookedSeats).toBe(0); + }); + + it('필수 코드가 없는 항목은 제외한다', async () => { + mockFetch.mockResolvedValue(jsonResponse({ Recordset: [{ CinemaNm: '이름만 있는 항목' }] })); + + const showtimes = await fetchDtryxTimetable({ + brandCode: 'indieart', + cinemaCode: '000067', + playDate: '20260820', + }); + + expect(showtimes).toEqual([]); + }); + + it('HTTP 오류는 상태 코드를 담아 던진다', async () => { + mockFetch.mockResolvedValue(jsonResponse({}, 503)); + + await expect( + fetchDtryxTimetable({ brandCode: 'indieart', cinemaCode: '000067' }), + ).rejects.toThrow('디트릭스 API 호출 실패: 503'); + }); + + it('요청이 취소되면 시간 초과로 알린다', async () => { + const abortError = new Error('aborted'); + abortError.name = 'AbortError'; + mockFetch.mockRejectedValue(abortError); + + await expect( + fetchDtryxTimetable({ brandCode: 'indieart', cinemaCode: '000067' }), + ).rejects.toThrow('디트릭스 API 호출 시간 초과'); + }); +}); + +describe('fetchDtryxPlayDates', () => { + it('상영 날짜와 휴관 여부를 변환한다', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + Recordset: [ + { PlaySDT: '2026-08-20', HiddenYn: 'N', RestYn: 'N' }, + { PlaySDT: '2026-08-22', HiddenYn: 'N', RestYn: 'Y' }, + { HiddenYn: 'N' }, + ], + }), + ); + + const dates = await fetchDtryxPlayDates({ brandCode: 'indieart', cinemaCode: '000067' }); + + expect(dates).toEqual([ + { playDate: '20260820', hidden: false, rest: false }, + { playDate: '20260822', hidden: false, rest: true }, + ]); + }); +}); + +describe('fetchDtryxNowShowing', () => { + it('상영작을 변환하고 코드 없는 항목은 제외한다', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + Recordset: [ + { + MovieCd: '027030', + MovieNm: '콘크리트 녹색섬', + RatingNm: '전체관람가', + RunningTime: '103', + }, + { MovieNm: '코드 없는 영화' }, + ], + }), + ); + + const movies = await fetchDtryxNowShowing({ brandCode: 'indieart', cinemaCode: '000067' }); + + expect(movies).toEqual([ + { + movieCode: '027030', + movieName: '콘크리트 녹색섬', + rating: '전체관람가', + runningMinutes: 103, + }, + ]); + }); +}); diff --git a/tests/services/dtryx/index.test.ts b/tests/services/dtryx/index.test.ts new file mode 100644 index 00000000..60f16e86 --- /dev/null +++ b/tests/services/dtryx/index.test.ts @@ -0,0 +1,36 @@ +/** + * 디트릭스 서비스 프로바이더 테스트 + */ + +import { describe, expect, it } from 'vitest'; +import { createDtryxService } from '../../../src/services/dtryx/index.js'; + +describe('디트릭스 서비스 프로바이더', () => { + it('메타데이터를 노출한다', () => { + const service = createDtryxService(); + + expect(service.metadata.id).toBe('dtryx'); + expect(service.metadata.name).toBe('디트릭스'); + }); + + it('도구 3종을 등록한다', () => { + const names = createDtryxService() + .getTools() + .map((tool) => tool.name) + .sort(); + + expect(names).toEqual([ + 'dtryx_get_remaining_seats', + 'dtryx_list_cinemas', + 'dtryx_list_now_showing', + ]); + }); + + it('모든 도구 이름에 서비스 접두사가 있다', () => { + expect( + createDtryxService() + .getTools() + .every((tool) => tool.name.startsWith('dtryx_')), + ).toBe(true); + }); +}); diff --git a/tests/services/dtryx/tools/dtryxTools.test.ts b/tests/services/dtryx/tools/dtryxTools.test.ts new file mode 100644 index 00000000..958f9e61 --- /dev/null +++ b/tests/services/dtryx/tools/dtryxTools.test.ts @@ -0,0 +1,170 @@ +/** + * 디트릭스 MCP 도구 테스트 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { McpToolResponse } from '../../../../src/core/types.js'; +import { createGetRemainingSeatsTool } from '../../../../src/services/dtryx/tools/getRemainingSeats.js'; +import { createListCinemasTool } from '../../../../src/services/dtryx/tools/listCinemas.js'; +import { createListNowShowingTool } from '../../../../src/services/dtryx/tools/listNowShowing.js'; + +const mockFetch = vi.fn(); + +beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function parsePayload(response: McpToolResponse): Record { + return JSON.parse(response.content[0]?.text ?? '{}') as Record; +} + +function timetableResponse(movieName: string, cinemaName: string) { + return jsonResponse({ + Recordset: [ + { + CinemaCd: '000067', + CinemaNm: cinemaName, + ScreenCd: '02', + ScreenNm: '2관', + PlaySDT: '2026-08-20', + ShowSeq: 1, + StartTime: '13:30', + EndTime: '15:13', + MovieCd: '027030', + MovieNm: movieName, + TotalSeatCnt: 138, + RemainSeatCnt: 128, + }, + ], + }); +} + +describe('dtryx_list_cinemas', () => { + it('브랜드 코드로 걸러낸다', async () => { + const tool = createListCinemasTool(); + const payload = parsePayload(await tool.handler({ brandCode: 'spacedog' })); + + expect(payload.count).toBe(1); + expect((payload.cinemas as Array<{ cinemaName: string }>)[0]?.cinemaName).toBe('라이카시네마'); + }); + + it('limit 으로 개수를 제한한다', async () => { + const payload = parsePayload(await createListCinemasTool().handler({ limit: 3 })); + + expect(payload.count).toBe(3); + }); + + it('네트워크 호출 없이 동작한다', async () => { + await createListCinemasTool().handler({}); + + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + +describe('dtryx_list_now_showing', () => { + it('키워드로 극장을 해석해 상영작을 반환한다', async () => { + mockFetch.mockResolvedValue(jsonResponse({ Recordset: [{ MovieCd: '1', MovieNm: '경멸' }] })); + + const payload = parsePayload(await createListNowShowingTool().handler({ keyword: '모모' })); + + expect(payload.count).toBe(1); + expect((payload.cinema as { cinemaCode: string }).cinemaCode).toBe('000067'); + }); + + it('극장을 찾지 못하면 오류로 응답한다', async () => { + const response = await createListNowShowingTool().handler({ keyword: '없는극장' }); + + expect(response.isError).toBe(true); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('카탈로그에 없는 극장은 brandCode 와 함께 조회한다', async () => { + mockFetch.mockResolvedValue(jsonResponse({ Recordset: [] })); + + const response = await createListNowShowingTool().handler({ + cinemaCode: '000999', + brandCode: 'etc', + }); + + expect(response.isError).toBeUndefined(); + expect(String(mockFetch.mock.calls[0]?.[0])).toContain('CinemaCd=000999'); + }); + + it('brandCode 없이 미등록 극장을 조회하면 오류다', async () => { + const response = await createListNowShowingTool().handler({ cinemaCode: '000999' }); + + expect(response.isError).toBe(true); + }); +}); + +describe('dtryx_get_remaining_seats', () => { + it('영화명으로 걸러낸다', async () => { + mockFetch.mockResolvedValue(timetableResponse('콘크리트 녹색섬', '아트하우스모모')); + + const payload = parsePayload( + await createGetRemainingSeatsTool().handler({ + cinemaCode: '000067', + playDate: '20260820', + movieName: '콘크리트', + }), + ); + + expect(payload.count).toBe(1); + }); + + it('일치하지 않는 영화명은 제외한다', async () => { + mockFetch.mockResolvedValue(timetableResponse('콘크리트 녹색섬', '아트하우스모모')); + + const payload = parsePayload( + await createGetRemainingSeatsTool().handler({ + cinemaCode: '000067', + playDate: '20260820', + movieName: '다른영화', + }), + ); + + expect(payload.count).toBe(0); + }); + + it('극장 미지정이면 카탈로그 전체를 조회한다', async () => { + mockFetch.mockResolvedValue(jsonResponse({ Recordset: [] })); + + const payload = parsePayload( + await createGetRemainingSeatsTool().handler({ playDate: '20260820' }), + ); + + expect(payload.searchedCinemaCount).toBeGreaterThan(1); + expect(mockFetch.mock.calls.length).toBe(payload.searchedCinemaCount); + }); + + it('일부 극장이 실패해도 나머지 결과를 반환한다', async () => { + mockFetch + .mockResolvedValueOnce(timetableResponse('경멸', '아트하우스모모')) + .mockRejectedValue(new Error('upstream down')); + + const payload = parsePayload( + await createGetRemainingSeatsTool().handler({ playDate: '20260820' }), + ); + + expect(payload.count).toBe(1); + expect((payload.failedCinemas as string[]).length).toBeGreaterThan(0); + }); + + it('미등록 극장을 brandCode 없이 조회하면 오류다', async () => { + const response = await createGetRemainingSeatsTool().handler({ cinemaCode: '000999' }); + + expect(response.isError).toBe(true); + }); +}); From c9d439541a365e3eed69dfee04a0003889bd30ca Mon Sep 17 00:00:00 2001 From: bgmbgm94 Date: Wed, 19 Aug 2026 22:01:46 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20=EB=94=94=ED=8A=B8=EB=A6=AD?= =?UTF-8?q?=EC=8A=A4=20=EA=B7=B9=EC=9E=A5=20=EB=A0=88=EC=A7=80=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EB=A5=BC=20location.ts=20=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CGV·롯데시네마·메가박스가 모두 location.ts 를 쓰고 있어 파일명을 맞췄습니다. 디트릭스 응답에는 극장 좌표가 없어 좌표 기반 근접 검색 대신 극장명 매칭만 제공한다는 점을 헤더 주석에 남겼습니다. Co-Authored-By: Claude Opus 5 --- src/api/dtryxHandlers.ts | 2 +- src/services/dtryx/{catalog.ts => location.ts} | 5 ++++- src/services/dtryx/tools/getRemainingSeats.ts | 2 +- src/services/dtryx/tools/listCinemas.ts | 2 +- src/services/dtryx/tools/listNowShowing.ts | 2 +- tests/services/dtryx/{catalog.test.ts => location.test.ts} | 6 +++--- 6 files changed, 11 insertions(+), 8 deletions(-) rename src/services/dtryx/{catalog.ts => location.ts} (91%) rename tests/services/dtryx/{catalog.test.ts => location.test.ts} (90%) diff --git a/src/api/dtryxHandlers.ts b/src/api/dtryxHandlers.ts index f0cc72c8..e9d30409 100644 --- a/src/api/dtryxHandlers.ts +++ b/src/api/dtryxHandlers.ts @@ -6,7 +6,7 @@ import { DTRYX_CINEMAS, findCinemaByCode, findCinemasByKeyword, -} from '../services/dtryx/catalog.js'; +} from '../services/dtryx/location.js'; import { fetchDtryxNowShowing, fetchDtryxPlayDates, diff --git a/src/services/dtryx/catalog.ts b/src/services/dtryx/location.ts similarity index 91% rename from src/services/dtryx/catalog.ts rename to src/services/dtryx/location.ts index 3de8a594..52745cdd 100644 --- a/src/services/dtryx/catalog.ts +++ b/src/services/dtryx/location.ts @@ -1,5 +1,8 @@ /** - * 디트릭스 극장 카탈로그 + * 디트릭스 극장 레지스트리 및 극장 해석 보조 모듈 + * + * 다른 영화관 서비스의 `location.ts` 와 같은 자리이지만, 디트릭스 응답에는 + * 극장 좌표가 없어 좌표 기반 근접 검색 대신 극장명 매칭만 제공합니다. * * 디트릭스는 브랜드/극장 목록을 반환하는 공개 엔드포인트를 제공하지 않습니다. * 그래서 조회로 확인된 극장을 상수로 관리합니다. diff --git a/src/services/dtryx/tools/getRemainingSeats.ts b/src/services/dtryx/tools/getRemainingSeats.ts index dd506eda..e4d3c877 100644 --- a/src/services/dtryx/tools/getRemainingSeats.ts +++ b/src/services/dtryx/tools/getRemainingSeats.ts @@ -4,7 +4,7 @@ import * as z from 'zod'; import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; -import { DTRYX_CINEMAS, findCinemaByCode, findCinemasByKeyword } from '../catalog.js'; +import { DTRYX_CINEMAS, findCinemaByCode, findCinemasByKeyword } from '../location.js'; import { fetchDtryxTimetable, toYyyymmdd } from '../client.js'; import type { DtryxCinema, DtryxShowtime } from '../types.js'; diff --git a/src/services/dtryx/tools/listCinemas.ts b/src/services/dtryx/tools/listCinemas.ts index 17adf548..7f8d5f31 100644 --- a/src/services/dtryx/tools/listCinemas.ts +++ b/src/services/dtryx/tools/listCinemas.ts @@ -4,7 +4,7 @@ import * as z from 'zod'; import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; -import { DTRYX_CINEMAS, findCinemasByKeyword } from '../catalog.js'; +import { DTRYX_CINEMAS, findCinemasByKeyword } from '../location.js'; interface ListCinemasArgs { keyword?: string; diff --git a/src/services/dtryx/tools/listNowShowing.ts b/src/services/dtryx/tools/listNowShowing.ts index 7c88b993..c7966da0 100644 --- a/src/services/dtryx/tools/listNowShowing.ts +++ b/src/services/dtryx/tools/listNowShowing.ts @@ -4,7 +4,7 @@ import * as z from 'zod'; import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; -import { findCinemaByCode, findCinemasByKeyword } from '../catalog.js'; +import { findCinemaByCode, findCinemasByKeyword } from '../location.js'; import { fetchDtryxNowShowing, fetchDtryxPlayDates } from '../client.js'; import type { DtryxCinema } from '../types.js'; diff --git a/tests/services/dtryx/catalog.test.ts b/tests/services/dtryx/location.test.ts similarity index 90% rename from tests/services/dtryx/catalog.test.ts rename to tests/services/dtryx/location.test.ts index f36c4239..6882ca85 100644 --- a/tests/services/dtryx/catalog.test.ts +++ b/tests/services/dtryx/location.test.ts @@ -1,5 +1,5 @@ /** - * 디트릭스 극장 카탈로그 테스트 + * 디트릭스 극장 레지스트리 테스트 */ import { describe, expect, it } from 'vitest'; @@ -7,9 +7,9 @@ import { DTRYX_CINEMAS, findCinemaByCode, findCinemasByKeyword, -} from '../../../src/services/dtryx/catalog.js'; +} from '../../../src/services/dtryx/location.js'; -describe('디트릭스 극장 카탈로그', () => { +describe('디트릭스 극장 레지스트리', () => { it('극장 코드가 중복되지 않는다', () => { const codes = DTRYX_CINEMAS.map((cinema) => cinema.cinemaCode); expect(new Set(codes).size).toBe(codes.length); From 34940671faedba51fc9304cc81c42762b1e00e98 Mon Sep 17 00:00:00 2001 From: bgmbgm94 Date: Thu, 20 Aug 2026 11:15:48 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EB=94=94=ED=8A=B8=EB=A6=AD?= =?UTF-8?q?=EC=8A=A4=20=EA=B7=B9=EC=9E=A5=EC=97=90=20=EC=A7=80=EC=97=AD?= =?UTF-8?q?=C2=B7=EC=A3=BC=EC=86=8C=20=EC=A0=95=EB=B3=B4=EC=99=80=20?= =?UTF-8?q?=EC=A7=80=EC=97=AD=20=ED=95=84=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 극장 목록에 광역 지역명과 도로명 주소를 담고, 지역으로 조회 범위를 좁히는 필터를 추가했습니다. 전국 22개 극장을 모두 조회하지 않고 서울·경기 등 특정 지역만 훑을 수 있습니다. - DtryxCinema 에 region, address 필드 추가 (극장 22곳) - findCinemasByRegion() 추가 - dtryx_list_cinemas, dtryx_get_remaining_seats 에 region 파라미터 추가 - GET /api/dtryx/cinemas, /api/dtryx/seats 에 region 쿼리 추가 주소는 디트릭스 극장 페이지의 안내 정보를 기준으로 정리했습니다. 좌표는 제공되지 않아 포함하지 않았고, 근접 검색도 그대로 제외되어 있습니다. 낭만극장과 허리우드클래식은 같은 건물이라 주소가 동일합니다. Co-Authored-By: Claude Opus 5 --- src/api/dtryxHandlers.ts | 22 ++- src/services/dtryx/location.ts | 187 +++++++++++++++--- src/services/dtryx/tools/getRemainingSeats.ts | 27 ++- src/services/dtryx/tools/listCinemas.ts | 7 +- src/services/dtryx/tools/listNowShowing.ts | 8 +- src/services/dtryx/types.ts | 4 + tests/services/dtryx/location.test.ts | 39 ++++ tests/services/dtryx/tools/dtryxTools.test.ts | 16 ++ 8 files changed, 281 insertions(+), 29 deletions(-) diff --git a/src/api/dtryxHandlers.ts b/src/api/dtryxHandlers.ts index e9d30409..d341b1e5 100644 --- a/src/api/dtryxHandlers.ts +++ b/src/api/dtryxHandlers.ts @@ -6,6 +6,7 @@ import { DTRYX_CINEMAS, findCinemaByCode, findCinemasByKeyword, + findCinemasByRegion, } from '../services/dtryx/location.js'; import { fetchDtryxNowShowing, @@ -21,6 +22,7 @@ function resolveCinemas( cinemaCode: string | undefined, keyword: string | undefined, brandCode: string | undefined, + region?: string, ): DtryxCinema[] { if (cinemaCode) { const known = findCinemaByCode(cinemaCode); @@ -28,29 +30,41 @@ function resolveCinemas( return [known]; } - return brandCode ? [{ brandCode, cinemaCode, cinemaName: '' }] : []; + return brandCode ? [{ brandCode, cinemaCode, cinemaName: '', region: '', address: '' }] : []; } if (keyword) { return findCinemasByKeyword(keyword); } + if (region) { + return findCinemasByRegion(region); + } + return [...DTRYX_CINEMAS]; } export async function handleDtryxListCinemas(c: ApiContext) { const keyword = c.req.query('keyword') || undefined; + const region = c.req.query('region') || undefined; const brandCode = c.req.query('brandCode') || undefined; const limit = parseInt(c.req.query('limit') || '50', 10); + const normalizedRegion = (region || '').replace(/\s+/g, ''); const cinemas = (keyword ? findCinemasByKeyword(keyword) : [...DTRYX_CINEMAS]) + .filter((cinema) => (normalizedRegion ? cinema.region.includes(normalizedRegion) : true)) .filter((cinema) => (brandCode ? cinema.brandCode === brandCode : true)) .slice(0, limit); return successResponse( c, { - filters: { keyword: keyword || null, brandCode: brandCode || null, limit }, + filters: { + keyword: keyword || null, + region: region || null, + brandCode: brandCode || null, + limit, + }, count: cinemas.length, cinemas, }, @@ -108,9 +122,10 @@ export async function handleDtryxGetRemainingSeats(c: ApiContext) { const keyword = c.req.query('keyword') || undefined; const brandCode = c.req.query('brandCode') || undefined; const movieName = c.req.query('movieName') || undefined; + const region = c.req.query('region') || undefined; const limit = parseInt(c.req.query('limit') || '50', 10); const timeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10); - const cinemas = resolveCinemas(cinemaCode, keyword, brandCode); + const cinemas = resolveCinemas(cinemaCode, keyword, brandCode, region); if (cinemas.length === 0) { return errorResponse( @@ -155,6 +170,7 @@ export async function handleDtryxGetRemainingSeats(c: ApiContext) { filters: { cinemaCode: cinemaCode || null, keyword: keyword || null, + region: region || null, movieName: movieName || null, }, searchedCinemaCount: cinemas.length, diff --git a/src/services/dtryx/location.ts b/src/services/dtryx/location.ts index 52745cdd..a77cce35 100644 --- a/src/services/dtryx/location.ts +++ b/src/services/dtryx/location.ts @@ -17,28 +17,160 @@ import type { DtryxCinema } from './types.js'; export const DTRYX_CINEMAS: readonly DtryxCinema[] = [ - { brandCode: 'indieart', cinemaCode: '000053', cinemaName: '씨네아트 리좀' }, - { brandCode: 'indieart', cinemaCode: '000057', cinemaName: '인디플러스포항' }, - { brandCode: 'indieart', cinemaCode: '000059', cinemaName: '오오극장' }, - { brandCode: 'indieart', cinemaCode: '000061', cinemaName: '전주디지털독립영화관' }, - { brandCode: 'indieart', cinemaCode: '000065', cinemaName: '더숲아트시네마' }, - { brandCode: 'indieart', cinemaCode: '000066', cinemaName: '광주극장' }, - { brandCode: 'indieart', cinemaCode: '000067', cinemaName: '아트하우스모모' }, - { brandCode: 'indieart', cinemaCode: '000069', cinemaName: '에무시네마' }, - { brandCode: 'indieart', cinemaCode: '000071', cinemaName: '헤이리시네마' }, - { brandCode: 'spacedog', cinemaCode: '000072', cinemaName: '라이카시네마' }, - { brandCode: 'etc', cinemaCode: '000083', cinemaName: '천안인생극장' }, - { brandCode: 'etc', cinemaCode: '000084', cinemaName: '광주자동차극장' }, - { brandCode: 'etc', cinemaCode: '000088', cinemaName: '아리랑시네센터' }, - { brandCode: 'etc', cinemaCode: '000092', cinemaName: '밀양시네마' }, - { brandCode: 'etc', cinemaCode: '000097', cinemaName: '모퉁이극장' }, - { brandCode: 'etc', cinemaCode: '000098', cinemaName: '씨네인디U' }, - { brandCode: 'etc', cinemaCode: '000100', cinemaName: '애관극장' }, - { brandCode: 'etc', cinemaCode: '000107', cinemaName: '금성시네마' }, - { brandCode: 'etc', cinemaCode: '000113', cinemaName: '낭만극장' }, - { brandCode: 'etc', cinemaCode: '000115', cinemaName: '허리우드클래식' }, - { brandCode: 'etc', cinemaCode: '000116', cinemaName: '명화극장' }, - { brandCode: 'etc', cinemaCode: '000117', cinemaName: '자유로자동차극장' }, + { + brandCode: 'indieart', + cinemaCode: '000053', + cinemaName: '씨네아트 리좀', + region: '경남', + address: '경상남도 창원시 마산합포구 동서북14길 24 (동성동)', + }, + { + brandCode: 'indieart', + cinemaCode: '000057', + cinemaName: '인디플러스포항', + region: '경북', + address: '경상북도 포항시 남구 희망대로 850, 1층 (대도동, 포항문화예술회관)', + }, + { + brandCode: 'indieart', + cinemaCode: '000059', + cinemaName: '오오극장', + region: '대구', + address: '대구광역시 중구 국채보상로 537 (수동)', + }, + { + brandCode: 'indieart', + cinemaCode: '000061', + cinemaName: '전주디지털독립영화관', + region: '전북', + address: '전라북도 전주시 완산구 전주객사3길 22, 2층 (고사동, 전주영화제작소)', + }, + { + brandCode: 'indieart', + cinemaCode: '000065', + cinemaName: '더숲아트시네마', + region: '서울', + address: '서울특별시 노원구 노해로 480, 지하1층 (상계동, 조광빌딩)', + }, + { + brandCode: 'indieart', + cinemaCode: '000066', + cinemaName: '광주극장', + region: '광주', + address: '광주광역시 동구 충장로46번길 10 (충장로5가)', + }, + { + brandCode: 'indieart', + cinemaCode: '000067', + cinemaName: '아트하우스모모', + region: '서울', + address: '서울특별시 서대문구 이화여대길 52, 지하4층 (대현동, 이화여대 ECC)', + }, + { + brandCode: 'indieart', + cinemaCode: '000069', + cinemaName: '에무시네마', + region: '서울', + address: '서울특별시 종로구 경희궁1가길 7, 1~3층·지하1층 (신문로2가)', + }, + { + brandCode: 'indieart', + cinemaCode: '000071', + cinemaName: '헤이리시네마', + region: '경기', + address: '경기도 파주시 탄현면 헤이리마을길 93-119, 지상3층', + }, + { + brandCode: 'spacedog', + cinemaCode: '000072', + cinemaName: '라이카시네마', + region: '서울', + address: '서울특별시 서대문구 연희로8길 18 (연희동)', + }, + { + brandCode: 'etc', + cinemaCode: '000083', + cinemaName: '천안인생극장', + region: '충남', + address: '충청남도 천안시 동남구 버들로 41', + }, + { + brandCode: 'etc', + cinemaCode: '000084', + cinemaName: '광주자동차극장', + region: '광주', + address: '광주광역시 북구 우치로 649', + }, + { + brandCode: 'etc', + cinemaCode: '000088', + cinemaName: '아리랑시네센터', + region: '서울', + address: '서울특별시 성북구 아리랑로12길 4', + }, + { + brandCode: 'etc', + cinemaCode: '000092', + cinemaName: '밀양시네마', + region: '경남', + address: '경상남도 밀양시 백민로 75-1', + }, + { + brandCode: 'etc', + cinemaCode: '000097', + cinemaName: '모퉁이극장', + region: '부산', + address: '부산광역시 중구 광복중앙로 13, 4층', + }, + { + brandCode: 'etc', + cinemaCode: '000098', + cinemaName: '씨네인디U', + region: '대전', + address: '대전광역시 중구 계백로 1712 (문화동, 기독교연합봉사회관 1층)', + }, + { + brandCode: 'etc', + cinemaCode: '000100', + cinemaName: '애관극장', + region: '인천', + address: '인천광역시 중구 경동 238', + }, + { + brandCode: 'etc', + cinemaCode: '000107', + cinemaName: '금성시네마', + region: '충남', + address: '충청남도 부여군 부여읍 사비로100번길 12', + }, + { + brandCode: 'etc', + cinemaCode: '000113', + cinemaName: '낭만극장', + region: '서울', + address: '서울특별시 종로구 삼일대로 428, 4층', + }, + { + brandCode: 'etc', + cinemaCode: '000115', + cinemaName: '허리우드클래식', + region: '서울', + address: '서울특별시 종로구 삼일대로 428, 4층', + }, + { + brandCode: 'etc', + cinemaCode: '000116', + cinemaName: '명화극장', + region: '경기', + address: '경기도 안산시 단원구 중앙대로 921 (고잔동, 동서코아빌딩 지하2층)', + }, + { + brandCode: 'etc', + cinemaCode: '000117', + cinemaName: '자유로자동차극장', + region: '경기', + address: '경기도 파주시 탄현면 필승로 432', + }, ]; /** 극장명 부분 일치로 카탈로그를 검색합니다. */ @@ -58,3 +190,14 @@ export function findCinemasByKeyword(keyword: string): DtryxCinema[] { export function findCinemaByCode(cinemaCode: string): DtryxCinema | undefined { return DTRYX_CINEMAS.find((cinema) => cinema.cinemaCode === cinemaCode); } + +/** 광역 지역명으로 극장을 찾습니다. 부분 일치를 허용합니다. */ +export function findCinemasByRegion(region: string): DtryxCinema[] { + const normalized = region.replace(/\s+/g, ''); + + if (!normalized) { + return [...DTRYX_CINEMAS]; + } + + return DTRYX_CINEMAS.filter((cinema) => cinema.region.includes(normalized)); +} diff --git a/src/services/dtryx/tools/getRemainingSeats.ts b/src/services/dtryx/tools/getRemainingSeats.ts index e4d3c877..9b6ab232 100644 --- a/src/services/dtryx/tools/getRemainingSeats.ts +++ b/src/services/dtryx/tools/getRemainingSeats.ts @@ -4,7 +4,12 @@ import * as z from 'zod'; import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; -import { DTRYX_CINEMAS, findCinemaByCode, findCinemasByKeyword } from '../location.js'; +import { + DTRYX_CINEMAS, + findCinemaByCode, + findCinemasByKeyword, + findCinemasByRegion, +} from '../location.js'; import { fetchDtryxTimetable, toYyyymmdd } from '../client.js'; import type { DtryxCinema, DtryxShowtime } from '../types.js'; @@ -12,6 +17,7 @@ interface GetRemainingSeatsArgs { playDate?: string; cinemaCode?: string; keyword?: string; + region?: string; brandCode?: string; movieName?: string; limit?: number; @@ -27,7 +33,15 @@ function resolveCinemas(args: GetRemainingSeatsArgs): DtryxCinema[] { } return args.brandCode - ? [{ brandCode: args.brandCode, cinemaCode: args.cinemaCode, cinemaName: '' }] + ? [ + { + brandCode: args.brandCode, + cinemaCode: args.cinemaCode, + cinemaName: '', + region: '', + address: '', + }, + ] : []; } @@ -35,6 +49,10 @@ function resolveCinemas(args: GetRemainingSeatsArgs): DtryxCinema[] { return findCinemasByKeyword(args.keyword); } + if (args.region) { + return findCinemasByRegion(args.region); + } + return [...DTRYX_CINEMAS]; } @@ -106,6 +124,7 @@ async function getRemainingSeats(args: GetRemainingSeatsArgs): Promise { - const { keyword, brandCode, limit = 50 } = args; + const { keyword, region, brandCode, limit = 50 } = args; + const normalizedRegion = (region || '').replace(/\s+/g, ''); const matched = (keyword ? findCinemasByKeyword(keyword) : [...DTRYX_CINEMAS]) + .filter((cinema) => (normalizedRegion ? cinema.region.includes(normalizedRegion) : true)) .filter((cinema) => (brandCode ? cinema.brandCode === brandCode : true)) .slice(0, limit); @@ -26,6 +29,7 @@ async function listCinemas(args: ListCinemasArgs): Promise { { filters: { keyword: keyword || null, + region: region || null, brandCode: brandCode || null, limit, }, @@ -49,6 +53,7 @@ export function createListCinemasTool(): ToolRegistration { '디트릭스 예매 플랫폼을 사용하는 독립·예술영화관 목록을 조회합니다. 극장명 키워드나 브랜드 코드로 걸러낼 수 있습니다.', inputSchema: { keyword: z.string().optional().describe('극장명 키워드 (예: 모모, 광주)'), + region: z.string().optional().describe('광역 지역명 (예: 서울, 경기, 부산)'), brandCode: z.string().optional().describe('브랜드 코드 (indieart, etc, spacedog 중 하나)'), limit: z.number().optional().default(50).describe('반환할 최대 극장 수 (기본값: 50)'), }, diff --git a/src/services/dtryx/tools/listNowShowing.ts b/src/services/dtryx/tools/listNowShowing.ts index c7966da0..9f478c09 100644 --- a/src/services/dtryx/tools/listNowShowing.ts +++ b/src/services/dtryx/tools/listNowShowing.ts @@ -24,7 +24,13 @@ function resolveCinema(args: ListNowShowingArgs): DtryxCinema | undefined { } return args.brandCode - ? { brandCode: args.brandCode, cinemaCode: args.cinemaCode, cinemaName: '' } + ? { + brandCode: args.brandCode, + cinemaCode: args.cinemaCode, + cinemaName: '', + region: '', + address: '', + } : undefined; } diff --git a/src/services/dtryx/types.ts b/src/services/dtryx/types.ts index a927eb34..5376105c 100644 --- a/src/services/dtryx/types.ts +++ b/src/services/dtryx/types.ts @@ -7,6 +7,10 @@ export interface DtryxCinema { brandCode: string; cinemaCode: string; cinemaName: string; + /** 광역 지역명 (예: 서울, 경기, 부산) */ + region: string; + /** 도로명 주소 */ + address: string; } /** 상영 가능 날짜 */ diff --git a/tests/services/dtryx/location.test.ts b/tests/services/dtryx/location.test.ts index 6882ca85..44ec17fa 100644 --- a/tests/services/dtryx/location.test.ts +++ b/tests/services/dtryx/location.test.ts @@ -7,6 +7,7 @@ import { DTRYX_CINEMAS, findCinemaByCode, findCinemasByKeyword, + findCinemasByRegion, } from '../../../src/services/dtryx/location.js'; describe('디트릭스 극장 레지스트리', () => { @@ -44,4 +45,42 @@ describe('디트릭스 극장 레지스트리', () => { expect(findCinemaByCode('000072')?.cinemaName).toBe('라이카시네마'); expect(findCinemaByCode('999999')).toBeUndefined(); }); + + it('모든 항목에 지역과 주소가 있다', () => { + expect( + DTRYX_CINEMAS.every((cinema) => cinema.region.length > 0 && cinema.address.length > 0), + ).toBe(true); + }); + + it('주소가 지역 표기와 어긋나지 않는다', () => { + const prefix: Record = { + 서울: '서울특별시', + 경기: '경기도', + 인천: '인천광역시', + 대전: '대전광역시', + 대구: '대구광역시', + 부산: '부산광역시', + 광주: '광주광역시', + 충남: '충청남도', + 전북: '전라북도', + 경남: '경상남도', + 경북: '경상북도', + }; + for (const cinema of DTRYX_CINEMAS) { + expect(cinema.address.startsWith(prefix[cinema.region] ?? '')).toBe(true); + } + }); + + it('지역명으로 극장을 찾는다', () => { + expect(findCinemasByRegion('서울')).toHaveLength(7); + expect(findCinemasByRegion('경기')).toHaveLength(3); + }); + + it('지역명이 비면 전체를 반환한다', () => { + expect(findCinemasByRegion('')).toHaveLength(DTRYX_CINEMAS.length); + }); + + it('없는 지역이면 빈 배열이다', () => { + expect(findCinemasByRegion('제주')).toEqual([]); + }); }); diff --git a/tests/services/dtryx/tools/dtryxTools.test.ts b/tests/services/dtryx/tools/dtryxTools.test.ts index 958f9e61..9e5ebd3b 100644 --- a/tests/services/dtryx/tools/dtryxTools.test.ts +++ b/tests/services/dtryx/tools/dtryxTools.test.ts @@ -60,6 +60,12 @@ describe('dtryx_list_cinemas', () => { expect((payload.cinemas as Array<{ cinemaName: string }>)[0]?.cinemaName).toBe('라이카시네마'); }); + it('지역으로 걸러낸다', async () => { + const payload = parsePayload(await createListCinemasTool().handler({ region: '서울' })); + + expect(payload.count).toBe(7); + }); + it('limit 으로 개수를 제한한다', async () => { const payload = parsePayload(await createListCinemasTool().handler({ limit: 3 })); @@ -138,6 +144,16 @@ describe('dtryx_get_remaining_seats', () => { expect(payload.count).toBe(0); }); + it('지역 지정 시 해당 지역만 조회한다', async () => { + mockFetch.mockResolvedValue(jsonResponse({ Recordset: [] })); + + const payload = parsePayload( + await createGetRemainingSeatsTool().handler({ region: '경기', playDate: '20260820' }), + ); + + expect(payload.searchedCinemaCount).toBe(3); + }); + it('극장 미지정이면 카탈로그 전체를 조회한다', async () => { mockFetch.mockResolvedValue(jsonResponse({ Recordset: [] })); From 8514ea2eb2c6eab3f37e3435c52fd25bf25e57fa Mon Sep 17 00:00:00 2001 From: bgmbgm94 Date: Thu, 20 Aug 2026 13:21:09 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=EB=94=94=ED=8A=B8=EB=A6=AD?= =?UTF-8?q?=EC=8A=A4=20=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=A7=80=20100%=20?= =?UTF-8?q?=EC=B6=A9=EC=A1=B1=20=EB=B0=8F=20=EB=8F=84=EB=8B=AC=20=EB=B6=88?= =?UTF-8?q?=EA=B0=80=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage 게이트(100%)를 통과하지 못해 보완했습니다. - 앱 통합 테스트(tests/app/app-api-dtryx.test.ts)를 추가해 라우트 등록을 검증합니다. 기존 메가박스·롯데시네마와 같은 방식입니다. - 기본값 대입, 정렬 동률, 극장 해석 분기 등 남은 경로를 tests/services/dtryx/ coverage.test.ts 로 채웠습니다. - 좌석 조회 핸들러의 try/catch 를 제거했습니다. Promise.allSettled 로 극장별 실패를 흡수하므로 예외가 전파되지 않아 도달할 수 없는 코드였습니다. 개별 실패는 기존대로 failedCinemas 로 보고합니다. Co-Authored-By: Claude Opus 5 --- src/api/dtryxHandlers.ts | 86 +++--- tests/app/app-api-dtryx.test.ts | 90 +++++++ tests/services/dtryx/coverage.test.ts | 369 ++++++++++++++++++++++++++ 3 files changed, 499 insertions(+), 46 deletions(-) create mode 100644 tests/app/app-api-dtryx.test.ts create mode 100644 tests/services/dtryx/coverage.test.ts diff --git a/src/api/dtryxHandlers.ts b/src/api/dtryxHandlers.ts index d341b1e5..1a486d05 100644 --- a/src/api/dtryxHandlers.ts +++ b/src/api/dtryxHandlers.ts @@ -136,52 +136,46 @@ export async function handleDtryxGetRemainingSeats(c: ApiContext) { ); } - try { - const settled = await Promise.allSettled( - cinemas.map((cinema) => - fetchDtryxTimetable({ - brandCode: cinema.brandCode, - cinemaCode: cinema.cinemaCode, - playDate, - timeout: timeoutMs, - }), - ), - ); - const failedCinemas = cinemas - .filter((_, index) => settled[index]?.status === 'rejected') - .map((cinema) => cinema.cinemaName || cinema.cinemaCode); - const normalize = (value: string) => value.replace(/\s+/g, '').toLowerCase(); - const showtimes = settled - .flatMap((result) => (result.status === 'fulfilled' ? result.value : [])) - .filter((item) => - movieName ? normalize(item.movieName).includes(normalize(movieName)) : true, - ) - .sort((a, b) => - a.startTime === b.startTime - ? a.cinemaName.localeCompare(b.cinemaName) - : a.startTime.localeCompare(b.startTime), - ) - .slice(0, limit); - - return successResponse( - c, - { + // Promise.allSettled 로 극장별 실패를 흡수하므로 여기서 예외가 전파되지 않습니다. + const settled = await Promise.allSettled( + cinemas.map((cinema) => + fetchDtryxTimetable({ + brandCode: cinema.brandCode, + cinemaCode: cinema.cinemaCode, playDate, - filters: { - cinemaCode: cinemaCode || null, - keyword: keyword || null, - region: region || null, - movieName: movieName || null, - }, - searchedCinemaCount: cinemas.length, - failedCinemas, - count: showtimes.length, - showtimes, + timeout: timeoutMs, + }), + ), + ); + const failedCinemas = cinemas + .filter((_, index) => settled[index]?.status === 'rejected') + .map((cinema) => cinema.cinemaName || cinema.cinemaCode); + const normalize = (value: string) => value.replace(/\s+/g, '').toLowerCase(); + const showtimes = settled + .flatMap((result) => (result.status === 'fulfilled' ? result.value : [])) + .filter((item) => (movieName ? normalize(item.movieName).includes(normalize(movieName)) : true)) + .sort((a, b) => + a.startTime === b.startTime + ? a.cinemaName.localeCompare(b.cinemaName) + : a.startTime.localeCompare(b.startTime), + ) + .slice(0, limit); + + return successResponse( + c, + { + playDate, + filters: { + cinemaCode: cinemaCode || null, + keyword: keyword || null, + region: region || null, + movieName: movieName || null, }, - { total: showtimes.length, pageSize: limit }, - ); - } catch (error) { - const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; - return errorResponse(c, 'DTRYX_SEATS_FETCH_FAILED', message, 500); - } + searchedCinemaCount: cinemas.length, + failedCinemas, + count: showtimes.length, + showtimes, + }, + { total: showtimes.length, pageSize: limit }, + ); } diff --git a/tests/app/app-api-dtryx.test.ts b/tests/app/app-api-dtryx.test.ts new file mode 100644 index 00000000..9bd1a15b --- /dev/null +++ b/tests/app/app-api-dtryx.test.ts @@ -0,0 +1,90 @@ +/** + * 앱 통합 테스트 - 디트릭스 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); + +function timetableResponse() { + return new Response( + JSON.stringify({ + Recordset: [ + { + CinemaCd: '000067', + CinemaNm: '아트하우스모모', + ScreenCd: '02', + ScreenNm: '2관', + PlaySDT: '2026-08-20', + ShowSeq: 1, + StartTime: '13:30', + EndTime: '15:13', + MovieCd: '027030', + MovieNm: '콘크리트 녹색섬', + TotalSeatCnt: 138, + RemainSeatCnt: 128, + }, + ], + }), + ); +} + +describe('GET /api/dtryx/cinemas', () => { + it('극장 목록을 반환한다', async () => { + const res = await app.request('/api/dtryx/cinemas'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.data.count).toBeGreaterThan(0); + }); + + it('지역으로 걸러낸다', async () => { + const res = await app.request('/api/dtryx/cinemas?region=경기'); + const data = await res.json(); + + expect(data.data.count).toBe(3); + }); +}); + +describe('GET /api/dtryx/movies', () => { + it('상영작을 반환한다', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ Recordset: [{ MovieCd: '1', MovieNm: '경멸' }] })), + ); + + const res = await app.request('/api/dtryx/movies?cinemaCode=000067'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.data.count).toBe(1); + }); + + it('극장을 찾지 못하면 404 를 반환한다', async () => { + const res = await app.request('/api/dtryx/movies?keyword=없는극장'); + + expect(res.status).toBe(404); + }); +}); + +describe('GET /api/dtryx/seats', () => { + it('회차와 좌석을 반환한다', async () => { + mockFetch.mockResolvedValue(timetableResponse()); + + const res = await app.request('/api/dtryx/seats?cinemaCode=000067&playDate=20260820'); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.data.count).toBe(1); + expect(data.data.showtimes[0].bookedSeats).toBe(10); + }); + + it('극장을 찾지 못하면 404 를 반환한다', async () => { + const res = await app.request('/api/dtryx/seats?cinemaCode=000999'); + + expect(res.status).toBe(404); + }); +}); diff --git a/tests/services/dtryx/coverage.test.ts b/tests/services/dtryx/coverage.test.ts new file mode 100644 index 00000000..17aa6673 --- /dev/null +++ b/tests/services/dtryx/coverage.test.ts @@ -0,0 +1,369 @@ +/** + * 디트릭스 분기 커버리지 보강 테스트 + * + * 기본값 대입, 정렬 동률 처리, 극장 해석 분기 등 앞선 테스트에서 + * 지나가지 않는 경로를 검증합니다. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + handleDtryxGetRemainingSeats, + handleDtryxListCinemas, + handleDtryxListNowShowing, +} from '../../../src/api/dtryxHandlers.js'; +import type { McpToolResponse } from '../../../src/core/types.js'; +import { + fetchDtryxNowShowing, + fetchDtryxPlayDates, + fetchDtryxTimetable, +} from '../../../src/services/dtryx/client.js'; +import { createGetRemainingSeatsTool } from '../../../src/services/dtryx/tools/getRemainingSeats.js'; +import { createListCinemasTool } from '../../../src/services/dtryx/tools/listCinemas.js'; +import { createListNowShowingTool } from '../../../src/services/dtryx/tools/listNowShowing.js'; + +const mockFetch = vi.fn(); + +beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function parsePayload(response: McpToolResponse): Record { + return JSON.parse(response.content[0]?.text ?? '{}') as Record; +} + +function createMockContext(query: Record = {}) { + return { + env: {}, + req: { + query: (key: string) => query[key], + param: () => undefined, + url: 'https://example.com/api/dtryx/seats', + }, + json: vi.fn().mockImplementation((data, status) => ({ data, status: status || 200 })), + } as unknown as Parameters[0]; +} + +/** 같은 시작 시간에 극장만 다른 두 회차 */ +function sameTimeResponse(cinemaName: string) { + return jsonResponse({ + Recordset: [ + { + CinemaCd: '000067', + CinemaNm: cinemaName, + ScreenCd: '01', + MovieCd: '1', + MovieNm: '경멸', + PlaySDT: '2026-08-20', + StartTime: '13:30', + TotalSeatCnt: 10, + RemainSeatCnt: 5, + }, + ], + }); +} + +describe('클라이언트 기본값 분기', () => { + it('playDate 를 생략하면 오늘 날짜로 조회한다', async () => { + mockFetch.mockResolvedValue(jsonResponse({ Recordset: [] })); + + await fetchDtryxTimetable({ brandCode: 'indieart', cinemaCode: '000067' }); + + expect(String(mockFetch.mock.calls[0]?.[0])).toMatch(/PlaySDT=\d{4}-\d{2}-\d{2}/); + }); + + it('선택 필드가 없으면 기본값으로 채운다', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + Recordset: [{ CinemaCd: '000067', ScreenCd: '01', MovieCd: '1', PlaySDT: '2026-08-20' }], + }), + ); + + const showtimes = await fetchDtryxTimetable({ + brandCode: 'indieart', + cinemaCode: '000067', + playDate: '20260820', + }); + + expect(showtimes[0]).toMatchObject({ + cinemaName: '', + screenName: '', + movieName: '', + rating: '', + planStatus: '', + }); + }); + + it('상영작 등급과 상영시간이 없으면 undefined 로 둔다', async () => { + mockFetch.mockResolvedValue(jsonResponse({ Recordset: [{ MovieCd: '1', MovieNm: '경멸' }] })); + + const movies = await fetchDtryxNowShowing({ brandCode: 'indieart', cinemaCode: '000067' }); + + expect(movies[0]?.rating).toBeUndefined(); + expect(movies[0]?.runningMinutes).toBeUndefined(); + }); +}); + +describe('도구 분기', () => { + it('키워드 없이 전체 목록을 반환한다', async () => { + const payload = parsePayload(await createListCinemasTool().handler({})); + + expect(payload.count).toBe(22); + }); + + it('등록된 극장 코드를 지정하면 카탈로그 정보를 쓴다', async () => { + mockFetch.mockResolvedValue(jsonResponse({ Recordset: [] })); + + const payload = parsePayload( + await createListNowShowingTool().handler({ cinemaCode: '000067' }), + ); + + expect((payload.cinema as { cinemaName: string }).cinemaName).toBe('아트하우스모모'); + }); + + it('조건이 하나도 없으면 극장을 찾지 못한다', async () => { + const response = await createListNowShowingTool().handler({}); + + expect(response.isError).toBe(true); + }); + + it('시작 시간이 같으면 극장명 순으로 정렬한다', async () => { + mockFetch + .mockResolvedValueOnce(sameTimeResponse('나극장')) + .mockResolvedValue(sameTimeResponse('가극장')); + + const payload = parsePayload( + await createGetRemainingSeatsTool().handler({ region: '서울', playDate: '20260820' }), + ); + const names = (payload.showtimes as Array<{ cinemaName: string }>).map((s) => s.cinemaName); + + expect(names[0]).toBe('가극장'); + }); +}); + +describe('핸들러 분기', () => { + it('지역으로 조회 범위를 좁힌다', async () => { + mockFetch.mockResolvedValue(jsonResponse({ Recordset: [] })); + + const result = (await handleDtryxGetRemainingSeats( + createMockContext({ region: '경기', playDate: '20260820' }), + )) as unknown as { data: { data: { searchedCinemaCount: number } } }; + + expect(result.data.data.searchedCinemaCount).toBe(3); + }); + + it('조건이 없으면 전체 극장을 조회한다', async () => { + mockFetch.mockResolvedValue(jsonResponse({ Recordset: [] })); + + const result = (await handleDtryxGetRemainingSeats( + createMockContext({ playDate: '20260820' }), + )) as unknown as { data: { data: { searchedCinemaCount: number } } }; + + expect(result.data.data.searchedCinemaCount).toBe(22); + }); + + it('시작 시간이 같으면 극장명 순으로 정렬한다', async () => { + mockFetch + .mockResolvedValueOnce(sameTimeResponse('나극장')) + .mockResolvedValue(sameTimeResponse('가극장')); + + const result = (await handleDtryxGetRemainingSeats( + createMockContext({ region: '서울', playDate: '20260820' }), + )) as unknown as { data: { data: { showtimes: Array<{ cinemaName: string }> } } }; + + expect(result.data.data.showtimes[0]?.cinemaName).toBe('가극장'); + }); + + it('카탈로그에 없는 극장이 실패하면 코드로 표기한다', async () => { + mockFetch.mockRejectedValue(new Error('upstream down')); + + const result = (await handleDtryxGetRemainingSeats( + createMockContext({ cinemaCode: '000999', brandCode: 'etc' }), + )) as unknown as { data: { data: { failedCinemas: string[] } } }; + + expect(result.data.data.failedCinemas).toEqual(['000999']); + }); + + it('키워드로 극장 목록을 걸러낸다', async () => { + const result = (await handleDtryxListCinemas( + createMockContext({ keyword: '모모' }), + )) as unknown as { data: { data: { count: number } } }; + + expect(result.data.data.count).toBe(1); + }); +}); + +describe('남은 분기', () => { + it('Recordset 이 없으면 빈 배열로 처리한다', async () => { + mockFetch.mockResolvedValue(jsonResponse({ RetMsg: '정상 완료' })); + + await expect( + fetchDtryxTimetable({ brandCode: 'indieart', cinemaCode: '000067' }), + ).resolves.toEqual([]); + mockFetch.mockResolvedValue(jsonResponse({ RetMsg: '정상 완료' })); + await expect( + fetchDtryxPlayDates({ brandCode: 'indieart', cinemaCode: '000067' }), + ).resolves.toEqual([]); + mockFetch.mockResolvedValue(jsonResponse({ RetMsg: '정상 완료' })); + await expect( + fetchDtryxNowShowing({ brandCode: 'indieart', cinemaCode: '000067' }), + ).resolves.toEqual([]); + }); + + it('PlaySDT 가 없으면 요청 날짜를 쓴다', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ Recordset: [{ CinemaCd: '000067', ScreenCd: '01', MovieCd: '1' }] }), + ); + + const showtimes = await fetchDtryxTimetable({ + brandCode: 'indieart', + cinemaCode: '000067', + playDate: '20260820', + }); + + expect(showtimes[0]?.playDate).toBe('20260820'); + }); + + it('상영 날짜 포함을 요청하면 함께 반환한다', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ Recordset: [{ MovieCd: '1', MovieNm: '경멸' }] })) + .mockResolvedValueOnce( + jsonResponse({ Recordset: [{ PlaySDT: '2026-08-20', HiddenYn: 'N', RestYn: 'N' }] }), + ); + + const payload = parsePayload( + await createListNowShowingTool().handler({ cinemaCode: '000067', includePlayDates: true }), + ); + + expect((payload.playDates as unknown[]).length).toBe(1); + }); + + it('상영 날짜 포함 요청은 API 에서도 동작한다', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ Recordset: [{ MovieCd: '1', MovieNm: '경멸' }] })) + .mockResolvedValueOnce( + jsonResponse({ Recordset: [{ PlaySDT: '2026-08-20', HiddenYn: 'N', RestYn: 'N' }] }), + ); + + const result = (await handleDtryxListNowShowing( + createMockContext({ cinemaCode: '000067', includePlayDates: 'true' }), + )) as unknown as { data: { data: { playDates: unknown[] } } }; + + expect(result.data.data.playDates.length).toBe(1); + }); + + it('문자열이 아닌 오류도 메시지로 감싼다', async () => { + mockFetch.mockRejectedValue('알 수 없는 실패'); + + const result = (await handleDtryxListNowShowing( + createMockContext({ cinemaCode: '000067' }), + )) as unknown as { status: number }; + + expect(result.status).toBe(500); + }); + + it('도구도 키워드로 극장을 좁힌다', async () => { + mockFetch.mockResolvedValue(jsonResponse({ Recordset: [] })); + + const payload = parsePayload( + await createGetRemainingSeatsTool().handler({ keyword: '모모', playDate: '20260820' }), + ); + + expect(payload.searchedCinemaCount).toBe(1); + }); + + it('도구에서 미등록 극장이 실패하면 코드로 표기한다', async () => { + mockFetch.mockRejectedValue(new Error('upstream down')); + + const payload = parsePayload( + await createGetRemainingSeatsTool().handler({ cinemaCode: '000999', brandCode: 'etc' }), + ); + + expect(payload.failedCinemas).toEqual(['000999']); + }); + + it('시작 시간이 다르면 시간 순으로 정렬한다', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + Recordset: [ + { + CinemaCd: '000067', + CinemaNm: '가극장', + ScreenCd: '01', + MovieCd: '1', + MovieNm: '경멸', + PlaySDT: '2026-08-20', + StartTime: '18:00', + TotalSeatCnt: 10, + RemainSeatCnt: 5, + }, + ], + }), + ) + .mockResolvedValue(sameTimeResponse('나극장')); + + const payload = parsePayload( + await createGetRemainingSeatsTool().handler({ region: '서울', playDate: '20260820' }), + ); + const times = (payload.showtimes as Array<{ startTime: string }>).map((s) => s.startTime); + + expect(times[0]).toBe('13:30'); + }); +}); + +describe('마지막 분기', () => { + it('핸들러도 시작 시간이 다르면 시간 순으로 정렬한다', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + Recordset: [ + { + CinemaCd: '000067', + CinemaNm: '가극장', + ScreenCd: '01', + MovieCd: '1', + MovieNm: '경멸', + PlaySDT: '2026-08-20', + StartTime: '18:00', + TotalSeatCnt: 10, + RemainSeatCnt: 5, + }, + ], + }), + ) + .mockResolvedValue(sameTimeResponse('나극장')); + + const result = (await handleDtryxGetRemainingSeats( + createMockContext({ region: '서울', playDate: '20260820' }), + )) as unknown as { data: { data: { showtimes: Array<{ startTime: string }> } } }; + + expect(result.data.data.showtimes[0]?.startTime).toBe('13:30'); + }); + + it('키워드로 찾지 못하면 키워드를 담아 오류를 돌려준다', async () => { + const response = await createGetRemainingSeatsTool().handler({ keyword: '없는극장' }); + const payload = parsePayload(response); + + expect(response.isError).toBe(true); + expect((payload.filters as { keyword: string | null }).keyword).toBe('없는극장'); + expect((payload.filters as { cinemaCode: string | null }).cinemaCode).toBeNull(); + }); + + it('극장 목록도 키워드로 걸러낸다', async () => { + const payload = parsePayload(await createListCinemasTool().handler({ keyword: '광주' })); + + expect(payload.count).toBe(2); + }); +});