diff --git a/.github/workflows/external-smoke.yml b/.github/workflows/external-smoke.yml
index 8e54cbd0..b1336623 100644
--- a/.github/workflows/external-smoke.yml
+++ b/.github/workflows/external-smoke.yml
@@ -36,7 +36,7 @@ jobs:
- name: Run external smoke sequentially
env:
- CLI_SMOKE_SERVICES: daiso gs25 seveneleven emart24 lottemart oliveyoung megabox lottecinema cgv opinet
+ CLI_SMOKE_SERVICES: daiso gs25 seveneleven emart24 lottemart oliveyoung megabox lottecinema cgv opinet dtryx
MCP_SMOKE_SERVICES: daiso gs25 seveneleven emart24 opinet
SMOKE_ATTEMPT_TIMEOUT_SECONDS: 90
run: |
diff --git a/README.md b/README.md
index c0b03c64..686c2511 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,9 @@
# Daiso MCP 및 Skill
-다이소(제품/매장/재고), 상품 가격 비교, 주변 음식점/카페, 주유소/유가, 개발자 요청 제출, 롯데마트(매장/상품), GS25(매장/상품/재고), 세븐일레븐(상품/매장/재고/인기검색어/카탈로그), CU(매장/재고), 이마트24(매장/상품/재고), 올리브영(매장/재고), 메가박스(지점/영화/시간표/좌석), 롯데시네마(지점/영화/좌석), CGV(극장/영화/시간표) 조회 기능을 MCP, CLI, Codex Skill로 AI에 연결합니다.
+다이소(제품/매장/재고), 상품 가격 비교, 주변 음식점/카페, 주유소/유가, 개발자 요청 제출, 롯데마트(매장/상품), GS25(매장/상품/재고), 세븐일레븐(상품/매장/재고/인기검색어/카탈로그), CU(매장/재고), 이마트24(매장/상품/재고), 올리브영(매장/재고), 메가박스(지점/영화/시간표/좌석), 롯데시네마(지점/영화/좌석), CGV(극장/영화/시간표), 디트릭스(독립·예술영화관/상영작/잔여 좌석) 조회 기능을 MCP, CLI, Codex Skill로 AI에 연결합니다.
+
+디트릭스의 지원 범위와 CLI 예시는 [독립·예술영화관 조회 가이드](docs/dtryx.md)를 참고하세요.
diff --git a/docs/dtryx.md b/docs/dtryx.md
new file mode 100644
index 00000000..67c4a1a9
--- /dev/null
+++ b/docs/dtryx.md
@@ -0,0 +1,37 @@
+# 디트릭스 독립·예술영화관 조회
+
+디트릭스 예매 플랫폼을 사용하는 극장의 상영작, 상영 가능 날짜, 회차별 잔여 좌석을 조회합니다. 공개 원본 API를 직접 사용하며 Zyte나 별도 API 키가 필요하지 않습니다.
+
+## 조회 방법
+
+MCP 도구는 `dtryx_list_cinemas`, `dtryx_list_now_showing`, `dtryx_get_remaining_seats`입니다. 예를 들어 “서울 독립영화관에서 오늘 상영하는 영화와 잔여 좌석을 찾아줘”라고 요청할 수 있습니다.
+
+```bash
+npx daiso get /api/dtryx/cinemas --region 서울
+npx daiso get /api/dtryx/movies --keyword 모모 --includePlayDates true
+npx daiso get /api/dtryx/seats --region 서울 --movieName 경멸 --limit 20
+```
+
+- 상영작 조회에는 `cinemaCode` 또는 극장명 `keyword`가 필요합니다. 검색 결과가 여러 곳이면 첫 번째 극장을 선택하므로 정확한 조회에는 극장 코드를 쓰세요.
+- 회차 조회에 극장 조건이 없으면 카탈로그 전체를 조회합니다. `region`, `keyword`, `brandCode`를 함께 쓰면 모든 조건에 맞는 극장만 조회합니다.
+- `playDate`는 `YYYYMMDD` 또는 `YYYY-MM-DD`를 받으며 생략하면 한국 날짜 기준 오늘입니다. 원본에는 하이픈 날짜로 전달합니다.
+- `limit`은 1~200, `timeoutMs`는 1~60000 정수이며 기본값은 각각 50, 15000입니다.
+- 일부 극장 조회가 실패하면 성공한 회차와 `failedCinemas`를 함께 반환합니다. 모든 극장 조회가 실패하면 API는 503, MCP는 오류를 반환합니다. 성공적으로 조회한 날에 편성이 없으면 빈 목록일 수 있습니다.
+
+## 카탈로그 범위
+
+현재 확인된 극장 22곳을 `src/services/dtryx/location.ts`에서 관리합니다. 전체 제휴 극장 목록을 보장하지 않습니다. 폐업·이전·신규 입점 여부를 정기적으로 확인해야 합니다. 좌표가 없어 거리순 조회는 제공하지 않습니다.
+
+카탈로그에 없는 극장도 공식 `cinemaCode`와 `brandCode`를 함께 알고 있으면 조회할 수 있습니다. 좌석은 조회 시점의 원본 정보로, 예매 과정에서 달라질 수 있습니다.
+
+## 메가박스 CLI
+
+기존 메가박스 API에 전용 CLI 명령을 추가했습니다.
+
+```bash
+npx daiso megabox-theaters 강남 --limit 3
+npx daiso megabox-movies --theaterId 1372
+npx daiso megabox-seats --theaterId 1372 --limit 10
+```
+
+디트릭스는 위의 범용 `get` 명령을 사용합니다. API 명세와 Actions에도 디트릭스 경로가 포함돼 있습니다.
diff --git a/docs/release-1.1.0.md b/docs/release-1.1.0.md
new file mode 100644
index 00000000..9dbf1b8c
--- /dev/null
+++ b/docs/release-1.1.0.md
@@ -0,0 +1,33 @@
+# 1.1.0 릴리스 노트
+
+## 추가
+
+- 디트릭스 독립·예술영화관 22곳의 카탈로그, 상영작·상영 가능 날짜, 회차별 잔여 좌석을 MCP와 GET API로 제공합니다. 지역·극장명·브랜드 필터를 함께 적용하고, 부분 실패와 전체 장애를 구분합니다.
+- 메가박스 CLI 명령 `megabox-theaters`, `megabox-movies`, `megabox-seats`를 추가했습니다.
+- 디트릭스 API 명세, Actions 연결, CLI 사용 가이드와 운영 검사를 추가했습니다.
+
+## 수정
+
+- MCP smoke는 오류 응답을 JSON으로 해석하지 않고 원래 오류를 보존합니다.
+- Zyte 계정 정지·키 누락과 GS25 인증 장애는 자동 재시도 대신 운영 설정 확인을 안내합니다.
+- CU 웹 매장 검색은 공식 GET 경로를 사용합니다. 같은 검색 필드와 기존 Zyte POST 폴백은 유지합니다.
+- 공용 JSON·텍스트 요청의 제한 시간을 응답 본문 읽기까지 적용합니다. 본문 오류에 새 재시도를 추가하지 않습니다.
+- 현재 Node 타입과 호환되도록 인터랙티브 CLI의 표준 입출력 참조를 수정했습니다.
+
+## 이전 npm 배포 이후 누적 반영
+
+7월 14일의 1.0.10 이후 main에 반영된 Home Assistant 스키마 호환, MCP 오류 응답 계약, 한국 날짜 기준 영화 검색, GS25 공개 매장 API, 롯데시네마 잔여 좌석 계산, 운영 호출 제한 통계와 의존성 보안 수정을 포함합니다.
+
+CLI는 원격 API를 호출하므로 서버 측 수정 중 일부는 기존 CLI에서도 적용됐습니다. 이번 릴리스는 패키지의 코드와 명령을 최신 main에 맞춥니다.
+
+## 의존성
+
+MCP SDK 1.30.0, Wrangler 4.131.2, Workers 타입 5.20260914.1, Biome 2.5.13, Node 타입 26.5.1, Playwright 1.63.0, proj4 2.22.0, globals 17.12.0으로 호환 가능한 갱신을 반영했습니다. Node 런타임 최소 버전은 22입니다.
+
+## 운영 제약
+
+릴리스 자체가 외부 계정이나 인증키를 복구하지는 않습니다. Zyte 정지 상태에서 이를 필요로 하는 서비스는 계속 조회 불가일 수 있으며, GS25 재고에는 유효한 GS25_API_KEY가 필요합니다. CU 재고의 운영 HTML 응답과 간헐적인 이마트24 지연은 배포 후 별도로 검증합니다. 비용 한도는 올리지 않았습니다.
+
+## 기여
+
+메가박스 CLI는 helenanova의 PR #183, 디트릭스는 bgmbgm94의 PR #178을 기반으로 통합하고 오류 처리·검증·문서를 보완했습니다.
diff --git a/docs/superpowers/plans/2026-09-15-maintenance.md b/docs/superpowers/plans/2026-09-15-maintenance.md
new file mode 100644
index 00000000..460d3da1
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-15-maintenance.md
@@ -0,0 +1,40 @@
+# September Maintenance Implementation Plan
+
+> Use superpowers:subagent-driven-development for independent implementation and review; operational investigation runs alongside code work.
+
+**Goal:** Restore recoverable service paths, improve diagnostics, integrate compatible maintenance PRs, and release verified code.
+
+**Architecture:** Keep existing Worker, REST, MCP and CLI interfaces. Preserve explicit upstream failures and distinguish account configuration errors from transient failures. No spending-limit increase.
+
+**Tech Stack:** TypeScript, Vitest, Cloudflare Workers, npm, GitHub Actions.
+
+## Task 1: Error diagnostics
+
+Files: scripts/ops/mcp-smoke.ts, src/core/errors.ts, tests/scripts/mcp-smoke.test.ts and existing error tests.
+
+- [ ] Add a failing smoke test returning `{isError:true,content:[{type:'text',text:'Zyte API 호출 실패: 403 Your account has been suspended.'}]}`; assert error is preserved and no JSON parse error appears.
+- [ ] Add diagnostics tests: known Zyte account suspension and missing credential are nonretryable with operator configuration guidance; ordinary upstream WAF 403 wrapped by 500 remains existing behavior; timeouts/429/5xx remain retryable.
+- [ ] Run targeted Vitest tests and confirm intended failures.
+- [ ] Implement minimal error-first parsing and narrowly recognized account/configuration classification. Preserve normal JSON validation.
+- [ ] Run targeted tests, check, coverage, build; independent spec then quality review.
+
+## Task 2: Operational recovery
+
+- [ ] Check authenticated existing health data and Zyte billing/stats read-only, without changing spending limits.
+- [ ] Compare CU and emart24 official requests with deployed failures; inspect GS25 authentication configuration and CGV direct endpoint.
+- [ ] For each confirmed defect, record request evidence and add a failing regression test in the affected client tests before implementation. Do not invent fallback data or convert failures to success.
+- [ ] Validate recovered path in deployment; document unresolved external credentials/account restrictions.
+
+## Task 3: Dependencies and feature PRs
+
+- [ ] Read current PR heads/diffs and lockfile versions; exclude superseded downgrades.
+- [ ] Integrate compatible patch/minor updates, test installation without peer overrides, run audit and required checks.
+- [ ] Review PR #183 and #178 actual code. Preserve contributor attribution when integrating; fill verified test/documentation gaps. Recheck complete coverage.
+- [ ] Resolve superseded PRs and merge verified work within user authorization, preserving original branches.
+
+## Task 4: Release and verification
+
+- [ ] Independent final review, npm run check, npm run test:coverage, npm audit, npm run build, npm run release:dry-run.
+- [ ] Version/tag commit, push matching Git state, publish npm using configured credentials or workflow. Verify registry version and gitHead.
+- [ ] Verify deployment workflow and representative deployed API/MCP calls; record limitations with evidence.
+- [ ] Update personal PROJECT.md and maintenance report, send completion notification.
diff --git a/docs/superpowers/specs/2026-09-15-maintenance-design.md b/docs/superpowers/specs/2026-09-15-maintenance-design.md
new file mode 100644
index 00000000..602793c4
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-15-maintenance-design.md
@@ -0,0 +1,18 @@
+# 9월 유지보수 설계
+
+사용자는 2026-09-15 점검 보고서의 모든 유지보수 진행을 승인했습니다.
+
+## 범위와 완료 기준
+
+1. MCP smoke는 오류 text를 JSON으로 해석하기 전에 isError를 확인하고 원인을 보존합니다. 계정 정지·설정 오류는 재시도 가능 장애와 구분합니다. 정상 응답 계약은 유지합니다.
+2. Zyte 정지 사유와 GS25 인증 상태를 확인하고 CU·이마트24·CGV의 공식 요청 경로를 조사합니다. 확인된 무료 직접 경로가 있으면 복구하고, 없는 경우 명시적 조회 불가를 유지합니다. 비용 한도 상향이나 새 과금 계약은 포함하지 않습니다.
+3. 의존성 PR 10건을 최신 main과 비교해 이미 반영된 항목을 정리하고 호환 가능한 갱신을 검증합니다. 메가박스 CLI와 디트릭스 PR은 코드·테스트·문서 검토 후 가능한 범위를 통합합니다.
+4. 정적 검사, 모든 테스트, 네 지표 100% coverage, audit, 빌드, 패키지 dry-run, 독립 리뷰를 통과한 변경을 GitHub에 반영합니다. 릴리스 커밋과 태그를 일치시키고 npm 게시를 검증합니다. 배포 후 기존 운영 체크를 다시 확인합니다.
+
+## 접근
+
+단순 의존성 갱신만으로는 운영 장애가 해결되지 않습니다. 반대로 유료 우회 확대는 기존 비용 결정을 위반합니다. 오류 원인을 보존하는 작은 수정, 검증된 공식 직접 경로 복구, 호환 의존성 통합을 순차 반영합니다. 장애를 빈 성공으로 숨기거나 확인되지 않은 인증키를 생성하지 않습니다.
+
+## 구조와 검증
+
+기존 scripts/ops/mcp-smoke.ts, src/core/errors.ts와 대응 테스트를 우선 수정합니다. 서비스 수정은 해당 client/api 모듈에 제한하고 450줄을 유지합니다. 오류 회귀는 실패 테스트를 먼저 작성하고 통과시킵니다. 외부 계정 사유와 코드 결함을 분리해 기록합니다. 외부 제약 때문에 실제 조회를 복구하지 못한 경우에는 검증 결과와 남은 조건을 명시합니다.
diff --git a/openapi.json b/openapi.json
index 6636c6eb..9e1e96ff 100644
--- a/openapi.json
+++ b/openapi.json
@@ -22,6 +22,51 @@
"summary": "공통 GET 액션 실행",
"description": "action 값에 따라 기존 GET API 엔드포인트로 프록시합니다. OpenAI Actions 등록 시 이 단일 엔드포인트를 사용하세요.",
"parameters": [
+ {
+ "name": "cinemaCode",
+ "in": "query",
+ "required": false,
+ "description": "디트릭스 극장 코드",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "brandCode",
+ "in": "query",
+ "required": false,
+ "description": "디트릭스 브랜드 코드",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "region",
+ "in": "query",
+ "required": false,
+ "description": "디트릭스 광역 지역명",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "movieName",
+ "in": "query",
+ "required": false,
+ "description": "디트릭스 영화명 부분 일치",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "includePlayDates",
+ "in": "query",
+ "required": false,
+ "description": "디트릭스 상영 가능 날짜 포함",
+ "schema": {
+ "type": "boolean"
+ }
+ },
{
"name": "action",
"in": "query",
@@ -30,6 +75,9 @@
"schema": {
"type": "string",
"enum": [
+ "dtryxListCinemas",
+ "dtryxListNowShowing",
+ "dtryxGetRemainingSeats",
"daisoSearchProducts",
"daisoGetProduct",
"daisoFindStores",
@@ -551,7 +599,7 @@
"name": "timeoutMs",
"in": "query",
"required": false,
- "description": "세븐일레븐 inventory 타임아웃",
+ "description": "원본 요청 타임아웃(ms), 디트릭스는 1~60000",
"schema": {
"type": "integer"
}
diff --git a/openapi.yaml b/openapi.yaml
index 66873ba4..9a81f249 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -24,6 +24,36 @@ paths:
summary: 공통 GET 액션 실행
description: action 값에 따라 기존 GET API 엔드포인트로 프록시합니다. OpenAI Actions 등록 시 이 단일 엔드포인트를 사용하세요.
parameters:
+ - name: cinemaCode
+ in: query
+ required: false
+ description: 디트릭스 극장 코드
+ schema:
+ type: string
+ - name: brandCode
+ in: query
+ required: false
+ description: 디트릭스 브랜드 코드
+ schema:
+ type: string
+ - name: region
+ in: query
+ required: false
+ description: 디트릭스 광역 지역명
+ schema:
+ type: string
+ - name: movieName
+ in: query
+ required: false
+ description: 디트릭스 영화명 부분 일치
+ schema:
+ type: string
+ - name: includePlayDates
+ in: query
+ required: false
+ description: 디트릭스 상영 가능 날짜 포함
+ schema:
+ type: boolean
- name: action
in: query
required: true
@@ -31,6 +61,9 @@ paths:
schema:
type: string
enum:
+ - dtryxListCinemas
+ - dtryxListNowShowing
+ - dtryxGetRemainingSeats
- daisoSearchProducts
- daisoGetProduct
- daisoFindStores
@@ -389,7 +422,7 @@ paths:
- name: timeoutMs
in: query
required: false
- description: 세븐일레븐 inventory 타임아웃
+ description: 원본 요청 타임아웃(ms), 디트릭스는 1~60000
schema:
type: integer
responses:
diff --git a/package-lock.json b/package-lock.json
index 82879eda..9721cbb2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,32 +9,32 @@
"version": "1.0.10",
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/sdk": "^1.29.0",
+ "@modelcontextprotocol/sdk": "^1.30.0",
"hono": "^4.13.7",
- "proj4": "^2.20.9",
+ "proj4": "^2.22.0",
"workers-mcp": "^0.0.13"
},
"bin": {
"daiso": "dist/bin.js"
},
"devDependencies": {
- "@biomejs/biome": "^2.5.5",
- "@cloudflare/workers-types": "^5.20260724.1",
+ "@biomejs/biome": "^2.5.13",
+ "@cloudflare/workers-types": "^5.20260914.1",
"@eslint/js": "^10.0.1",
- "@types/node": "^26.1.0",
+ "@types/node": "^26.5.1",
"@vitest/coverage-v8": "^4.1.11",
"eslint": "^10.8.0",
"eslint-config-prettier": "^10.1.8",
- "globals": "^17.7.0",
+ "globals": "^17.12.0",
"jiti": "^2.7.0",
- "playwright": "^1.62.0",
+ "playwright": "^1.63.0",
"prettier": "^3.9.6",
"sharp": "^0.35.4",
"tsx": "^4.23.1",
"typescript": "6.0.3",
"typescript-eslint": "^8.65.0",
"vitest": "^4.1.11",
- "wrangler": "^4.114.0"
+ "wrangler": "^4.131.2"
},
"engines": {
"node": ">=22"
@@ -97,9 +97,9 @@
}
},
"node_modules/@biomejs/biome": {
- "version": "2.5.5",
- "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.5.tgz",
- "integrity": "sha512-r1S8nFsAG1MY+vJFZALzIvwXAJv6ejDQ0mxP21Tgr9YK3ZFtjrvbBwDdNhx1rUqvccEIeNg20cYCNzl6Cr69pQ==",
+ "version": "2.5.13",
+ "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.13.tgz",
+ "integrity": "sha512-+SEC/mFk1a+5mvUANZgbZTaiZXs1nj4iMhL/PHiqDT5TPUEPFIlliEtkKKZB4N862yylHC3UI+/Sj2I0HJEqhA==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
@@ -113,20 +113,20 @@
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
- "@biomejs/cli-darwin-arm64": "2.5.5",
- "@biomejs/cli-darwin-x64": "2.5.5",
- "@biomejs/cli-linux-arm64": "2.5.5",
- "@biomejs/cli-linux-arm64-musl": "2.5.5",
- "@biomejs/cli-linux-x64": "2.5.5",
- "@biomejs/cli-linux-x64-musl": "2.5.5",
- "@biomejs/cli-win32-arm64": "2.5.5",
- "@biomejs/cli-win32-x64": "2.5.5"
+ "@biomejs/cli-darwin-arm64": "2.5.13",
+ "@biomejs/cli-darwin-x64": "2.5.13",
+ "@biomejs/cli-linux-arm64": "2.5.13",
+ "@biomejs/cli-linux-arm64-musl": "2.5.13",
+ "@biomejs/cli-linux-x64": "2.5.13",
+ "@biomejs/cli-linux-x64-musl": "2.5.13",
+ "@biomejs/cli-win32-arm64": "2.5.13",
+ "@biomejs/cli-win32-x64": "2.5.13"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
- "version": "2.5.5",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.5.tgz",
- "integrity": "sha512-kUrAhXVWUrwmAUnV2iXSK7umxKFysTwvqK+Ty6ptUcLY/7T3SnCAjUowE4uvwaEej6nXZ7hu/dTtbokKdsPeag==",
+ "version": "2.5.13",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.13.tgz",
+ "integrity": "sha512-nYSuDJ6zgVqZUkAkJkvhXxsF2PYIrUk/g638K4voCXz7foI9f7b6C2/7+oAihsHQlivZNMUrm/y8ywlcHQtZOw==",
"cpu": [
"arm64"
],
@@ -141,9 +141,9 @@
}
},
"node_modules/@biomejs/cli-darwin-x64": {
- "version": "2.5.5",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.5.tgz",
- "integrity": "sha512-DamiYc5bUYZ2uxlfc+RLEPtz1Abb6PO5eTbOkufLpSGwd/7AMQAdxhFYiXmwwkJL8IsT8S7GvdgwDHqaMFAvKw==",
+ "version": "2.5.13",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.13.tgz",
+ "integrity": "sha512-KVy1ceEDuJ3AzFxjT9kkxbVy+UANw1pjEMUS6lvKfxjJ+fmkRvc7sQn1Xo6ETgseEI7wUQoV03KSga3XfE8YRQ==",
"cpu": [
"x64"
],
@@ -158,13 +158,16 @@
}
},
"node_modules/@biomejs/cli-linux-arm64": {
- "version": "2.5.5",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.5.tgz",
- "integrity": "sha512-lRKF/pH/1RiYiBKExi3TCZVAtvzEm77aifrvcNiDFrR9WxeAnDUjDnseb6y2XV85mjitLs6SILGm2XG77cHtSQ==",
+ "version": "2.5.13",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.13.tgz",
+ "integrity": "sha512-VlNMtoxOqs0dUR6drxxHr18SNUvI7xxAuHZlH4s/dstYSf3g6RaqVgo8JRnhcOhKz9afWrvtJTboNG1JuYlIDQ==",
"cpu": [
"arm64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -175,13 +178,16 @@
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
- "version": "2.5.5",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.5.tgz",
- "integrity": "sha512-U4WMl/sy/E/Q73vf15VspakLRRs2LDFcCeBxJnQfXzssb88zpV6PJPaQ3ezhQ7H6Ht2/8bvuZeHgJWzmoxllZg==",
+ "version": "2.5.13",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.13.tgz",
+ "integrity": "sha512-CH32xpep3dNS5EVJpAHlYchkBYznxyVZQrx0b6YYYlPL9u9ZeD2NtqiK/6s64+HFS2a7hGnryLlqqZAOh8ax5g==",
"cpu": [
"arm64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -192,13 +198,16 @@
}
},
"node_modules/@biomejs/cli-linux-x64": {
- "version": "2.5.5",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.5.tgz",
- "integrity": "sha512-H/O39nJEw/2Zm/fm7hrmxxoF8kK/aU1uCoPp70ruXVbomaAdLpJJnCmL11Q2JotT8QVHH06So04Oq53lCSwSwQ==",
+ "version": "2.5.13",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.13.tgz",
+ "integrity": "sha512-Fi6gIxbUaJ3ZCIXeG3ggIBPF76O2DjDN67WrPX/ODRv54GeXPm2gbEPC96Yb0yrJoiH0Eax1JLx1Pi0XbsNFZQ==",
"cpu": [
"x64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -209,13 +218,16 @@
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
- "version": "2.5.5",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.5.tgz",
- "integrity": "sha512-m7wC7tjX5Lrmo69dc4md8FeKpPU1NTCY1v7xUoQQ2vadWwNnBS0KZOG8471otFPHrTHihQJAjQPgMObpLvDe6A==",
+ "version": "2.5.13",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.13.tgz",
+ "integrity": "sha512-F3pmwl+VHoUuVJN/tbNLKeLt0SVK3EIyN1jXlMcNyQGYRQQTVqXF+GgkP0/CjGXFN87e/mv0sBfUnWqWza5PKQ==",
"cpu": [
"x64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -226,9 +238,9 @@
}
},
"node_modules/@biomejs/cli-win32-arm64": {
- "version": "2.5.5",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.5.tgz",
- "integrity": "sha512-7BryINPuYypLUAH3o/o5ZdgomJ4zn3EDR0ChZJst7n32S6ZhKbgHXuYydLu+YAnx59ehGFR0z/MG6qnzQi3Yyw==",
+ "version": "2.5.13",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.13.tgz",
+ "integrity": "sha512-+WD13qshXrr0Icv4BfsAdzm8Fs3TL+nZ59zEQxsYNd8lcgZJ0A5+OrlwsL1PipVCmWeRpxgIPD6DAcEd7smkCQ==",
"cpu": [
"arm64"
],
@@ -243,9 +255,9 @@
}
},
"node_modules/@biomejs/cli-win32-x64": {
- "version": "2.5.5",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.5.tgz",
- "integrity": "sha512-bIBFo+n6MIxdNcVFy5CrurbKiZQiUciK3bt8+O9I4wjFZNTfXLpi+giq47522eXqW5NBc9ulx7dR1SlZKi2J5g==",
+ "version": "2.5.13",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.13.tgz",
+ "integrity": "sha512-VOofU/nW761XWzUeUNE8zzYNyPrxuMuNFMizL0qz7J75yeV8N7WFheUlk1/K6dOkaFpH9wJ+lDKlwjAbw0346w==",
"cpu": [
"x64"
],
@@ -307,9 +319,9 @@
}
},
"node_modules/@cloudflare/workerd-darwin-64": {
- "version": "1.20260722.1",
- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260722.1.tgz",
- "integrity": "sha512-vZOP8vIS3NwnuaO+gz0FZ7kIGeiO3bZmxV35Ph9zOXKSREhDFlH7wQ7mkCdhW3O4jnXsew+XT7b+DNEI2CcJGQ==",
+ "version": "1.20260911.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260911.1.tgz",
+ "integrity": "sha512-785eaY1bkR1cm4Z/PCUeteZYmTMe6lre2zz63/GdGGimsoMsKxgl4brFPRukim8iv28EyD1XoCB/VPYF20BERA==",
"cpu": [
"x64"
],
@@ -324,9 +336,9 @@
}
},
"node_modules/@cloudflare/workerd-darwin-arm64": {
- "version": "1.20260722.1",
- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260722.1.tgz",
- "integrity": "sha512-EmIQymihDq6WNdER4+LF8Qn80yqayBUpJ+tkOO7wmY8pmgfyXjIUFNXotl21AHovTeu2seR7HdVUgeN/BilCWw==",
+ "version": "1.20260911.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260911.1.tgz",
+ "integrity": "sha512-WU4bFqEN0H7ndGWxoedegv95DmNVBtv0ncXcHG9nYFTUI78sxEb0qoT3U6Ga4hyBkzsJFBX/zvVBIGX3qKldGA==",
"cpu": [
"arm64"
],
@@ -341,9 +353,9 @@
}
},
"node_modules/@cloudflare/workerd-linux-64": {
- "version": "1.20260722.1",
- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260722.1.tgz",
- "integrity": "sha512-jvZ3k9fxcnEn04s80CgIYxQfpOyAiz/8qC42DP8EBa9tR27qWyg9wmm31zIobVlrgBZn/+8NfdP73avRGcQOjQ==",
+ "version": "1.20260911.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260911.1.tgz",
+ "integrity": "sha512-0Y2gy62oxQxWa38qinSPE6zNL5+JmumJtDY9AWW1HB8KHuATxN71o5MGzmVFfB8PwZsiHfUd2Sv7O22krCOrhw==",
"cpu": [
"x64"
],
@@ -358,9 +370,9 @@
}
},
"node_modules/@cloudflare/workerd-linux-arm64": {
- "version": "1.20260722.1",
- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260722.1.tgz",
- "integrity": "sha512-BOSB55SMNdy+DA5uj2WirgiNanpHGis5PVvXH1wSfvjRKr4JGgWK+EZzxz0RFUo6QjjQQC/NimEzNZ7va7jmKg==",
+ "version": "1.20260911.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260911.1.tgz",
+ "integrity": "sha512-kttNPnx1r2lCqFUoMH62z7CqGV+j4QBbw5fdtaz4pzOrzBv0AWkNATt7onFUe+SwP8zhcepMtbm2F4kKzTf6VA==",
"cpu": [
"arm64"
],
@@ -375,9 +387,9 @@
}
},
"node_modules/@cloudflare/workerd-windows-64": {
- "version": "1.20260722.1",
- "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260722.1.tgz",
- "integrity": "sha512-sYM8YgUpKnRz2xjvdJLX1Ojzoi4MlA4gk8WTTExhGydjYB2UTs5NIbv0ZmpKgMoK9io3ixgmiW56ZnTbcWOdiA==",
+ "version": "1.20260911.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260911.1.tgz",
+ "integrity": "sha512-5iO/YfoBDOgO3CrHdkiiVP8SL3O2jC+c6Ux3d378TSPKLhU5+CgHjtE/ZSodWQrzr4FzFRqdW8S7n5nbyD1MHQ==",
"cpu": [
"x64"
],
@@ -392,12 +404,11 @@
}
},
"node_modules/@cloudflare/workers-types": {
- "version": "5.20260724.1",
- "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260724.1.tgz",
- "integrity": "sha512-gl0brZ60JhkZU3INgr1jsxaFEiWf3AB9RsCjLTMwT5RKspWRUpsFd0wxwbys7gb8Ywkfq9tORhw0y9G+7bDUYw==",
+ "version": "5.20260914.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260914.1.tgz",
+ "integrity": "sha512-9xGgkvmG1lw0QdKJmSR5YNXaA221DfefpDCfPLz5PlzdYU0BXzxONQoWakLXTsFIz8xtsNfeUg+mbspIA5P4Xg==",
"dev": true,
- "license": "MIT OR Apache-2.0",
- "peer": true
+ "license": "MIT OR Apache-2.0"
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
@@ -1613,12 +1624,12 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
- "version": "1.29.0",
- "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
- "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
+ "version": "1.30.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
+ "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
"license": "MIT",
"dependencies": {
- "@hono/node-server": "^1.19.9",
+ "@hono/node-server": "^1.19.9 || ^2.0.5",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
@@ -2021,9 +2032,9 @@
}
},
"node_modules/@speed-highlight/core": {
- "version": "1.2.17",
- "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz",
- "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==",
+ "version": "1.2.24",
+ "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz",
+ "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==",
"dev": true,
"license": "CC0-1.0"
},
@@ -2084,7 +2095,6 @@
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
@@ -2097,14 +2107,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "26.1.1",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
- "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
+ "version": "26.5.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz",
+ "integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "undici-types": "~8.3.0"
+ "undici-types": "~8.9.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
@@ -2152,7 +2161,6 @@
"integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/types": "8.65.0",
@@ -2344,7 +2352,6 @@
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.11",
@@ -2502,7 +2509,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3068,7 +3074,6 @@
"integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"workspaces": [
"packages/*"
],
@@ -3326,7 +3331,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -3654,9 +3658,9 @@
}
},
"node_modules/globals": {
- "version": "17.7.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
- "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==",
+ "version": "17.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz",
+ "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3723,7 +3727,6 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz",
"integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -3902,7 +3905,6 @@
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -4444,7 +4446,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
@@ -4569,22 +4570,19 @@
}
},
"node_modules/miniflare": {
- "version": "4.20260722.0",
- "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260722.0.tgz",
- "integrity": "sha512-LW6ABMhCx/yIEFBLC/DO4yAhdm2T/G7jp7pr5T2kj895+CCIaHZqpMXdW9O6YE48LcYcCJChwWc8aEs1vpbTXw==",
+ "version": "5.20260911.1-alpha",
+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260911.1-alpha.tgz",
+ "integrity": "sha512-7IDj9monoYcCPrS8HfcTt90T3pDwKGvNEAR1Y061KbJhBd5JkStOwOpHMUDFBo9PEbjWVDxPICAnXGNNV2LNfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "0.8.1",
- "sharp": "0.35.2",
- "undici": "7.28.0",
- "workerd": "1.20260722.1",
+ "sharp": "0.35.4",
+ "undici": "7.29.0",
+ "workerd": "1.20260911.1",
"ws": "8.21.0",
"youch": "4.1.0-beta.10"
},
- "bin": {
- "miniflare": "bootstrap.js"
- },
"engines": {
"node": ">=22.0.0"
}
@@ -4902,28 +4900,25 @@
}
},
"node_modules/playwright": {
- "version": "1.62.0",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
- "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "playwright-core": "1.62.0"
+ "playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
- },
- "optionalDependencies": {
- "fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
- "version": "1.62.0",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
- "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -4933,21 +4928,6 @@
"node": ">=20"
}
},
- "node_modules/playwright/node_modules/fsevents": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
- "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
@@ -5004,9 +4984,9 @@
}
},
"node_modules/proj4": {
- "version": "2.20.9",
- "resolved": "https://registry.npmjs.org/proj4/-/proj4-2.20.9.tgz",
- "integrity": "sha512-GLBGqXaTcdWnppre3o1sMmy4DcMGSGq/ng+9k2MTNddarRK6SveINqlqYzi3xEXuy06ljY1TTrC6H9C4f360IQ==",
+ "version": "2.22.0",
+ "resolved": "https://registry.npmjs.org/proj4/-/proj4-2.22.0.tgz",
+ "integrity": "sha512-MVvH09QrYm3A235rcX39WDiCKanrBiblu28l0KYIn/B2DofmYhvP/LLS43kE66jNDfx/P1l/nah3JmsAq8UgbQ==",
"license": "MIT",
"dependencies": {
"mgrs": "1.0.0",
@@ -5559,7 +5539,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -5730,7 +5709,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5786,9 +5764,9 @@
}
},
"node_modules/undici-types": {
- "version": "8.3.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
- "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
},
@@ -5798,7 +5776,6 @@
"integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"pathe": "^2.0.3"
}
@@ -5937,7 +5914,6 @@
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
@@ -6096,13 +6072,12 @@
}
},
"node_modules/workerd": {
- "version": "1.20260722.1",
- "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260722.1.tgz",
- "integrity": "sha512-NycKuc1x2onvsRfGGpM093vRlLFU2zHDAM0+APpccfg4+gZxDGCH27RmdDvkeBuoZyYqgLo3oAfF6re4mvC3vQ==",
+ "version": "1.20260911.1",
+ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260911.1.tgz",
+ "integrity": "sha512-vRr8QdBxueQOZJO1hRCI73EZlix87IAyBAcSyI3rA1VB+6oxjw3oaqzYnIV8C4IOPtUgihbdMAgzkb5GM4V7DQ==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"workerd": "bin/workerd"
},
@@ -6110,11 +6085,11 @@
"node": ">=16"
},
"optionalDependencies": {
- "@cloudflare/workerd-darwin-64": "1.20260722.1",
- "@cloudflare/workerd-darwin-arm64": "1.20260722.1",
- "@cloudflare/workerd-linux-64": "1.20260722.1",
- "@cloudflare/workerd-linux-arm64": "1.20260722.1",
- "@cloudflare/workerd-windows-64": "1.20260722.1"
+ "@cloudflare/workerd-darwin-64": "1.20260911.1",
+ "@cloudflare/workerd-darwin-arm64": "1.20260911.1",
+ "@cloudflare/workerd-linux-64": "1.20260911.1",
+ "@cloudflare/workerd-linux-arm64": "1.20260911.1",
+ "@cloudflare/workerd-windows-64": "1.20260911.1"
}
},
"node_modules/workers-mcp": {
@@ -6144,9 +6119,9 @@
}
},
"node_modules/wrangler": {
- "version": "4.114.0",
- "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.114.0.tgz",
- "integrity": "sha512-M65P25t5UHA1TIJfgZXDcj+YzVobgKdRguM2QPz0xnxLFuOcuE3ErgllDht0iaho7MS4o0g/Bb4YK2+GT+bibg==",
+ "version": "4.131.2",
+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.131.2.tgz",
+ "integrity": "sha512-jmkGE7monbPKyYQr1FPQN+SARVhddqw2fhXOmTKCw4lroqlFGSS6rit/RTvPi/qzNLKrXxkS8DhWXasJnStplg==",
"dev": true,
"license": "MIT OR Apache-2.0",
"dependencies": {
@@ -6154,10 +6129,10 @@
"@cloudflare/unenv-preset": "2.16.1",
"blake3-wasm": "2.1.5",
"esbuild": "0.28.1",
- "miniflare": "4.20260722.0",
+ "miniflare": "5.20260911.1-alpha",
"path-to-regexp": "6.3.0",
"unenv": "2.0.0-rc.24",
- "workerd": "1.20260722.1"
+ "workerd": "1.20260911.1"
},
"bin": {
"cf-wrangler": "bin/cf-wrangler.js",
@@ -6171,7 +6146,7 @@
"fsevents": "2.3.3"
},
"peerDependencies": {
- "@cloudflare/workers-types": "^5.20260722.1"
+ "@cloudflare/workers-types": "^5.20260911.1"
},
"peerDependenciesMeta": {
"@cloudflare/workers-types": {
@@ -6277,7 +6252,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index 80174b5b..8e547cea 100644
--- a/package.json
+++ b/package.json
@@ -80,23 +80,23 @@
"node": ">=22"
},
"devDependencies": {
- "@biomejs/biome": "^2.5.5",
- "@cloudflare/workers-types": "^5.20260724.1",
+ "@biomejs/biome": "^2.5.13",
+ "@cloudflare/workers-types": "^5.20260914.1",
"@eslint/js": "^10.0.1",
- "@types/node": "^26.1.0",
+ "@types/node": "^26.5.1",
"@vitest/coverage-v8": "^4.1.11",
"eslint": "^10.8.0",
"eslint-config-prettier": "^10.1.8",
- "globals": "^17.7.0",
+ "globals": "^17.12.0",
"jiti": "^2.7.0",
- "playwright": "^1.62.0",
+ "playwright": "^1.63.0",
"prettier": "^3.9.6",
"sharp": "^0.35.4",
"tsx": "^4.23.1",
"typescript": "6.0.3",
"typescript-eslint": "^8.65.0",
"vitest": "^4.1.11",
- "wrangler": "^4.114.0"
+ "wrangler": "^4.131.2"
},
"overrides": {
"@modelcontextprotocol/sdk": {
@@ -108,9 +108,9 @@
}
},
"dependencies": {
- "@modelcontextprotocol/sdk": "^1.29.0",
+ "@modelcontextprotocol/sdk": "^1.30.0",
"hono": "^4.13.7",
- "proj4": "^2.20.9",
+ "proj4": "^2.22.0",
"workers-mcp": "^0.0.13"
}
}
diff --git a/scripts/ops/cli-smoke.ts b/scripts/ops/cli-smoke.ts
index 50cf45a1..7b2e55a7 100644
--- a/scripts/ops/cli-smoke.ts
+++ b/scripts/ops/cli-smoke.ts
@@ -30,6 +30,7 @@ type SmokeService =
| 'megabox'
| 'lottecinema'
| 'cgv'
+ | 'dtryx'
| 'opinet';
interface CliSmokeDeps {
@@ -169,8 +170,8 @@ export const CLI_SMOKE_COMMANDS: CliSmokeCommand[] = [
},
{
service: 'megabox',
- scenario: '메가박스 get 위치 검색',
- args: ['get', '/api/megabox/theaters', '--keyword', '강남', '--limit', '1', '--json'],
+ scenario: '메가박스 위치 검색',
+ args: ['megabox-theaters', '강남', '--limit', '1', '--json'],
validate: (stdout) => validateApiEnvelope(stdout, expectDataField('keyword', '강남')),
},
{
@@ -191,6 +192,15 @@ export const CLI_SMOKE_COMMANDS: CliSmokeCommand[] = [
args: ['get', '/api/opinet/average', '--json'],
validate: (stdout) => validateApiEnvelope(stdout, expectDataField('provider', 'opinet')),
},
+ {
+ service: 'dtryx', scenario: '디트릭스 상영작 조회',
+ args: ['get', '/api/dtryx/movies', '--cinemaCode', '000067', '--json'],
+ validate: (stdout) => validateApiEnvelope(stdout, (data) => {
+ const first = Array.isArray(data.movies) ? data.movies[0] : undefined;
+ return isRecord(first) && typeof first.movieCode === 'string' && typeof first.movieName === 'string'
+ ? null : '디트릭스 상영작의 movieCode 또는 movieName이 없습니다';
+ }),
+ },
];
function parseServiceArg(argv: string[]): string | undefined {
diff --git a/scripts/ops/mcp-smoke.ts b/scripts/ops/mcp-smoke.ts
index cfc7c349..9bf543f1 100644
--- a/scripts/ops/mcp-smoke.ts
+++ b/scripts/ops/mcp-smoke.ts
@@ -14,6 +14,7 @@ interface ToolListResult {
}
interface ToolCallResult {
+ isError?: boolean;
content?: Array<{ type: string; text?: string }>;
}
@@ -56,7 +57,12 @@ function isRecord(value: unknown): value is Record {
}
function parseToolPayload(result: ToolCallResult): Record {
- const text = result.content?.find((item) => item.type === 'text' && typeof item.text === 'string')?.text;
+ const text = result.content?.find(
+ (item) => item.type === 'text' && typeof item.text === 'string',
+ )?.text;
+ if (result.isError) {
+ throw new Error(text || 'MCP 도구 호출이 오류를 반환했습니다');
+ }
if (!text) {
throw new Error('도구 호출 결과에 text content가 없습니다');
}
@@ -70,7 +76,9 @@ function parseToolPayload(result: ToolCallResult): Record {
}
function createResponseExcerpt(result: ToolCallResult | undefined): string {
- const text = result?.content?.find((item) => item.type === 'text' && typeof item.text === 'string')?.text;
+ const text = result?.content?.find(
+ (item) => item.type === 'text' && typeof item.text === 'string',
+ )?.text;
return (text || JSON.stringify(result ?? {})).replace(/\s+/g, ' ').slice(0, 500);
}
@@ -87,8 +95,12 @@ function formatScenarioFailure(
].join(' ');
}
-function expectField(fieldName: string, expected: unknown): (payload: Record) => string | null {
- return (payload) => (payload[fieldName] === expected ? null : `${fieldName} 값이 ${String(expected)}가 아닙니다`);
+function expectField(
+ fieldName: string,
+ expected: unknown,
+): (payload: Record) => string | null {
+ return (payload) =>
+ payload[fieldName] === expected ? null : `${fieldName} 값이 ${String(expected)}가 아닙니다`;
}
function validateDaisoInventoryByName(payload: Record): string | null {
@@ -163,7 +175,9 @@ export async function runMcpSmoke(deps: McpSmokeDeps = {}): Promise {
const writeOut = deps.writeOut || ((message: string) => process.stdout.write(`${message}\n`));
const writeErr = deps.writeErr || ((message: string) => process.stderr.write(`${message}\n`));
const service = deps.service || parseServiceArg(process.argv.slice(2));
- const smokeTools = service ? MCP_SMOKE_TOOLS.filter((tool) => tool.service === service) : MCP_SMOKE_TOOLS;
+ const smokeTools = service
+ ? MCP_SMOKE_TOOLS.filter((tool) => tool.service === service)
+ : MCP_SMOKE_TOOLS;
const scenarios = service
? MCP_SMOKE_SCENARIOS.filter((scenario) => scenario.service === service)
: MCP_SMOKE_SCENARIOS;
@@ -207,9 +221,7 @@ export async function runMcpSmoke(deps: McpSmokeDeps = {}): Promise {
}
}
- writeOut(
- `MCP smoke 통과: ${smokeTools.length}개 도구 확인 및 ${scenarios.length}개 호출 완료`,
- );
+ writeOut(`MCP smoke 통과: ${smokeTools.length}개 도구 확인 및 ${scenarios.length}개 호출 완료`);
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : '알 수 없는 오류';
diff --git a/skills/daiso-cli/references/cli-command-map.md b/skills/daiso-cli/references/cli-command-map.md
index 34cebb13..d08acfcd 100644
--- a/skills/daiso-cli/references/cli-command-map.md
+++ b/skills/daiso-cli/references/cli-command-map.md
@@ -73,6 +73,9 @@ Use `--json` for structured output. In shell commands, quoted Korean strings are
- Lotte Cinema theaters: `npx daiso lottecinema-theaters 잠실 --limit 10 --json`
- Lotte Cinema movies: `npx daiso get /api/lottecinema/movies --theaterId --json`
- Lotte Cinema seats: `npx daiso get /api/lottecinema/seats --theaterId --movieId --playDate --json`
+- Megabox theaters: `npx daiso megabox-theaters 강남 --limit 10 --json`
+- Megabox movies: `npx daiso megabox-movies --theaterId --playDate --json`
+- Megabox seats: `npx daiso megabox-seats --theaterId --movieId --playDate --json`
- CGV theaters: `npx daiso cgv-theaters 강남 --limit 10 --json`
- CGV movies: `npx daiso cgv-movies --playDate --theaterCode --json`
- CGV timetable: `npx daiso cgv-timetable --playDate --theaterCode --json`
@@ -84,3 +87,15 @@ Use `--json` for structured output. In shell commands, quoted Korean strings are
- MCP URL: `npx daiso url`
- Help: `npx daiso help`
- Command help: `npx daiso help products`
+
+## 디트릭스 독립·예술영화관
+
+전용 명령 대신 범용 GET을 사용합니다. 극장 카탈로그는 확인된 22곳이며 전체 제휴 극장을 보장하지 않습니다.
+
+```bash
+daiso get /api/dtryx/cinemas --region 서울
+daiso get /api/dtryx/movies --keyword 모모 --includePlayDates true
+daiso get /api/dtryx/seats --region 서울 --limit 20
+```
+
+상영작은 cinemaCode 또는 keyword가 필요합니다. 날짜는 YYYYMMDD 또는 YYYY-MM-DD이며 기본값은 한국 날짜 기준 오늘입니다. 일부 극장 실패는 failedCinemas에 표시하고 전체 실패는 오류입니다.
diff --git a/src/api/actionsProxy.ts b/src/api/actionsProxy.ts
index dd140e43..6d95dce6 100644
--- a/src/api/actionsProxy.ts
+++ b/src/api/actionsProxy.ts
@@ -10,6 +10,9 @@ export interface ActionQueryDefinition {
}
export const ACTION_QUERY_DEFINITIONS: ActionQueryDefinition[] = [
+ { action: 'dtryxListCinemas', summary: '디트릭스 극장 목록', targetPath: '/api/dtryx/cinemas' },
+ { action: 'dtryxListNowShowing', summary: '디트릭스 상영작', targetPath: '/api/dtryx/movies' },
+ { action: 'dtryxGetRemainingSeats', summary: '디트릭스 잔여 좌석', targetPath: '/api/dtryx/seats' },
{ action: 'daisoSearchProducts', summary: '다이소 제품 검색', targetPath: '/api/daiso/products' },
{
action: 'daisoGetProduct',
diff --git a/src/api/dtryxHandlers.ts b/src/api/dtryxHandlers.ts
new file mode 100644
index 00000000..fc197203
--- /dev/null
+++ b/src/api/dtryxHandlers.ts
@@ -0,0 +1,162 @@
+/**
+ * 디트릭스 GET API 핸들러
+ */
+
+import { resolveDtryxCinemas } from '../services/dtryx/location.js';
+import { DTRYX_INVALID_INPUT, hasInvalidDtryxOptions } from '../services/dtryx/validation.js';
+import {
+ fetchDtryxNowShowing,
+ fetchDtryxPlayDates,
+ fetchDtryxTimetable,
+ toYyyymmdd,
+} from '../services/dtryx/client.js';
+import { type ApiContext, errorResponse, successResponse } from './response.js';
+
+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 = Number(c.req.query('limit') ?? '50');
+ if (hasInvalidDtryxOptions({ limit })) {
+ return errorResponse(c, 'DTRYX_INVALID_INPUT', DTRYX_INVALID_INPUT, 400);
+ }
+ const cinemas = resolveDtryxCinemas({ keyword, region, brandCode }).slice(0, limit);
+
+ return successResponse(
+ c,
+ {
+ filters: {
+ keyword: keyword || null,
+ region: region || 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 = Number(c.req.query('timeoutMs') ?? '15000');
+ if (hasInvalidDtryxOptions({ timeoutMs })) {
+ return errorResponse(c, 'DTRYX_INVALID_INPUT', DTRYX_INVALID_INPUT, 400);
+ }
+ const cinema =
+ cinemaCode || keyword ? resolveDtryxCinemas({ cinemaCode, keyword, brandCode })[0] : undefined;
+
+ 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 region = c.req.query('region') || undefined;
+ const limit = Number(c.req.query('limit') ?? '50');
+ const timeoutMs = Number(c.req.query('timeoutMs') ?? '15000');
+ if (hasInvalidDtryxOptions({ limit, timeoutMs, playDate })) {
+ return errorResponse(c, 'DTRYX_INVALID_INPUT', DTRYX_INVALID_INPUT, 400);
+ }
+ const cinemas = resolveDtryxCinemas({ cinemaCode, keyword, brandCode, region });
+
+ if (cinemas.length === 0) {
+ return errorResponse(
+ c,
+ 'DTRYX_CINEMA_NOT_FOUND',
+ '극장을 찾을 수 없습니다. cinemaCode 또는 keyword 를 확인하세요.',
+ 404,
+ );
+ }
+
+ // Promise.allSettled 로 극장별 실패를 흡수하므로 여기서 예외가 전파되지 않습니다.
+ 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);
+ if (failedCinemas.length === cinemas.length) {
+ return errorResponse(
+ c,
+ 'DTRYX_SEATS_FAILED',
+ `모든 극장 조회에 실패했습니다: ${failedCinemas.join(', ')}`,
+ 503,
+ );
+ }
+ 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,
+ },
+ searchedCinemaCount: cinemas.length,
+ failedCinemas,
+ count: showtimes.length,
+ showtimes,
+ },
+ { total: showtimes.length, pageSize: limit },
+ );
+}
diff --git a/src/api/healthCheckDefinitions.ts b/src/api/healthCheckDefinitions.ts
index 663a66e6..b557bad7 100644
--- a/src/api/healthCheckDefinitions.ts
+++ b/src/api/healthCheckDefinitions.ts
@@ -21,6 +21,15 @@ export const SEVENELEVEN_UPSTREAM_403_PATTERNS = [
];
export const HEALTH_CHECKS: HealthCheckDefinition[] = [
+ {
+ id: 'dtryx.movies',
+ service: 'dtryx',
+ target: 'movies',
+ mode: 'deep',
+ path: '/api/dtryx/movies?cinemaCode=000067',
+ collectionKey: 'movies',
+ requiredFields: ['movieCode', 'movieName'],
+ },
{
id: 'cli.contract',
service: 'cli',
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/cli.ts b/src/cli.ts
index 18f0da54..8d003870 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -48,6 +48,9 @@ import {
handleLottecinemaTheaters,
handleLottecinemaMovies,
handleLottecinemaSeats,
+ handleMegaboxTheaters,
+ handleMegaboxMovies,
+ handleMegaboxSeats,
} from './cli/commands/cinema.js';
export type { CliDeps } from './cli/types.js';
@@ -179,6 +182,10 @@ export async function runCli(argv: string[], deps?: Partial): Promise {
+ return await runApiCommand({
+ command: 'megabox-theaters',
+ options,
+ deps,
+ path: '/api/megabox/theaters',
+ configure: applyKeywordPositional,
+ });
+}
+
+export async function handleMegaboxMovies(options: string[], deps: CliDeps): Promise {
+ return await runApiCommand({
+ command: 'megabox-movies',
+ options,
+ deps,
+ path: '/api/megabox/movies',
+ configure: applyKeywordPositional,
+ });
+}
+
+export async function handleMegaboxSeats(options: string[], deps: CliDeps): Promise {
+ return await runApiCommand({
+ command: 'megabox-seats',
+ options,
+ deps,
+ path: '/api/megabox/seats',
+ configure: applyKeywordPositional,
+ });
+}
+
export async function handleCgvTheaters(options: string[], deps: CliDeps): Promise {
return await runApiCommand({
command: 'cgv-theaters',
diff --git a/src/cliHelpDefinitions.ts b/src/cliHelpDefinitions.ts
index 61d9bee2..b356ffb0 100644
--- a/src/cliHelpDefinitions.ts
+++ b/src/cliHelpDefinitions.ts
@@ -24,6 +24,9 @@ export type CommandName =
| 'lottecinema-theaters'
| 'lottecinema-movies'
| 'lottecinema-seats'
+ | 'megabox-theaters'
+ | 'megabox-movies'
+ | 'megabox-seats'
| 'lottemart-stores'
| 'lottemart-products'
| 'emart24-stores'
@@ -60,6 +63,9 @@ export const COMMAND_LIST: CommandName[] = [
'lottecinema-theaters',
'lottecinema-movies',
'lottecinema-seats',
+ 'megabox-theaters',
+ 'megabox-movies',
+ 'megabox-seats',
'lottemart-stores',
'lottemart-products',
'emart24-stores',
@@ -97,6 +103,9 @@ export const COMMAND_SUMMARY: Record = {
'lottecinema-theaters': '롯데시네마 주변 지점 조회',
'lottecinema-movies': '롯데시네마 영화/회차 조회',
'lottecinema-seats': '롯데시네마 잔여 좌석 조회',
+ 'megabox-theaters': '메가박스 주변 지점 조회',
+ 'megabox-movies': '메가박스 영화/회차 조회',
+ 'megabox-seats': '메가박스 잔여 좌석 조회',
'lottemart-stores': '롯데마트 매장 검색',
'lottemart-products': '롯데마트 상품 검색',
'emart24-stores': '이마트24 매장 검색',
@@ -271,6 +280,30 @@ export const COMMAND_DETAIL: Record = {
'예시: daiso lottecinema-seats 잠실 --playDate 20260310 --movieId 23816',
'예시: daiso lottecinema-seats --playDate 20260310 --keyword "안산 중앙역" --movieId 23816',
],
+ 'megabox-theaters': [
+ '명령: megabox-theaters',
+ '설명: 메가박스 주변 지점 API를 호출합니다.',
+ '사용법: daiso megabox-theaters [keyword] [--lat 값] [--lng 값] [--playDate YYYYMMDD] [--areaCode 값] [--limit N] [--json]',
+ '옵션: --keyword, --lat, --lng, --playDate, --areaCode, --limit, --json',
+ '예시: daiso megabox-theaters 강남 --limit 5',
+ '예시: daiso megabox-theaters --keyword "코엑스" --limit 5',
+ ],
+ 'megabox-movies': [
+ '명령: megabox-movies',
+ '설명: 메가박스 영화/회차 API를 호출합니다.',
+ '사용법: daiso megabox-movies [keyword] [--playDate YYYYMMDD] [--theaterId 값] [--movieId 값] [--areaCode 값] [--lat 값] [--lng 값] [--json]',
+ '옵션: --playDate, --theaterId, --movieId, --keyword, --areaCode, --lat, --lng, --json',
+ '예시: daiso megabox-movies 강남 --playDate 20260310',
+ '예시: daiso megabox-movies --playDate 20260310 --keyword "코엑스"',
+ ],
+ 'megabox-seats': [
+ '명령: megabox-seats',
+ '설명: 메가박스 잔여 좌석 API를 호출합니다.',
+ '사용법: daiso megabox-seats [keyword] [--playDate YYYYMMDD] [--theaterId 값] [--movieId 값] [--areaCode 값] [--lat 값] [--lng 값] [--limit N] [--json]',
+ '옵션: --playDate, --theaterId, --movieId, --keyword, --areaCode, --lat, --lng, --limit, --json',
+ '예시: daiso megabox-seats 강남 --playDate 20260310 --movieId 23816',
+ '예시: daiso megabox-seats --playDate 20260310 --keyword "코엑스" --movieId 23816',
+ ],
'lottemart-stores': [
'명령: lottemart-stores',
'설명: 롯데마트 계열 매장 검색 API를 호출합니다.',
diff --git a/src/cliInteractive.ts b/src/cliInteractive.ts
index 8a7ac8aa..17bda290 100644
--- a/src/cliInteractive.ts
+++ b/src/cliInteractive.ts
@@ -5,10 +5,16 @@
*/
import { createInterface } from 'node:readline/promises';
-import { stdin as input, stdout as output } from 'node:process';
+import process from 'node:process';
import { pickFromList } from './cliPicker.js';
import { fetchEnvelope, fetchStoresWithKeywordFallback } from './cli/interactiveFetch.js';
-import { askMenu, askNextAction, askNonEmpty, askYesNo, printStoreDetail } from './cli/interactivePrompt.js';
+import {
+ askMenu,
+ askNextAction,
+ askNonEmpty,
+ askYesNo,
+ printStoreDetail,
+} from './cli/interactivePrompt.js';
import type { InteractiveCliDeps, InteractivePrompt } from './cli/interactiveTypes.js';
import {
parseLotteCinemaTheaters,
@@ -18,7 +24,13 @@ import {
runLotteCinemaSearch,
runOliveyoungItemSearch,
} from './cli/interactiveItemSearch.js';
-import { buildDaisoStoreKeywordVariants, isRecord, parseDaisoProducts, parseStores, toText } from './utils/cliInteractiveHelpers.js';
+import {
+ buildDaisoStoreKeywordVariants,
+ isRecord,
+ parseDaisoProducts,
+ parseStores,
+ toText,
+} from './utils/cliInteractiveHelpers.js';
export type { InteractiveCliDeps } from './cli/interactiveTypes.js';
@@ -41,7 +53,11 @@ export function inferDaisoFreeTextIntent(input: string): DaisoFreeTextIntent {
const locationIndex = tokens.findIndex(isLocationToken);
if (locationIndex >= 0) {
const storeKeyword = tokens[locationIndex];
- const productQuery = tokens.filter((_, index) => index !== locationIndex).join(' ').trim() || input.trim();
+ const productQuery =
+ tokens
+ .filter((_, index) => index !== locationIndex)
+ .join(' ')
+ .trim() || input.trim();
return { productQuery, storeKeyword };
}
@@ -74,7 +90,7 @@ async function askServiceChoice(
}
function createPrompt(): InteractivePrompt {
- const rl = createInterface({ input, output });
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
return {
ask: async (question: string) => {
@@ -133,7 +149,11 @@ export async function runInteractiveCli(deps: InteractiveCliDeps): Promise
- `${index + 1}. ${store.name} | ${store.address || '주소 정보 없음'}`,
- filterText: (store) => `${store.name} ${store.address} ${store.phone}`,
- indexText: '입력: 번호 선택 | /키워드 필터 | all 전체보기 | 0 다시 검색',
- });
+ const selectedStore =
+ stores.length === 1
+ ? stores[0]
+ : await pickFromList({
+ prompt,
+ writeOut: deps.writeOut,
+ title: '[매장 선택]',
+ emptyText: '검색된 매장이 없습니다.',
+ cancelText: '매장 검색으로 돌아갑니다.',
+ items: stores,
+ renderItem: (store, index) =>
+ `${index + 1}. ${store.name} | ${store.address || '주소 정보 없음'}`,
+ filterText: (store) => `${store.name} ${store.address} ${store.phone}`,
+ indexText: '입력: 번호 선택 | /키워드 필터 | all 전체보기 | 0 다시 검색',
+ });
if (!selectedStore) {
continue;
}
@@ -230,7 +251,11 @@ export async function runInteractiveCli(deps: InteractiveCliDeps): Promise 0 && storeResult.matchedKeyword !== storeKeyword) {
diff --git a/src/core/errors.ts b/src/core/errors.ts
index 202c0875..814021f7 100644
--- a/src/core/errors.ts
+++ b/src/core/errors.ts
@@ -23,6 +23,7 @@ const SERVICE_PREFIXES = [
'megabox',
'lottecinema',
'cgv',
+ 'dtryx',
'oliveyoung',
] as const;
@@ -38,7 +39,9 @@ function toOperation(parts: string[]): string | undefined {
return filtered.map((part) => part.toLowerCase()).join('_');
}
-function inferServiceAndOperation(code: string): Pick {
+function inferServiceAndOperation(
+ code: string,
+): Pick {
const parts = code.split('_').filter((part) => part.length > 0);
const normalizedFirst = normalizeService(parts[0] || '');
const service = SERVICE_PREFIXES.find((item) => normalizeService(item) === normalizedFirst);
@@ -79,7 +82,19 @@ export function toStandardErrorDiagnostics(
} = {},
): StandardErrorDiagnostics {
const inferred = inferServiceAndOperation(code);
- const retryable = isRetryable(code, options.status || options.upstreamStatus);
+ // 실제 Zyte 진단 문구만 설정 오류로 취급해 일반적인 접근 차단과 구분합니다.
+ const configurationError = [
+ 'Zyte API 호출 실패: 403 Your account has been suspended.',
+ 'Zyte API 호출 실패: 403 account suspended',
+ 'ZYTE_API_KEY가 설정되지 않았습니다.',
+ ].some((diagnostic) => message.includes(diagnostic));
+ const gs25AuthenticationError =
+ code === 'GS25_UPSTREAM_UNAVAILABLE' &&
+ message.includes('GS25 재고 서비스 인증을 사용할 수 없습니다.');
+ const retryable =
+ !configurationError &&
+ !gs25AuthenticationError &&
+ isRetryable(code, options.status || options.upstreamStatus);
return {
code,
@@ -89,7 +104,11 @@ export function toStandardErrorDiagnostics(
service: options.service || inferred.service,
operation: options.operation || inferred.operation,
upstreamStatus: options.upstreamStatus,
- hint: buildHint(retryable),
+ hint: gs25AuthenticationError
+ ? '운영자는 GS25_API_KEY 설정을 확인하세요.'
+ : configurationError
+ ? '운영자는 ZYTE_API_KEY 설정과 Zyte 계정 상태를 확인하세요.'
+ : buildHint(retryable),
};
}
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/pages/openapiFullSpec.ts b/src/pages/openapiFullSpec.ts
index b5358947..5e3ca423 100644
--- a/src/pages/openapiFullSpec.ts
+++ b/src/pages/openapiFullSpec.ts
@@ -15,6 +15,7 @@ import { OPENAPI_PATHS_PLACES } from './openapiSpecPathsPlaces.js';
import { OPENAPI_PATHS_OPINET } from './openapiSpecPathsOpinet.js';
import { OPENAPI_PATHS_COMPARE } from './openapiSpecPathsCompare.js';
import { OPENAPI_PATHS_FEEDBACK } from './openapiSpecPathsFeedback.js';
+import { OPENAPI_PATHS_DTRYX } from './openapiSpecPathsDtryx.js';
import { OPENAPI_COMPONENTS } from './openapiSpecComponents.js';
export function generateFullOpenApiSpec(baseUrl: string): object {
@@ -65,6 +66,7 @@ export function generateFullOpenApiSpec(baseUrl: string): object {
...OPENAPI_PATHS_MEGABOX,
...OPENAPI_PATHS_LOTTECINEMA,
...OPENAPI_PATHS_CGV,
+ ...OPENAPI_PATHS_DTRYX,
},
components: OPENAPI_COMPONENTS,
};
diff --git a/src/pages/openapiSpecActionParameters.ts b/src/pages/openapiSpecActionParameters.ts
index 5f0aa8c5..58384c3e 100644
--- a/src/pages/openapiSpecActionParameters.ts
+++ b/src/pages/openapiSpecActionParameters.ts
@@ -4,6 +4,41 @@
import { ACTION_QUERY_ACTIONS } from '../api/actionsProxy.js';
export const ACTION_QUERY_PARAMETERS = [
+ {
+ name: 'cinemaCode',
+ in: 'query',
+ required: false,
+ description: '디트릭스 극장 코드',
+ schema: { type: 'string' },
+ },
+ {
+ name: 'brandCode',
+ in: 'query',
+ required: false,
+ description: '디트릭스 브랜드 코드',
+ schema: { type: 'string' },
+ },
+ {
+ name: 'region',
+ in: 'query',
+ required: false,
+ description: '디트릭스 광역 지역명',
+ schema: { type: 'string' },
+ },
+ {
+ name: 'movieName',
+ in: 'query',
+ required: false,
+ description: '디트릭스 영화명 부분 일치',
+ schema: { type: 'string' },
+ },
+ {
+ name: 'includePlayDates',
+ in: 'query',
+ required: false,
+ description: '디트릭스 상영 가능 날짜 포함',
+ schema: { type: 'boolean' },
+ },
{
name: 'action',
in: 'query',
@@ -392,7 +427,7 @@ export const ACTION_QUERY_PARAMETERS = [
name: 'timeoutMs',
in: 'query',
required: false,
- description: '세븐일레븐 inventory 타임아웃',
+ description: '원본 요청 타임아웃(ms), 디트릭스는 1~60000',
schema: { type: 'integer' },
},
] as const;
diff --git a/src/pages/openapiSpecPathsDtryx.ts b/src/pages/openapiSpecPathsDtryx.ts
new file mode 100644
index 00000000..33a261aa
--- /dev/null
+++ b/src/pages/openapiSpecPathsDtryx.ts
@@ -0,0 +1,151 @@
+/** 디트릭스 공개 API 경로와 응답 스키마입니다. */
+const text = { type: 'string' };
+const integer = { type: 'integer' };
+const cinema = {
+ type: 'object',
+ properties: {
+ brandCode: text,
+ cinemaCode: text,
+ cinemaName: text,
+ region: text,
+ address: text,
+ },
+};
+const movie = {
+ type: 'object',
+ properties: { movieCode: text, movieName: text, rating: text, runningMinutes: integer },
+};
+const showtime = {
+ type: 'object',
+ properties: {
+ scheduleId: text,
+ brandCode: text,
+ cinemaCode: text,
+ cinemaName: text,
+ screenCode: text,
+ screenName: text,
+ movieCode: text,
+ movieName: text,
+ playDate: text,
+ startTime: text,
+ endTime: text,
+ runningMinutes: integer,
+ rating: text,
+ totalSeats: integer,
+ remainingSeats: integer,
+ bookedSeats: integer,
+ planStatus: text,
+ },
+};
+const query = (name: string, description: string, schema: object = text) => ({
+ name,
+ in: 'query',
+ required: false,
+ description,
+ schema,
+});
+const filters = [
+ query('keyword', '극장명 부분 검색'),
+ query('brandCode', '브랜드 코드. 미등록 극장을 조회할 때 cinemaCode와 함께 지정합니다.'),
+];
+const selector = [query('cinemaCode', '극장 코드 (예: 000067)'), ...filters];
+const region = query('region', '광역 지역명. 극장명과 함께 지정하면 두 조건을 모두 적용합니다.');
+const limit = query('limit', '최대 결과 수', { ...integer, minimum: 1, maximum: 200, default: 50 });
+const timeout = query('timeoutMs', '원본 요청 제한 시간(ms)', {
+ ...integer,
+ minimum: 1,
+ maximum: 60000,
+ default: 15000,
+});
+const error = {
+ description: '요청 조건 오류 또는 원본 조회 실패',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } },
+};
+function responses(properties: object) {
+ return {
+ '200': {
+ description: '조회 성공. 일부 극장 조회 실패는 failedCinemas에 표시합니다.',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ success: { type: 'boolean', const: true },
+ data: { type: 'object', properties },
+ meta: { type: 'object', properties: { total: integer, pageSize: integer } },
+ },
+ },
+ },
+ },
+ },
+ '400': error,
+ '404': error,
+ '500': error,
+ '503': error,
+ };
+}
+export const OPENAPI_PATHS_DTRYX = {
+ '/api/dtryx/cinemas': {
+ get: {
+ operationId: 'dtryxListCinemas',
+ summary: '디트릭스 독립·예술영화관 목록',
+ description:
+ '확인된 극장 카탈로그를 검색합니다. 전체 제휴 극장 목록이나 실시간 위치 정보는 아닙니다.',
+ parameters: [...filters, region, limit],
+ responses: responses({
+ filters: { type: 'object' },
+ count: integer,
+ cinemas: { type: 'array', items: cinema },
+ }),
+ },
+ },
+ '/api/dtryx/movies': {
+ get: {
+ operationId: 'dtryxListNowShowing',
+ summary: '디트릭스 극장 상영작 조회',
+ description:
+ 'cinemaCode 또는 keyword가 필요합니다. 극장명 검색은 첫 번째 일치 극장을 선택합니다.',
+ parameters: [
+ ...selector,
+ query('includePlayDates', '상영 가능한 날짜 포함', { type: 'boolean', default: false }),
+ timeout,
+ ],
+ responses: responses({
+ cinema,
+ count: integer,
+ movies: { type: 'array', items: movie },
+ playDates: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: { playDate: text, hidden: { type: 'boolean' }, rest: { type: 'boolean' } },
+ },
+ },
+ }),
+ },
+ },
+ '/api/dtryx/seats': {
+ get: {
+ operationId: 'dtryxGetRemainingSeats',
+ summary: '디트릭스 상영시간표와 잔여 좌석 조회',
+ description:
+ '극장 조건이 없으면 카탈로그 전체를 조회합니다. 일부 실패는 failedCinemas에 표시하고 전체 실패는 503을 반환합니다.',
+ parameters: [
+ ...selector,
+ region,
+ query('movieName', '영화명 부분 일치'),
+ query('playDate', 'YYYYMMDD 또는 YYYY-MM-DD. 기본값은 한국 날짜 기준 오늘입니다.'),
+ limit,
+ timeout,
+ ],
+ responses: responses({
+ playDate: text,
+ filters: { type: 'object' },
+ searchedCinemaCount: integer,
+ failedCinemas: { type: 'array', items: text },
+ count: integer,
+ showtimes: { type: 'array', items: showtime },
+ }),
+ },
+ },
+};
diff --git a/src/pages/promptRetailCinemaText.ts b/src/pages/promptRetailCinemaText.ts
index 3687a80c..a9d2a217 100644
--- a/src/pages/promptRetailCinemaText.ts
+++ b/src/pages/promptRetailCinemaText.ts
@@ -3,7 +3,15 @@
*/
export function buildPromptRetailCinemaText(baseUrl: string): string {
- return `### 6-0. 키 없는 통합 상품 가격 후보 비교
+ return `### 디트릭스 독립·예술영화관
+
+- 극장 카탈로그: ${baseUrl}/api/dtryx/cinemas?region=서울
+- 상영작: ${baseUrl}/api/dtryx/movies?keyword=모모&includePlayDates=true
+- 회차와 잔여 좌석: ${baseUrl}/api/dtryx/seats?region=서울
+
+상영작 조회는 cinemaCode 또는 keyword가 필요합니다. 날짜 기본값은 한국 날짜 기준 오늘이며 YYYYMMDD 또는 YYYY-MM-DD를 받습니다. 지역·극장명·브랜드 필터는 함께 적용합니다. 카탈로그는 확인된 22곳이며 전체 제휴 극장이 아닙니다. 일부 실패는 failedCinemas로 표시하고 전체 조회 실패는 오류입니다.
+
+### 6-0. 키 없는 통합 상품 가격 후보 비교
**설명**: 새 외부 API 키 없이 기존 다이소, GS25, 세븐일레븐, 이마트24 상품 검색을 묶어 가격 후보를 비교합니다. 실제 매장 재고와 행사가까지 확정하는 기능이 아니라 "어디가 싸 보이는지"를 빠르게 고르는 1차 검색입니다.
diff --git a/src/services/cu/client.ts b/src/services/cu/client.ts
index 3df9e49d..a948fed1 100644
--- a/src/services/cu/client.ts
+++ b/src/services/cu/client.ts
@@ -133,10 +133,10 @@ async function requestCuWebHtml(
const form = new URLSearchParams(body);
const formText = form.toString();
const targetUrl = `${CU_API.WEB_BASE_URL}${path}`;
- const response = await fetch(`${CU_API.WEB_BASE_URL}${path}`, {
- method: 'POST',
+ // 공식 웹의 GET 조회는 같은 검색 조건을 지원하며 추가 재시도 시간을 쓰지 않습니다.
+ const response = await fetch(`${targetUrl}?${formText}`, {
+ method: 'GET',
headers: CU_WEB_DEFAULT_HEADERS,
- body: formText,
signal: AbortSignal.timeout(timeout),
});
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/client.ts b/src/services/dtryx/client.ts
new file mode 100644
index 00000000..fb90d6b5
--- /dev/null
+++ b/src/services/dtryx/client.ts
@@ -0,0 +1,201 @@
+/**
+ * 디트릭스 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}`);
+ }
+
+ const body: unknown = await response.json();
+ if (
+ typeof body !== 'object' ||
+ body === null ||
+ !('RetCode' in body) ||
+ body.RetCode !== 'success' ||
+ !('Recordset' in body) ||
+ !Array.isArray(body.Recordset) ||
+ !body.Recordset.every(
+ (item) => typeof item === 'object' && item !== null && !Array.isArray(item),
+ )
+ ) {
+ throw new Error('디트릭스 API 응답 오류: RetCode 또는 Recordset이 유효하지 않습니다.');
+ }
+ return body 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/location.ts b/src/services/dtryx/location.ts
new file mode 100644
index 00000000..d98b8ea0
--- /dev/null
+++ b/src/services/dtryx/location.ts
@@ -0,0 +1,237 @@
+/**
+ * 디트릭스 극장 레지스트리 및 극장 해석 보조 모듈
+ *
+ * 다른 영화관 서비스의 `location.ts` 와 같은 자리이지만, 디트릭스 응답에는
+ * 극장 좌표가 없어 좌표 기반 근접 검색 대신 극장명 매칭만 제공합니다.
+ *
+ * 디트릭스는 브랜드/극장 목록을 반환하는 공개 엔드포인트를 제공하지 않습니다.
+ * 그래서 조회로 확인된 극장을 상수로 관리합니다.
+ *
+ * 아래 목록은 상영시간표 응답의 `CinemaNm` 으로 확인한 값만 담았습니다.
+ * `indieart/000068`, `etc/000086` 두 코드는 상영 가능 날짜는 있으나
+ * 아직 편성 회차가 없어 극장명을 확인하지 못해 제외했습니다.
+ *
+ * `BrandCd` 는 조회 시 반드시 일치해야 하며, 값이 다르면 오류 없이 0건이 반환됩니다.
+ */
+
+import type { DtryxCinema } from './types.js';
+
+export const DTRYX_CINEMAS: readonly DtryxCinema[] = [
+ {
+ 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',
+ },
+];
+
+/** 극장명 부분 일치로 카탈로그를 검색합니다. */
+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);
+}
+
+/** 광역 지역명으로 극장을 찾습니다. 부분 일치를 허용합니다. */
+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));
+}
+
+/** 극장 코드와 검색 필터를 함께 적용하며, 미등록 코드는 브랜드와 함께 허용합니다. */
+export function resolveDtryxCinemas(args: {
+ cinemaCode?: string;
+ keyword?: string;
+ region?: string;
+ brandCode?: string;
+}): DtryxCinema[] {
+ let cinemas = [...DTRYX_CINEMAS];
+ if (args.cinemaCode) {
+ const known = findCinemaByCode(args.cinemaCode);
+ cinemas = known
+ ? [known]
+ : args.brandCode
+ ? [
+ {
+ cinemaCode: args.cinemaCode,
+ brandCode: args.brandCode,
+ cinemaName: '',
+ region: '',
+ address: '',
+ },
+ ]
+ : [];
+ }
+ const keywordMatches = args.keyword ? findCinemasByKeyword(args.keyword) : undefined;
+ const regionMatches = args.region ? findCinemasByRegion(args.region) : undefined;
+ return cinemas.filter(
+ (cinema) =>
+ (!keywordMatches || keywordMatches.includes(cinema)) &&
+ (!regionMatches || regionMatches.includes(cinema)) &&
+ (!args.brandCode || cinema.brandCode === args.brandCode),
+ );
+}
diff --git a/src/services/dtryx/tools/getRemainingSeats.ts b/src/services/dtryx/tools/getRemainingSeats.ts
new file mode 100644
index 00000000..0059d395
--- /dev/null
+++ b/src/services/dtryx/tools/getRemainingSeats.ts
@@ -0,0 +1,161 @@
+/**
+ * 디트릭스 상영시간표·잔여 좌석 조회 도구
+ */
+
+import * as z from 'zod';
+import {
+ DTRYX_INVALID_INPUT,
+ hasInvalidDtryxOptions,
+ dtryxLimitSchema,
+ dtryxTimeoutSchema,
+ dtryxDateSchema,
+} from '../validation.js';
+import type { McpToolResponse, ToolRegistration } from '../../../core/types.js';
+import { resolveDtryxCinemas } from '../location.js';
+import { fetchDtryxTimetable, toYyyymmdd } from '../client.js';
+import type { DtryxShowtime } from '../types.js';
+
+interface GetRemainingSeatsArgs {
+ playDate?: string;
+ cinemaCode?: string;
+ keyword?: string;
+ region?: string;
+ brandCode?: string;
+ movieName?: string;
+ limit?: number;
+ timeoutMs?: number;
+}
+
+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 {
+ if (hasInvalidDtryxOptions(args)) {
+ return { isError: true, content: [{ type: 'text', text: DTRYX_INVALID_INPUT }] };
+ }
+ const { playDate = toYyyymmdd(), movieName, limit = 50, timeoutMs = 15000 } = args;
+ const cinemas = resolveDtryxCinemas(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);
+ if (failedCinemas.length === cinemas.length) {
+ return {
+ isError: true,
+ content: [
+ {
+ type: 'text',
+ text: JSON.stringify({ error: '모든 극장 조회에 실패했습니다.', failedCinemas }),
+ },
+ ],
+ };
+ }
+ 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,
+ region: args.region || 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: dtryxDateSchema
+ .optional()
+ .describe('조회 날짜(YYYYMMDD 또는 YYYY-MM-DD, 기본값: 오늘)'),
+ cinemaCode: z.string().optional().describe('극장 코드 (예: 000067)'),
+ keyword: z.string().optional().describe('극장명 키워드 (예: 모모, 광주)'),
+ region: z
+ .string()
+ .optional()
+ .describe('광역 지역명으로 조회 범위를 좁힙니다 (예: 서울, 경기)'),
+ brandCode: z
+ .string()
+ .optional()
+ .describe('브랜드 코드. 카탈로그에 없는 극장 코드를 직접 조회할 때 함께 지정합니다.'),
+ movieName: z.string().optional().describe('영화명 부분 일치 필터 (예: 경멸)'),
+ limit: dtryxLimitSchema.optional().default(50).describe('반환할 최대 회차 수 (기본값: 50)'),
+ timeoutMs: dtryxTimeoutSchema
+ .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..048a840b
--- /dev/null
+++ b/src/services/dtryx/tools/listCinemas.ts
@@ -0,0 +1,65 @@
+/**
+ * 디트릭스 극장 목록 조회 도구
+ */
+
+import * as z from 'zod';
+import { DTRYX_INVALID_INPUT, hasInvalidDtryxOptions, dtryxLimitSchema } from '../validation.js';
+import type { McpToolResponse, ToolRegistration } from '../../../core/types.js';
+import { resolveDtryxCinemas } from '../location.js';
+
+interface ListCinemasArgs {
+ keyword?: string;
+ region?: string;
+ brandCode?: string;
+ limit?: number;
+}
+
+async function listCinemas(args: ListCinemasArgs): Promise {
+ if (hasInvalidDtryxOptions(args)) {
+ return { isError: true, content: [{ type: 'text', text: DTRYX_INVALID_INPUT }] };
+ }
+ const { keyword, region, brandCode, limit = 50 } = args;
+ const matched = resolveDtryxCinemas(args).slice(0, limit);
+
+ return {
+ content: [
+ {
+ type: 'text',
+ text: JSON.stringify(
+ {
+ filters: {
+ keyword: keyword || null,
+ region: region || 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('극장명 키워드 (예: 모모, 광주)'),
+ region: z.string().optional().describe('광역 지역명 (예: 서울, 경기, 부산)'),
+ brandCode: z.string().optional().describe('브랜드 코드 (indieart, etc, spacedog 중 하나)'),
+ limit: dtryxLimitSchema.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..d04b4a1c
--- /dev/null
+++ b/src/services/dtryx/tools/listNowShowing.ts
@@ -0,0 +1,104 @@
+/**
+ * 디트릭스 현재 상영작 조회 도구
+ */
+
+import * as z from 'zod';
+import { DTRYX_INVALID_INPUT, hasInvalidDtryxOptions, dtryxTimeoutSchema } from '../validation.js';
+import type { McpToolResponse, ToolRegistration } from '../../../core/types.js';
+import { resolveDtryxCinemas } from '../location.js';
+import { fetchDtryxNowShowing, fetchDtryxPlayDates } from '../client.js';
+
+interface ListNowShowingArgs {
+ cinemaCode?: string;
+ keyword?: string;
+ brandCode?: string;
+ includePlayDates?: boolean;
+ timeoutMs?: number;
+}
+
+async function listNowShowing(args: ListNowShowingArgs): Promise {
+ if (hasInvalidDtryxOptions(args)) {
+ return { isError: true, content: [{ type: 'text', text: DTRYX_INVALID_INPUT }] };
+ }
+ const { includePlayDates = false, timeoutMs = 15000 } = args;
+ const cinema = args.cinemaCode || args.keyword ? resolveDtryxCinemas(args)[0] : undefined;
+
+ 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: dtryxTimeoutSchema
+ .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..7e073da8
--- /dev/null
+++ b/src/services/dtryx/types.ts
@@ -0,0 +1,92 @@
+/**
+ * 디트릭스 서비스 전용 타입 정의
+ */
+
+/** 디트릭스에 등록된 극장 */
+export interface DtryxCinema {
+ brandCode: string;
+ cinemaCode: string;
+ cinemaName: string;
+ /** 광역 지역명 (예: 서울, 경기, 부산) */
+ region: string;
+ /** 도로명 주소 */
+ address: 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/src/services/dtryx/validation.ts b/src/services/dtryx/validation.ts
new file mode 100644
index 00000000..a2158908
--- /dev/null
+++ b/src/services/dtryx/validation.ts
@@ -0,0 +1,24 @@
+/** API와 MCP에서 공통으로 사용하는 디트릭스 입력 검증 */
+import * as z from 'zod';
+
+export const dtryxLimitSchema = z.number().int().min(1).max(200);
+export const dtryxTimeoutSchema = z.number().int().min(1).max(60000);
+export const dtryxDateSchema = z.string().refine((value) => {
+ if (!/^(\d{8}|\d{4}-\d{2}-\d{2})$/.test(value)) return false;
+ const compact = value.replace(/-/g, '');
+ const dashed = `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6, 8)}`;
+ const date = new Date(`${dashed}T00:00:00Z`);
+ return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === dashed;
+});
+
+const optionsSchema = z.object({
+ limit: dtryxLimitSchema.optional(),
+ timeoutMs: dtryxTimeoutSchema.optional(),
+ playDate: dtryxDateSchema.optional(),
+});
+export const DTRYX_INVALID_INPUT =
+ 'limit은 1~200, timeoutMs는 1~60000 사이의 정수이며 playDate는 유효한 YYYYMMDD 또는 YYYY-MM-DD 날짜여야 합니다.';
+
+export function hasInvalidDtryxOptions(options: unknown): boolean {
+ return !optionsSchema.safeParse(options).success;
+}
diff --git a/src/services/gs25/errors.ts b/src/services/gs25/errors.ts
index dcd00a4c..b175f241 100644
--- a/src/services/gs25/errors.ts
+++ b/src/services/gs25/errors.ts
@@ -4,7 +4,7 @@
export class Gs25UpstreamUnavailableError extends Error {
constructor() {
- super('GS25 재고 서비스 인증을 사용할 수 없습니다. 잠시 후 다시 시도해주세요.');
+ super('GS25 재고 서비스 인증을 사용할 수 없습니다. 운영자는 GS25_API_KEY 설정을 확인하세요.');
this.name = 'Gs25UpstreamUnavailableError';
}
}
diff --git a/src/utils/http.ts b/src/utils/http.ts
index be149e50..b56c2ddf 100644
--- a/src/utils/http.ts
+++ b/src/utils/http.ts
@@ -41,15 +41,19 @@ export class HttpError extends Error {
/** HTML 원문을 노출하지 않고 예상하지 못한 응답 형식을 구분합니다. */
export class UnexpectedHtmlResponseError extends Error {
- constructor(readonly status: number, readonly contentType: string) {
+ constructor(
+ readonly status: number,
+ readonly contentType: string,
+ ) {
super(`JSON 대신 HTML 응답을 받았습니다 (HTTP ${status}).`);
this.name = 'UnexpectedHtmlResponseError';
}
}
-export function createTimeoutController(
- timeout: number,
-): { controller: AbortController; timeoutId: ReturnType } {
+export function createTimeoutController(timeout: number): {
+ controller: AbortController;
+ timeoutId: ReturnType;
+} {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
return { controller, timeoutId };
@@ -76,7 +80,11 @@ function normalizeMethod(method: string | undefined): string {
return (method || 'GET').toUpperCase();
}
-function canRetryMethod(method: string, retryMethods: string[], retryUnsafeMethods: boolean): boolean {
+function canRetryMethod(
+ method: string,
+ retryMethods: string[],
+ retryUnsafeMethods: boolean,
+): boolean {
return retryUnsafeMethods || retryMethods.map((item) => item.toUpperCase()).includes(method);
}
@@ -99,7 +107,11 @@ function parseRetryAfterDelayMs(response: Response): number | null {
return null;
}
-export async function fetchWithTimeout(url: string, options: FetchOptions = {}): Promise {
+async function requestWithTimeout(
+ url: string,
+ options: FetchOptions,
+ readResponse: (response: Response) => Promise,
+): Promise {
const {
timeout = 10000,
retries = 0,
@@ -116,6 +128,7 @@ export async function fetchWithTimeout(url: string, options: FetchOptions = {}):
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const { controller, timeoutId } = createTimeoutController(timeout);
+ let readingResponse = false;
try {
const response = await fetch(url, {
@@ -123,7 +136,11 @@ export async function fetchWithTimeout(url: string, options: FetchOptions = {}):
signal: controller.signal,
});
- if (retryAllowed && attempt < maxAttempts && isRetryableStatus(response.status, retryStatusCodes)) {
+ if (
+ retryAllowed &&
+ attempt < maxAttempts &&
+ isRetryableStatus(response.status, retryStatusCodes)
+ ) {
const delayMs = parseRetryAfterDelayMs(response) ?? retryDelayMs;
onRetry?.({
attempt,
@@ -139,9 +156,10 @@ export async function fetchWithTimeout(url: string, options: FetchOptions = {}):
continue;
}
- return response;
+ readingResponse = true;
+ return await readResponse(response);
} catch (error) {
- if (!retryAllowed || attempt >= maxAttempts) {
+ if (readingResponse || !retryAllowed || attempt >= maxAttempts) {
throw error;
}
@@ -164,26 +182,40 @@ export async function fetchWithTimeout(url: string, options: FetchOptions = {}):
throw new Error('API 요청 재시도 처리 중 알 수 없는 오류가 발생했습니다.');
}
+export async function fetchWithTimeout(url: string, options: FetchOptions = {}): Promise {
+ return requestWithTimeout(url, options, async (response) => response);
+}
+
+async function readTextResponse(url: string, options: FetchOptions) {
+ // 헤더 수신뿐 아니라 본문 읽기에도 같은 요청 제한 시간을 적용합니다.
+ return requestWithTimeout(url, options, async (response) => ({
+ response,
+ body: await response.text(),
+ }));
+}
+
export async function fetchJson(url: string, options: FetchOptions = {}): Promise {
- const response = await fetchWithTimeout(url, options);
+ const { response, body } = await readTextResponse(url, options);
if (!response.ok) {
- throw new HttpError(response.status, response.statusText, await response.text());
+ throw new HttpError(response.status, response.statusText, body);
}
- const body = await response.text();
if (/^\s*(?: {
- const response = await fetchWithTimeout(url, options);
+ const { response, body } = await readTextResponse(url, options);
if (!response.ok) {
- throw new HttpError(response.status, response.statusText, await response.text());
+ throw new HttpError(response.status, response.statusText, body);
}
- return response.text();
+ return body;
}
diff --git a/tests/api/dtryx-handlers.test.ts b/tests/api/dtryx-handlers.test.ts
new file mode 100644
index 00000000..1a555dc0
--- /dev/null
+++ b/tests/api/dtryx-handlers.test.ts
@@ -0,0 +1,159 @@
+/**
+ * 디트릭스 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({ RetCode: 'success', ...(body as Record) }),
+ {
+ 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/api/gs25-handlers.test.ts b/tests/api/gs25-handlers.test.ts
index 5453c190..f53280f1 100644
--- a/tests/api/gs25-handlers.test.ts
+++ b/tests/api/gs25-handlers.test.ts
@@ -294,7 +294,7 @@ describe('handleGs25CheckInventory', () => {
success: false,
error: {
code: 'GS25_UPSTREAM_UNAVAILABLE',
- message: 'GS25 재고 서비스 인증을 사용할 수 없습니다. 잠시 후 다시 시도해주세요.',
+ message: 'GS25 재고 서비스 인증을 사용할 수 없습니다. 운영자는 GS25_API_KEY 설정을 확인하세요.',
},
}),
503,
diff --git a/tests/api/health-checks.test.ts b/tests/api/health-checks.test.ts
index e6beff94..fd592b5b 100644
--- a/tests/api/health-checks.test.ts
+++ b/tests/api/health-checks.test.ts
@@ -134,6 +134,8 @@ describe('runHealthChecks', () => {
productNo: 'A1',
storeCode: 'S1',
storeName: '매장',
+ movieCode: 'M1',
+ movieName: '영화',
theaterCode: 'T1',
theaterName: '극장',
};
@@ -147,6 +149,7 @@ describe('runHealthChecks', () => {
products: [representative],
stores: [representative],
theaters: [representative],
+ movies: [representative],
inventory: {
products: [representative],
items: [representative],
diff --git a/tests/api/response.test.ts b/tests/api/response.test.ts
index 495d39eb..a22659ef 100644
--- a/tests/api/response.test.ts
+++ b/tests/api/response.test.ts
@@ -63,3 +63,45 @@ describe('errorResponse', () => {
);
});
});
+
+describe('설정 오류 진단', () => {
+ it.each([
+ 'Zyte API 호출 실패: 403 Your account has been suspended.',
+ 'Zyte API 호출 실패: 403 account suspended',
+ 'ZYTE_API_KEY가 설정되지 않았습니다. .env 또는 Cloudflare Worker Secret을 확인해주세요.',
+ ])('설정 오류는 재시도 대신 운영자 조치를 안내한다: %s', (message) => {
+ const result = toStandardErrorDiagnostics('GS25_PRODUCT_SEARCH_FAILED', message, {
+ status: 500,
+ });
+ expect(result).toMatchObject({ message, status: 500, retryable: false });
+ expect(result.hint).toContain('운영자');
+ expect(result.hint).toContain('ZYTE_API_KEY');
+ expect(result.hint).toContain('계정');
+ });
+
+ it.each([
+ ['GS25_PRODUCT_SEARCH_FAILED', 'Zyte API 호출 실패: 403 Forbidden', 500, 403],
+ ['GS25_PRODUCT_SEARCH_FAILED', 'upstream account suspended', 500, 403],
+ ['GS25_PRODUCT_SEARCH_FAILED', 'Zyte API 호출 실패: 503 unavailable', 503, undefined],
+ ['GS25_PRODUCT_SEARCH_FAILED', 'rate limited', 429, undefined],
+ ['GS25_TIMEOUT', 'timeout', undefined, undefined],
+ ['GS25_TIMEOUT', 'timeout', 408, undefined],
+ ])('일반 외부 오류는 재시도 판단을 유지한다: %s %s', (code, message, status, upstreamStatus) => {
+ expect(toStandardErrorDiagnostics(code, message, { status, upstreamStatus })).toMatchObject({
+ retryable: true,
+ hint: '일시적인 외부 서비스 오류일 수 있습니다. 잠시 후 다시 시도하세요.',
+ });
+ });
+});
+
+describe('GS25 인증 장애 안내', () => {
+ it('인증 오류에 자동 재시도를 권하지 않는다', () => {
+ const diagnostics = toStandardErrorDiagnostics(
+ 'GS25_UPSTREAM_UNAVAILABLE',
+ 'GS25 재고 서비스 인증을 사용할 수 없습니다. 운영자는 GS25_API_KEY 설정을 확인하세요.',
+ { status: 503 },
+ );
+ expect(diagnostics.retryable).toBe(false);
+ expect(diagnostics.hint).toContain('GS25_API_KEY');
+ });
+});
diff --git a/tests/app/app-api-dtryx.test.ts b/tests/app/app-api-dtryx.test.ts
new file mode 100644
index 00000000..39abac76
--- /dev/null
+++ b/tests/app/app-api-dtryx.test.ts
@@ -0,0 +1,111 @@
+/**
+ * 앱 통합 테스트 - 디트릭스 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({
+ RetCode: 'success',
+ 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({ RetCode: 'success', 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);
+ });
+});
+
+describe('디트릭스 장애와 잘못된 입력', () => {
+ it('전체 외부 장애를 성공 응답으로 반환하지 않는다', async () => {
+ mockFetch.mockRejectedValue(new Error('upstream unavailable'));
+ const response = await app.request('/api/dtryx/seats?cinemaCode=000067');
+ expect(response.status).toBe(503);
+ expect(await response.json()).toMatchObject({
+ success: false,
+ error: { code: 'DTRYX_SEATS_FAILED' },
+ });
+ });
+
+ it('극장 없는 상영작 조회와 잘못된 제한값을 거절한다', async () => {
+ expect((await app.request('/api/dtryx/movies')).status).toBe(404);
+ expect((await app.request('/api/dtryx/seats?limit=-1')).status).toBe(400);
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/app/app-health-checks.test.ts b/tests/app/app-health-checks.test.ts
index 1d6d2f7f..28eef65f 100644
--- a/tests/app/app-health-checks.test.ts
+++ b/tests/app/app-health-checks.test.ts
@@ -579,6 +579,8 @@ describe('GET /api/health/checks', () => {
branchName: '강남점',
storeCode: 'S1',
storeName: '강남점',
+ movieCode: 'M1',
+ movieName: '영화',
theaterCode: 'T1',
theaterName: '강남점',
};
@@ -592,6 +594,7 @@ describe('GET /api/health/checks', () => {
products: [representative],
stores: [representative],
theaters: [representative],
+ movies: [representative],
inventory: {
products: [representative],
items: [representative],
diff --git a/tests/app/cli.test.ts b/tests/app/cli.test.ts
index a7ebd952..c2dbcd54 100644
--- a/tests/app/cli.test.ts
+++ b/tests/app/cli.test.ts
@@ -630,6 +630,87 @@ describe('CLI', () => {
);
});
+ it('megabox-theaters 명령은 메가박스 주변 지점 API를 호출한다', async () => {
+ const { deps } = createDeps();
+ const fetchImpl = vi.fn().mockResolvedValue({
+ ok: true,
+ json: vi
+ .fn()
+ .mockResolvedValue({ success: true, data: { theaters: [] }, meta: { total: 0 } }),
+ } as unknown as Response);
+ deps.fetchImpl = fetchImpl;
+
+ const exitCode = await runCli(
+ ['megabox-theaters', '--lat', '37.5125', '--lng', '127.0589', '--limit', '5'],
+ deps,
+ );
+
+ expect(exitCode).toBe(0);
+ expect(fetchImpl).toHaveBeenCalledWith(
+ 'https://mcp.aka.page/api/megabox/theaters?lat=37.5125&lng=127.0589&limit=5',
+ );
+ });
+
+ it('megabox-theaters 명령은 위치 검색어를 positional 인자로 받는다', async () => {
+ const { deps } = createDeps();
+ const fetchImpl = vi.fn().mockResolvedValue({
+ ok: true,
+ json: vi
+ .fn()
+ .mockResolvedValue({ success: true, data: { theaters: [] }, meta: { total: 0 } }),
+ } as unknown as Response);
+ deps.fetchImpl = fetchImpl;
+
+ const exitCode = await runCli(['megabox-theaters', '강남', '--limit', '5'], deps);
+
+ expect(exitCode).toBe(0);
+ expect(fetchImpl).toHaveBeenCalledWith(
+ 'https://mcp.aka.page/api/megabox/theaters?limit=5&keyword=%EA%B0%95%EB%82%A8',
+ );
+ });
+
+ it('megabox-movies 명령은 메가박스 영화/회차 API를 호출한다', async () => {
+ const { deps } = createDeps();
+ const fetchImpl = vi.fn().mockResolvedValue({
+ ok: true,
+ json: vi
+ .fn()
+ .mockResolvedValue({ success: true, data: { movies: [] }, meta: { total: 0 } }),
+ } as unknown as Response);
+ deps.fetchImpl = fetchImpl;
+
+ const exitCode = await runCli(
+ ['megabox-movies', '--playDate', '20260310', '--theaterId', '1372'],
+ deps,
+ );
+
+ expect(exitCode).toBe(0);
+ expect(fetchImpl).toHaveBeenCalledWith(
+ 'https://mcp.aka.page/api/megabox/movies?playDate=20260310&theaterId=1372',
+ );
+ });
+
+ it('megabox-seats 명령은 메가박스 잔여 좌석 API를 호출한다', async () => {
+ const { deps } = createDeps();
+ const fetchImpl = vi.fn().mockResolvedValue({
+ ok: true,
+ json: vi
+ .fn()
+ .mockResolvedValue({ success: true, data: { seats: [] }, meta: { total: 0 } }),
+ } as unknown as Response);
+ deps.fetchImpl = fetchImpl;
+
+ const exitCode = await runCli(
+ ['megabox-seats', '--playDate', '20260310', '--theaterId', '1372', '--movieId', '23816'],
+ deps,
+ );
+
+ expect(exitCode).toBe(0);
+ expect(fetchImpl).toHaveBeenCalledWith(
+ 'https://mcp.aka.page/api/megabox/seats?playDate=20260310&theaterId=1372&movieId=23816',
+ );
+ });
+
it('lottecinema-movies 명령은 롯데시네마 영화/회차 API를 호출한다', async () => {
const { deps } = createDeps();
const fetchImpl = vi.fn().mockResolvedValue({
@@ -1147,6 +1228,9 @@ describe('CLI', () => {
['lottecinema-theaters', ['lottecinema-theaters', '잠실']],
['lottecinema-movies', ['lottecinema-movies', '잠실']],
['lottecinema-seats', ['lottecinema-seats', '잠실']],
+ ['megabox-theaters', ['megabox-theaters', '강남']],
+ ['megabox-movies', ['megabox-movies', '강남']],
+ ['megabox-seats', ['megabox-seats', '강남']],
['emart24-stores', ['emart24-stores', '강남']],
['emart24-products', ['emart24-products', '두바이']],
['emart24-inventory', ['emart24-inventory', '8800244010504', '--bizNoArr', '28339']],
diff --git a/tests/pages/dtryx-openapi.test.ts b/tests/pages/dtryx-openapi.test.ts
new file mode 100644
index 00000000..b6d1e7f2
--- /dev/null
+++ b/tests/pages/dtryx-openapi.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest';
+import { generateFullOpenApiSpec } from '../../src/pages/openapiFullSpec.js';
+import { ACTION_QUERY_DEFINITIONS } from '../../src/api/actionsProxy.js';
+import { ACTION_QUERY_PARAMETERS } from '../../src/pages/openapiSpecActionParameters.js';
+import { HEALTH_CHECKS } from '../../src/api/healthCheckDefinitions.js';
+
+describe('디트릭스 공개 API 계약', () => {
+ it.each(['cinemas', 'movies', 'seats'])('%s 경로와 Actions를 노출한다', (resource) => {
+ const spec = generateFullOpenApiSpec('https://example.com') as {
+ paths: Record;
+ };
+ const path = `/api/dtryx/${resource}`;
+ expect(spec.paths[path]).toBeDefined();
+ expect(ACTION_QUERY_DEFINITIONS.some((definition) => definition.targetPath === path)).toBe(
+ true,
+ );
+ });
+ it('Actions에 극장 코드와 필터를 전달할 수 있다', () => {
+ expect(ACTION_QUERY_PARAMETERS.map((parameter) => parameter.name)).toEqual(
+ expect.arrayContaining([
+ 'cinemaCode',
+ 'brandCode',
+ 'region',
+ 'movieName',
+ 'includePlayDates',
+ ]),
+ );
+ });
+ it('실제 상영작을 확인하는 전체 운영 검사를 등록한다', () => {
+ expect(HEALTH_CHECKS).toContainEqual(
+ expect.objectContaining({
+ id: 'dtryx.movies',
+ service: 'dtryx',
+ mode: 'deep',
+ collectionKey: 'movies',
+ }),
+ );
+ });
+});
diff --git a/tests/scripts/cinema-smoke.test.ts b/tests/scripts/cinema-smoke.test.ts
new file mode 100644
index 00000000..3613ca9b
--- /dev/null
+++ b/tests/scripts/cinema-smoke.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+import { CLI_SMOKE_COMMANDS } from '../../scripts/ops/cli-smoke.js';
+
+describe('영화관 CLI smoke', () => {
+ it('메가박스 전용 명령으로 실행한다', () => {
+ expect(CLI_SMOKE_COMMANDS.find((command) => command.service === 'megabox')?.args[0]).toBe(
+ 'megabox-theaters',
+ );
+ });
+ it('디트릭스 실제 상영작 응답을 확인한다', () => {
+ const scenario = CLI_SMOKE_COMMANDS.find((command) => command.service === 'dtryx');
+ expect(scenario).toBeDefined();
+ expect(
+ scenario?.validate(
+ JSON.stringify({
+ success: true,
+ data: { movies: [{ movieCode: '1', movieName: '영화' }] },
+ }),
+ '',
+ ),
+ ).toBeNull();
+ for (const movies of [undefined, [], [{}], [{ movieCode: '1' }]]) {
+ expect(
+ scenario?.validate(JSON.stringify({ success: true, data: { movies } }), ''),
+ ).toBeTypeOf('string');
+ }
+ });
+});
diff --git a/tests/scripts/cli-smoke.test.ts b/tests/scripts/cli-smoke.test.ts
index 3cda7ed9..8c405fc2 100644
--- a/tests/scripts/cli-smoke.test.ts
+++ b/tests/scripts/cli-smoke.test.ts
@@ -50,11 +50,14 @@ describe('runCliSmoke', () => {
'seveneleven-products': { success: true, data: { query: '커피' } },
'emart24-products': { success: true, data: { keyword: '커피' } },
'lottemart-products': { success: true, data: { keyword: '콜라' } },
+ 'megabox-theaters': { success: true, data: { keyword: '강남' } },
'lottecinema-theaters': { success: true, data: { keyword: '잠실' } },
};
const path = args[2];
const getPayload =
- path === '/api/oliveyoung/products'
+ path === '/api/dtryx/movies'
+ ? { success: true, data: { movies: [{ movieCode: '1', movieName: '영화' }] } }
+ : path === '/api/oliveyoung/products'
? { success: true, data: { keyword: '선크림' } }
: path === '/api/megabox/theaters' || path === '/api/cgv/theaters'
? { success: true, data: { keyword: '강남' } }
@@ -220,11 +223,14 @@ describe('runCliSmoke', () => {
'seveneleven-products': { success: true, data: { query: '커피' } },
'emart24-products': { success: true, data: { keyword: '커피' } },
'lottemart-products': { success: true, data: { keyword: '콜라' } },
+ 'megabox-theaters': { success: true, data: { keyword: '강남' } },
'lottecinema-theaters': { success: true, data: { keyword: null } },
};
const path = args[2];
const getPayload =
- path === '/api/oliveyoung/products'
+ path === '/api/dtryx/movies'
+ ? { success: true, data: { movies: [{ movieCode: '1', movieName: '영화' }] } }
+ : path === '/api/oliveyoung/products'
? { success: true, data: { keyword: '선크림' } }
: path === '/api/megabox/theaters' || path === '/api/cgv/theaters'
? { success: true, data: { keyword: '강남' } }
diff --git a/tests/scripts/mcp-smoke.test.ts b/tests/scripts/mcp-smoke.test.ts
index d3854641..483aca91 100644
--- a/tests/scripts/mcp-smoke.test.ts
+++ b/tests/scripts/mcp-smoke.test.ts
@@ -2,7 +2,11 @@
* MCP smoke 스크립트 테스트
*/
import { describe, expect, it, vi } from 'vitest';
-import { MCP_SMOKE_SCENARIOS, MCP_SMOKE_TOOL_NAMES, runMcpSmoke } from '../../scripts/ops/mcp-smoke.js';
+import {
+ MCP_SMOKE_SCENARIOS,
+ MCP_SMOKE_TOOL_NAMES,
+ runMcpSmoke,
+} from '../../scripts/ops/mcp-smoke.js';
describe('runMcpSmoke', () => {
it('상품명 기반 재고 도구를 필수 smoke 대상으로 포함한다', () => {
@@ -122,3 +126,47 @@ describe('runMcpSmoke', () => {
expect(close).toHaveBeenCalledOnce();
});
});
+
+describe('MCP 도구 오류 응답', () => {
+ it.each([
+ ['Zyte API 호출 실패: 403 Your account has been suspended.'],
+ [JSON.stringify({ keyword: '콜라', error: 'configuration missing' })],
+ [''],
+ [undefined],
+ ])('isError 응답은 원문을 보존하고 실패한다: %s', async (text) => {
+ const errors: string[] = [];
+ const close = vi.fn();
+ const exitCode = await runMcpSmoke({
+ service: 'gs25',
+ createClient: async () => ({
+ listTools: async () => ({ tools: [{ name: 'gs25_search_products' }] }),
+ callTool: async () => ({
+ isError: true,
+ content: text === undefined ? [] : [{ type: 'text', text }],
+ }),
+ close,
+ }),
+ writeOut: () => undefined,
+ writeErr: (message) => errors.push(message),
+ });
+ expect(exitCode).toBe(1);
+ expect(errors[0]).toContain(
+ `GS25 상품 검색 - ${text || 'MCP 도구 호출이 오류를 반환했습니다'}`,
+ );
+ expect(close).toHaveBeenCalledOnce();
+ });
+
+ it.each(['not json', '[]', ''])('성공 응답도 JSON 객체 검증을 유지한다: %s', async (text) => {
+ const exitCode = await runMcpSmoke({
+ service: 'gs25',
+ createClient: async () => ({
+ listTools: async () => ({ tools: [{ name: 'gs25_search_products' }] }),
+ callTool: async () => ({ isError: false, content: [{ type: 'text', text }] }),
+ close: async () => undefined,
+ }),
+ writeOut: () => undefined,
+ writeErr: () => undefined,
+ });
+ expect(exitCode).toBe(1);
+ });
+});
diff --git a/tests/services/cu/client.test.ts b/tests/services/cu/client.test.ts
index c3ce36be..eb56fa5e 100644
--- a/tests/services/cu/client.test.ts
+++ b/tests/services/cu/client.test.ts
@@ -98,15 +98,24 @@ describe('fetchCuStores', () => {
const result = await fetchCuStores({ searchWord: '안산 중앙역' });
expect(mockFetch).toHaveBeenCalledWith(
- 'https://cu.bgfretail.com/store/list_Ajax.do',
+ expect.stringContaining('https://cu.bgfretail.com/store/list_Ajax.do?'),
expect.objectContaining({
- method: 'POST',
+ method: 'GET',
headers: expect.objectContaining({
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
}),
}),
);
+ const [requestUrl, requestOptions] = mockFetch.mock.calls[0];
+ expect(Object.fromEntries(new URL(requestUrl).searchParams)).toEqual({
+ pageIndex: '1', listType: '', jumpoCode: '', jumpoLotto: '', jumpoToto: '',
+ jumpoCash: '', jumpoHour: '', jumpoCafe: '', jumpoDelivery: '', jumpoBakery: '',
+ jumpoFry: '', jumpoMultiDevice: '', jumpoPosCash: '', jumpoBattery: '',
+ jumpoAdderss: '', jumpoSido: '', jumpoGugun: '', jumpodong: '', searchWord: '안산 중앙역',
+ });
+ expect(requestOptions.body).toBeUndefined();
+ expect(mockFetch).toHaveBeenCalledTimes(1);
expect(result.totalCount).toBe(1);
expect(result.stores[0]).toEqual(
expect.objectContaining({
@@ -130,7 +139,7 @@ describe('fetchCuStores', () => {
);
});
- it('웹 검색이 400이면 Zyte로 재시도해 결과를 반환한다', async () => {
+ it.each([400, 403, 429])('웹 GET 검색이 %s이면 기존 POST 형식으로 Zyte 재시도한다', async (status) => {
const zyteBody = Buffer.from(
`
@@ -147,7 +156,7 @@ describe('fetchCuStores', () => {
mockFetch
.mockResolvedValueOnce(
new Response('bad request', {
- status: 400,
+ status,
statusText: 'Bad Request',
}),
)
@@ -170,6 +179,13 @@ describe('fetchCuStores', () => {
expect(result.totalCount).toBe(1);
expect(result.stores[0].storeName).toBe('안산중앙역에코점');
expect(mockFetch).toHaveBeenNthCalledWith(2, 'https://api.zyte.com/v1/extract', expect.any(Object));
+ expect(mockFetch).toHaveBeenCalledTimes(2);
+ const fallbackBody = JSON.parse(mockFetch.mock.calls[1][1].body);
+ expect(fallbackBody).toMatchObject({
+ url: 'https://cu.bgfretail.com/store/list_Ajax.do',
+ httpRequestMethod: 'POST',
+ });
+ expect(fallbackBody.httpRequestText).toBe(new URL(mockFetch.mock.calls[0][0]).search.slice(1));
});
it('웹 검색 400 + Zyte 실패 시 원본 에러를 반환한다', async () => {
diff --git a/tests/services/cu/tools/checkInventory.test.ts b/tests/services/cu/tools/checkInventory.test.ts
index e6dae2da..5415cf74 100644
--- a/tests/services/cu/tools/checkInventory.test.ts
+++ b/tests/services/cu/tools/checkInventory.test.ts
@@ -250,7 +250,7 @@ describe('createCheckInventoryTool', () => {
expect(parsed.location).toBeNull();
expect(parsed.nearbyStores.stores[0].storeName).toBe('안산중앙역에코점');
expect(mockFetch).toHaveBeenLastCalledWith(
- 'https://cu.bgfretail.com/store/list_Ajax.do',
+ expect.stringContaining('https://cu.bgfretail.com/store/list_Ajax.do?'),
expect.any(Object),
);
});
diff --git a/tests/services/dtryx/client.test.ts b/tests/services/dtryx/client.test.ts
new file mode 100644
index 00000000..8b7316d0
--- /dev/null
+++ b/tests/services/dtryx/client.test.ts
@@ -0,0 +1,216 @@
+/**
+ * 디트릭스 클라이언트 테스트
+ */
+
+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({ RetCode: 'success', ...(body as Record) }),
+ {
+ 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/coverage.test.ts b/tests/services/dtryx/coverage.test.ts
new file mode 100644
index 00000000..c0682f70
--- /dev/null
+++ b/tests/services/dtryx/coverage.test.ts
@@ -0,0 +1,373 @@
+/**
+ * 디트릭스 분기 커버리지 보강 테스트
+ *
+ * 기본값 대입, 정렬 동률 처리, 극장 해석 분기 등 앞선 테스트에서
+ * 지나가지 않는 경로를 검증합니다.
+ */
+
+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({ RetCode: 'success', ...(body as Record) }),
+ {
+ 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 { status: number; data: { error: { message: string } } };
+
+ expect(result.status).toBe(503);
+ expect(result.data.error.message).toContain('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' }),
+ ).rejects.toThrow('디트릭스 API 응답 오류');
+ mockFetch.mockResolvedValue(jsonResponse({ RetMsg: '정상 완료' }));
+ await expect(
+ fetchDtryxPlayDates({ brandCode: 'indieart', cinemaCode: '000067' }),
+ ).rejects.toThrow('디트릭스 API 응답 오류');
+ mockFetch.mockResolvedValue(jsonResponse({ RetMsg: '정상 완료' }));
+ await expect(
+ fetchDtryxNowShowing({ brandCode: 'indieart', cinemaCode: '000067' }),
+ ).rejects.toThrow('디트릭스 API 응답 오류');
+ });
+
+ 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);
+ });
+});
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/location.test.ts b/tests/services/dtryx/location.test.ts
new file mode 100644
index 00000000..44ec17fa
--- /dev/null
+++ b/tests/services/dtryx/location.test.ts
@@ -0,0 +1,86 @@
+/**
+ * 디트릭스 극장 레지스트리 테스트
+ */
+
+import { describe, expect, it } from 'vitest';
+import {
+ DTRYX_CINEMAS,
+ findCinemaByCode,
+ findCinemasByKeyword,
+ findCinemasByRegion,
+} from '../../../src/services/dtryx/location.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();
+ });
+
+ 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/regressions.test.ts b/tests/services/dtryx/regressions.test.ts
new file mode 100644
index 00000000..d60109a0
--- /dev/null
+++ b/tests/services/dtryx/regressions.test.ts
@@ -0,0 +1,147 @@
+/** 디트릭스 입력·필터·외부 장애 회귀 테스트 */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ handleDtryxGetRemainingSeats,
+ handleDtryxListCinemas,
+ handleDtryxListNowShowing,
+} from '../../../src/api/dtryxHandlers.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();
+const validEmpty = () => new Response(JSON.stringify({ RetCode: 'success', Recordset: [] }));
+function context(query: Record = {}) {
+ return {
+ req: { query: (key: string) => query[key] },
+ json: (body: unknown, status = 200) => ({ body, status }),
+ } as unknown as Parameters[0];
+}
+beforeEach(() => {
+ mockFetch.mockReset();
+ vi.stubGlobal('fetch', mockFetch);
+});
+afterEach(() => vi.unstubAllGlobals());
+
+describe('극장 선택과 외부 장애', () => {
+ it.each([
+ { keyword: '모모', region: '경기' },
+ { keyword: '모모', brandCode: 'etc' },
+ { cinemaCode: '000067', brandCode: 'etc' },
+ ])('모든 필터를 함께 적용한다: %o', async (query) => {
+ mockFetch.mockImplementation(validEmpty);
+ const api = (await handleDtryxGetRemainingSeats(context(query))) as unknown as {
+ status: number;
+ };
+ const mcp = await createGetRemainingSeatsTool().handler(query);
+ expect(api.status).toBe(404);
+ expect(mcp.isError).toBe(true);
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+ it('상영작은 극장 지정 없이 조회하지 않는다', async () => {
+ mockFetch.mockImplementation(validEmpty);
+ expect(await handleDtryxListNowShowing(context())).toMatchObject({ status: 404 });
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+ it('상영작도 브랜드 필터를 적용한다', async () => {
+ mockFetch.mockImplementation(validEmpty);
+ expect(
+ await handleDtryxListNowShowing(context({ keyword: '모모', brandCode: 'etc' })),
+ ).toMatchObject({ status: 404 });
+ expect(
+ (await createListNowShowingTool().handler({ keyword: '모모', brandCode: 'etc' })).isError,
+ ).toBe(true);
+ });
+ it('모든 극장 요청 실패는 API 503과 MCP 오류로 반환한다', async () => {
+ mockFetch.mockRejectedValue(new Error('upstream down'));
+ expect(await handleDtryxGetRemainingSeats(context({ region: '서울' }))).toMatchObject({
+ status: 503,
+ body: { success: false },
+ });
+ expect((await createGetRemainingSeatsTool().handler({ region: '서울' })).isError).toBe(true);
+ });
+ it.each([false, true])('빈 정상 회차와 일부 실패는 성공으로 유지한다: %s', async (partial) => {
+ mockFetch.mockImplementation(validEmpty);
+ if (partial) mockFetch.mockRejectedValueOnce(new Error('down'));
+ expect(await handleDtryxGetRemainingSeats(context({ region: '서울' }))).toMatchObject({
+ status: 200,
+ });
+ if (partial) mockFetch.mockRejectedValueOnce(new Error('down'));
+ expect((await createGetRemainingSeatsTool().handler({ region: '서울' })).isError).not.toBe(
+ true,
+ );
+ });
+ it('카탈로그 외 코드와 브랜드를 직접 조회할 수 있다', async () => {
+ mockFetch.mockImplementation(validEmpty);
+ expect(
+ await handleDtryxListNowShowing(context({ cinemaCode: '999999', brandCode: 'etc' })),
+ ).toMatchObject({ status: 200 });
+ expect(
+ (await createGetRemainingSeatsTool().handler({ cinemaCode: '999999', brandCode: 'etc' }))
+ .isError,
+ ).not.toBe(true);
+ expect(String(mockFetch.mock.calls[0][0])).toContain('CinemaCd=999999');
+ });
+});
+
+describe('입력 검증', () => {
+ it.each(['0', '-1', '201', '1.5', 'abc', 'Infinity'])(
+ '잘못된 limit을 거절한다: %s',
+ async (limit) => {
+ for (const handler of [handleDtryxListCinemas, handleDtryxGetRemainingSeats]) {
+ expect(await handler(context({ limit }))).toMatchObject({ status: 400 });
+ }
+ for (const tool of [createListCinemasTool(), createGetRemainingSeatsTool()]) {
+ expect((await tool.handler({ limit: Number(limit) })).isError).toBe(true);
+ }
+ expect(mockFetch).not.toHaveBeenCalled();
+ },
+ );
+ it.each(['0', '-1', '60001', '1.5', 'abc'])(
+ '잘못된 timeout을 거절한다: %s',
+ async (timeoutMs) => {
+ for (const handler of [handleDtryxListNowShowing, handleDtryxGetRemainingSeats]) {
+ expect(await handler(context({ cinemaCode: '000067', timeoutMs }))).toMatchObject({
+ status: 400,
+ });
+ }
+ for (const tool of [createListNowShowingTool(), createGetRemainingSeatsTool()]) {
+ expect(
+ (await tool.handler({ cinemaCode: '000067', timeoutMs: Number(timeoutMs) })).isError,
+ ).toBe(true);
+ }
+ expect(mockFetch).not.toHaveBeenCalled();
+ },
+ );
+ it.each(['20260230', '2026-13-01', '2026-2-01', 'tomorrow'])(
+ '유효하지 않은 날짜를 거절한다: %s',
+ async (playDate) => {
+ expect(await handleDtryxGetRemainingSeats(context({ playDate }))).toMatchObject({
+ status: 400,
+ });
+ expect((await createGetRemainingSeatsTool().handler({ playDate })).isError).toBe(true);
+ expect(mockFetch).not.toHaveBeenCalled();
+ },
+ );
+});
+
+describe('응답 envelope 검증', () => {
+ it.each([
+ { RetCode: 'failed', RetMsg: 'upstream error', Recordset: [] },
+ { RetCode: 'success' },
+ { Recordset: [] },
+ { RetCode: 'success', Recordset: {} },
+ { RetCode: 'success', Recordset: [null] },
+ null,
+ ])('실패하거나 잘못된 응답을 빈 목록으로 처리하지 않는다: %o', async (body) => {
+ mockFetch.mockImplementation(() => new Response(JSON.stringify(body)));
+ for (const request of [fetchDtryxNowShowing, fetchDtryxPlayDates, fetchDtryxTimetable]) {
+ await expect(request({ brandCode: 'indieart', cinemaCode: '000067' })).rejects.toThrow();
+ }
+ });
+});
diff --git a/tests/services/dtryx/tools/dtryxTools.test.ts b/tests/services/dtryx/tools/dtryxTools.test.ts
new file mode 100644
index 00000000..0faeb65d
--- /dev/null
+++ b/tests/services/dtryx/tools/dtryxTools.test.ts
@@ -0,0 +1,189 @@
+/**
+ * 디트릭스 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({ RetCode: 'success', ...(body as Record) }),
+ {
+ 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('지역으로 걸러낸다', async () => {
+ const payload = parsePayload(await createListCinemasTool().handler({ region: '서울' }));
+
+ expect(payload.count).toBe(7);
+ });
+
+ 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({ region: '경기', playDate: '20260820' }),
+ );
+
+ expect(payload.searchedCinemaCount).toBe(3);
+ });
+
+ 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);
+ });
+});
diff --git a/tests/utils/httpBodyTimeout.test.ts b/tests/utils/httpBodyTimeout.test.ts
new file mode 100644
index 00000000..0e1f41dc
--- /dev/null
+++ b/tests/utils/httpBodyTimeout.test.ts
@@ -0,0 +1,82 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { fetchJson, fetchText, fetchWithTimeout } from '../../src/utils/http.js';
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+
+describe('응답 본문 제한 시간', () => {
+ it.each([200, 404])('본문 읽기 실패는 추가 요청을 만들지 않는다 (%s)', async (status) => {
+ const fetchMock = vi.fn(
+ async () =>
+ new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.error(new Error('body disconnected'));
+ },
+ }),
+ { status },
+ ),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+ await expect(fetchText('https://example.com', { retries: 1, retryDelayMs: 0 })).rejects.toThrow(
+ 'body disconnected',
+ );
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it.each(['{broken', 'error'])('본문 해석 실패는 재시도하지 않는다', async (body) => {
+ const fetchMock = vi.fn(async () => new Response(body));
+ vi.stubGlobal('fetch', fetchMock);
+ await expect(
+ fetchJson('https://example.com', { retries: 1, retryDelayMs: 0 }),
+ ).rejects.toThrow();
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('fetchWithTimeout은 읽지 않은 스트림 응답을 그대로 반환한다', async () => {
+ const response = new Response(new ReadableStream());
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => response),
+ );
+ await expect(fetchWithTimeout('https://example.com')).resolves.toBe(response);
+ expect(response.bodyUsed).toBe(false);
+ await response.body?.cancel();
+ });
+
+ it.each([fetchJson, fetchText])('헤더를 받은 뒤 멈춘 본문도 중단한다', async (read) => {
+ vi.useFakeTimers();
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(
+ async (_url, init) =>
+ new Response(
+ new ReadableStream({
+ start(controller) {
+ init.signal.addEventListener('abort', () =>
+ controller.error(new DOMException('aborted', 'AbortError')),
+ );
+ },
+ }),
+ ),
+ ),
+ );
+ const result = read('https://example.com', { timeout: 10 });
+ let settled = false;
+ const observed = result.then(
+ () => {
+ settled = true;
+ expect.unreachable('본문 제한 시간이 누락됐습니다');
+ },
+ (error) => {
+ settled = true;
+ expect(error.name).toBe('AbortError');
+ },
+ );
+ await vi.advanceTimersByTimeAsync(11);
+ expect(settled).toBe(true);
+ await observed;
+ });
+});