diff --git a/docs/plans/2026-10-05-quality-improvements.md b/docs/plans/2026-10-05-quality-improvements.md new file mode 100644 index 00000000..4e99c55f --- /dev/null +++ b/docs/plans/2026-10-05-quality-improvements.md @@ -0,0 +1,52 @@ +# 전체 조회 품질 개선 구현 계획 + +> 작업 에이전트는 superpowers:subagent-driven-development와 TDD를 적용합니다. 사용자 승인 범위는 2026-10-05 전체 감사 결과의 모든 개선 항목입니다. + +**목표:** 실제 0개와 확인 불가를 구분하고, 요청한 상품·지역·영화 범위를 유지하며, 모든 REST/MCP/CLI 경로가 동일한 계약을 제공합니다. + +**설계:** 정상 응답 필드와 명령은 유지합니다. 누락된 수량에는 null 및 확인 상태를 사용하거나 구조가 깨진 upstream 응답에는 명시적인 오류를 반환합니다. 외부 실패를 빈 검색·품절로 변환하지 않습니다. 기존 서비스별 모듈을 수정하고 공통 코드에는 필요한 검증과 오류 전달만 둡니다. + +**기술:** TypeScript, Zod, Hono, MCP SDK, Vitest, Playwright 중계. + +## 재고 서비스 + +대상: `src/services/{daiso,cu,gs25,emart24,seveneleven}`, 관련 `tests/services`와 `tests/api`. + +- [x] 감사 F1–F5, L3·L5의 fixture를 회귀 테스트로 옮겨 실패를 확인합니다. `success:false`는 0으로 변환되지 않아야 합니다. `콜라제로`, `콜라` 후보에서 정확한 `콜라`를 선택해야 합니다. 부산 요청에 서울 매장을 반환하면 안 됩니다. 수량 필드가 없는 매장은 품절로 확정하면 안 됩니다. 페이지 2의 이미 페이지 처리된 상품은 다시 자르면 안 됩니다. 요청 pageSize를 지켜야 합니다. +- [x] 각 최소 수정 후 해당 서비스 테스트를 실행합니다. API와 도구의 nullable 수량 타입·집계·렌더링을 확인합니다. 메타데이터와 재고 결과를 분리하며 실제 0은 그대로 유지합니다. +- [x] CU REST 후보 선택은 공통 선택 함수를 통해 MCP와 같게 만듭니다. 공통 REST `handlers.ts` 수정은 root에게 전달합니다. + +## 영화관과 생활 정보 + +대상: `src/services/{cgv,megabox,lottecinema,dtryx,places,opinet}`, 관련 API와 테스트, 필요 시 `src/utils/format.ts`. + +- [x] F6–F10, L6의 실패 테스트를 먼저 실행합니다. `frSeatCnt:0, frtmpSeatCnt:17`은 0이어야 합니다. 잔여 수량 누락은 0·예약100으로 확정하면 안 됩니다. 극장 해석 실패 뒤 무제한 조회를 하면 안 됩니다. 잘못된 영화는 다른 영화로 완화하지 않습니다. HTML·잘못된 envelope는 정상 빈 결과가 아닙니다. +- [x] YYYYMMDD의 실제 달력 날짜, 양수 한도와 유효 좌표를 REST/MCP 모두 검증합니다. 정상 기존 요청과 0좌석은 유지합니다. +- [x] 해당 서비스와 API 테스트 및 타입 검사를 실행합니다. 공통 숫자 변환의 기존 소비자를 모두 확인합니다. + +## 올리브영과 중계 복구 + +대상: `scripts/relay/{ownership,lifecycle,guard-runtime,supervisor}`, `src/services/oliveyoung`, 관련 테스트. + +- [x] F11–F14와 정리 시간·캐시 위험을 실패 테스트로 재현합니다. 이미 소유 프로세스가 없는 close rejection은 복구 가능해야 합니다. 소유권을 확인하지 못하면 기존 안전한 차단을 유지합니다. +- [x] cleanup 전체 시간이 guard/supervisor/lifecycle 예산 안에 들어오도록 단일 deadline을 지킵니다. 가짜 시계로 최악 경로를 검증합니다. +- [x] 빈 `SUCCESS data:{}`를 확인 완료 품절로 집계하지 않습니다. 모든 매장이 미판매면 `not_sold`입니다. 지역 집계는 `nearby_store`에서 확인한 결과만 셉니다. +- [x] 검색 캐시에 저장된 과거 지역 재고를 실패 시 현재 재고로 재사용하지 않습니다. 필요하면 관측 시간과 stale 상태를 명시하되 검색 기능의 정상 캐시는 유지합니다. +- [x] API 공통 `handlers.ts` 변경은 root에게 전달합니다. 실제 운영 브라우저·quota 원장을 변경하지 않습니다. + +## 공통 계약과 통합 + +대상: `src/core/outputSchema.ts`, `src/services/compare`, registry, 비교 REST, `src/utils/convenienceTransport.ts`, 서비스 REST 오류 처리, `src/cli/http.ts`, `package.json`. + +- [x] `createToolOutputSchema('megabox_find_nearby_theaters').safeParse({keyword:null,theaters:[]})`와 카탈로그 `{pages:{totalCount:0,items:[]}}`가 성공하는 테스트를 먼저 실패시킵니다. 실제 스키마를 좁게 맞춥니다. +- [x] 비교 GS25/Seven 요청이 정상 개별 조회와 같은 중계 설정을 받도록 옵션을 factory→도구/REST→client에 전달합니다. 비교 per-service 결과 한도를 지킵니다. +- [x] 가짜 relay HTTP429 및 `Retry-After:30`, `x-relay-quota-reason:minute`를 주입한 실제 REST handler가 429 및 재시도 정보를 반환하는 테스트를 만듭니다. ServiceError의 status와 quota 정보를 끝까지 보존합니다. +- [x] CLI `SERVICE_RETIRED`에 폐기된 롯데마트 명령을 안내하지 않는 테스트를 실행하고 수정합니다. +- [x] `workers-mcp`가 코드·문서·배포에서 사용되지 않는지 조사하고 미사용이면 제거합니다. production audit를 다시 실행합니다. + +## 통합 검증과 전달 + +- [x] 영역별 수정은 독립 리뷰로 요구사항 충족과 코드 품질을 확인합니다. 450줄 규칙을 지킵니다. +- [x] `npm run check`, `npm run test:coverage`, `npm run build`, `npm audit --omit=dev`, Worker dry run을 실행합니다. 100% coverage를 유지합니다. +- [x] 감사의 각 ID를 수정·회귀 테스트·남은 제한과 연결하는 변경 기록을 저장합니다. OpenAPI를 재생성합니다. +- [ ] 변경을 커밋하고 PR을 만들며 첨부합니다. 기존 사용자 승인 범위와 저장소 규칙에 따라 통합을 진행하고, 운영 검증에서는 남은 quota를 확인해 기존 제한을 유지합니다. diff --git a/docs/quality-improvements-2026-10-05.md b/docs/quality-improvements-2026-10-05.md new file mode 100644 index 00000000..3445b1e7 --- /dev/null +++ b/docs/quality-improvements-2026-10-05.md @@ -0,0 +1,42 @@ +# 조회 오동작 개선 기록 + +2026년 10월 5일 전체 감사에서 확인한 조회 실패와 잘못된 확정값을 수정했습니다. 정상 수량 0개는 그대로 유지하고, 관측되지 않은 수량은 확인 불가로 구분합니다. Google 지오코딩·Zyte 비활성화와 롯데마트 지원 종료 정책은 유지합니다. + +## 재고와 좌석의 의미 + +- 다이소·CU·GS25·이마트24의 수량이 `null`이면 upstream 수량을 확인하지 못한 상태입니다. CLI는 “확인 불가”로 표시합니다. 세븐일레븐은 기존 미확인 값 `-1`을 유지합니다. 실제 숫자 0은 확인된 0개입니다. +- 다이소가 명시적으로 실패한 재고 응답을 보내면 성공 0개로 변환하지 않습니다. 매장 집계에는 `unknownStockCount`를 제공합니다. +- CU·이마트24는 정확한 상품명 후보를 우선 선택합니다. 일반 키워드의 첫 후보를 사용하는 경우 `selectionReason`으로 선택 근거를 제공합니다. 세븐일레븐의 지역 매칭 실패는 다른 지역 매장으로 대체하지 않습니다. +- GS25 매장 메타데이터와 상품 후보에서 관측되지 않은 재고는 `null`입니다. `unknownStockStoreCount`를 함께 제공하며, 알려진 수량이 하나도 없으면 합계도 `null`입니다. +- CGV의 숫자 0은 임시 수량으로 덮어쓰지 않습니다. 각 영화관의 좌석 수가 없거나 비정상이면 해당 수량과 유도된 예약 수를 `null`로 제공합니다. +- 올리브영은 지역에서 확인한 상품만 지역 재고 집계에 포함합니다. 빈 재고 payload는 미확인, 전부 미판매인 상품은 `not_sold`입니다. 지역 조회 실패 시 검색 캐시에 남은 과거 매장 수량을 재사용하지 않습니다. + +수량을 무조건 숫자로 가정하던 API 소비자는 `null`을 확인 불가로 처리해야 합니다. `null`을 숫자 0으로 변환하면 이번에 수정한 품절 오판을 다시 만들게 됩니다. OpenAPI 및 MCP 출력 계약도 갱신했습니다. + +## 조회 범위와 호출 계약 + +| 감사 항목 | 개선 결과 | 회귀 검증 | +|---|---|---| +| L1·L2 | 좌표 전용 메가박스 MCP의 null 검색어, 세븐일레븐 카탈로그의 `{totalCount,items}` 구조 허용 | `quality-contracts.test.ts` | +| L3·L5 | 세븐일레븐 MCP의 중복 페이지 처리 제거와 전체 개수 보존, 이마트24 개수 한도 적용 | `inventory-quality`, `productKeyword` | +| L4 | REST/MCP 가격 비교에도 편의점 중계 설정 전달, 모든 서비스 결과 개수 한도 적용 | `quality-contracts`의 중계 경로·서비스별 과다 응답 검사 | +| L6·F6–F8 | 영화 코드 필터 엄격 유지, 실제 0좌석 보존, 누락 좌석 nullable, 메가박스 위치 해석 실패 시 범위 확대 금지 | 영화관 `quality-regressions`, `seat-count` | +| L7 | 편의점의 typed 중계 오류를 보존. HTTP 429와 Retry-After, diagnostics.quotaReason/retryAfter 전달 | 실제 Hono GS25·CU·Seven handler 검사 및 `inventory-quota` | +| L8 | 롯데마트 지원 종료 오류에 폐기된 조회 명령 안내 제거 | `quality-cli` | +| F1–F5 | 미확인 수량, 정확한 후보 선택, 지역 매칭 및 합계 수정 | `inventory-quality`, `inventory-summary`, `inventoryQuantity` | +| F9·F10 | Places·Opinet 잘못된 JSON/envelope 거절. 영화관 날짜·양수 정수 한도·좌표 쌍 검증 | 각 client 및 `cinema-validation` | +| F11–F14 | 올리브영 종료 복구, 미확인/미판매 상태, 지역 집계, 과거 재고 재사용 방지 | OY `quality-regressions`, REST `quality-oliveyoung-counters`, relay 테스트 | + +## 올리브영 정리 시간과 복구 + +정상 종료의 상위 lifecycle이 정한 10초 절대 deadline을 supervisor·guard·ownership으로 전달합니다. ownership은 guard의 profile 정리와 종료 확인을 위해 500ms를 남깁니다. IPC 연결 해제 같은 독립 정리는 로컬 8초 예산을 사용합니다. 서로 다른 층에서 시간을 새로 시작해 종료 기한을 넘기는 문제를 방지합니다. + +이미 소유 프로세스가 없다는 snapshot을 확인했다면 Playwright close rejection 때문에 영구 실패를 유지하지 않습니다. 소유권이 불확실하거나 프로세스·profile 종료를 시간 안에 확인하지 못하면 marker와 실패 상태를 유지합니다. 임의 Chrome을 종료하거나 marker를 무조건 지우지 않습니다. 가짜 시계 테스트에서 느린 close·강제 회수 확인·profile 완료와 시간 초과를 함께 검증했습니다. + +## 의존성과 검증 + +코드·스크립트에서 사용하지 않는 `workers-mcp`를 제거했습니다. 이 의존성이 가져오던 `braces` 취약점 경로도 제거되어 production 및 전체 의존성 audit는 0건입니다. + +수정 후 검증은 228개 파일·2,631개 테스트, `npm run check`, 100% Statements/Branches/Functions/Lines coverage, build, Worker dry run을 통과했습니다. 독립 리뷰에서 비교 결과 한도·GS25 미확인 합계·메타데이터의 잘못된 0·REST/MCP 빈 결과 차이를 찾아 추가 수정하고 재검증했습니다. + +운영 확인은 배포 후 같은 상품 코드·매장·지역의 결과를 REST/MCP/공개 CLI로 확인해야 합니다. 정상 검색 결과만으로 모든 실패 경로가 정상이라는 결론을 내리지 않습니다. 편의점 공유 매분 30회·일일 3,000회 원장과 인증 세션을 보존하고, 예산이 부족하면 재시도 시각을 기다립니다. diff --git a/openapi.json b/openapi.json index cad38f39..5746f6ca 100644 --- a/openapi.json +++ b/openapi.json @@ -826,6 +826,7 @@ }, "quantity": { "type": "integer", + "nullable": true, "description": "재고 수량", "example": 12 }, @@ -952,6 +953,7 @@ }, "onlineStock": { "type": "integer", + "nullable": true, "description": "온라인 재고 수량", "example": 150 }, @@ -966,6 +968,10 @@ "type": "integer", "description": "재고 있는 매장 수" }, + "unknownStockCount": { + "type": "integer", + "description": "수량을 확인하지 못한 매장 수" + }, "stores": { "type": "array", "items": { @@ -1075,7 +1081,9 @@ "type": "string", "enum": [ "in_stock", - "out_of_stock" + "out_of_stock", + "not_sold", + "unknown" ], "example": "in_stock" }, @@ -1326,6 +1334,9 @@ "outOfStockCount": { "type": "integer" }, + "notSoldCount": { + "type": "integer" + }, "products": { "type": "array", "items": { @@ -1421,10 +1432,12 @@ }, "totalSeats": { "type": "integer", + "nullable": true, "example": 120 }, "remainingSeats": { "type": "integer", + "nullable": true, "example": 42 } } @@ -1643,6 +1656,7 @@ }, "stock": { "type": "integer", + "nullable": true, "example": 10 }, "deliveryYn": { @@ -2058,6 +2072,7 @@ }, "bizQty": { "type": "integer", + "nullable": true, "example": 3 }, "storeName": { @@ -2113,6 +2128,7 @@ }, "realStockQuantity": { "type": "integer", + "nullable": true, "example": 3 }, "distanceM": { @@ -2152,8 +2168,13 @@ "type": "integer", "example": 42 }, + "unknownStockStoreCount": { + "type": "integer", + "example": 0 + }, "totalStockQuantity": { "type": "integer", + "nullable": true, "example": 110 } } @@ -2264,8 +2285,13 @@ "type": "integer", "example": 35 }, + "unknownStockStoreCount": { + "type": "integer", + "example": 0 + }, "totalStockQuantity": { "type": "integer", + "nullable": true, "example": 90 }, "stores": { @@ -2591,14 +2617,17 @@ }, "totalSeats": { "type": "integer", + "nullable": true, "example": 32 }, "bookedSeats": { "type": "integer", + "nullable": true, "example": 4 }, "remainingSeats": { "type": "integer", + "nullable": true, "example": 28 } } @@ -2900,10 +2929,12 @@ }, "totalSeats": { "type": "integer", + "nullable": true, "example": 150 }, "remainingSeats": { "type": "integer", + "nullable": true, "example": 42 } } diff --git a/openapi.yaml b/openapi.yaml index cdc5df67..4962d88e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -589,6 +589,7 @@ components: example: 0.5km quantity: type: integer + nullable: true description: 재고 수량 example: 12 options: @@ -675,6 +676,7 @@ components: format: float onlineStock: type: integer + nullable: true description: 온라인 재고 수량 example: 150 storeInventory: @@ -686,6 +688,9 @@ components: inStockCount: type: integer description: 재고 있는 매장 수 + unknownStockCount: + type: integer + description: 수량을 확인하지 못한 매장 수 stores: type: array items: @@ -765,6 +770,8 @@ components: enum: - in_stock - out_of_stock + - not_sold + - unknown example: in_stock stockSource: type: string @@ -940,6 +947,8 @@ components: type: integer outOfStockCount: type: integer + notSoldCount: + type: integer products: type: array items: @@ -1006,9 +1015,11 @@ components: example: "11:20" totalSeats: type: integer + nullable: true example: 120 remainingSeats: type: integer + nullable: true example: 42 MegaboxTheaterSearchResponse: type: object @@ -1156,6 +1167,7 @@ components: example: 97 stock: type: integer + nullable: true example: 10 deliveryYn: type: boolean @@ -1446,6 +1458,7 @@ components: example: 28339 bizQty: type: integer + nullable: true example: 3 storeName: type: string @@ -1483,6 +1496,7 @@ components: example: 127.0276 realStockQuantity: type: integer + nullable: true example: 3 distanceM: type: number @@ -1511,8 +1525,12 @@ components: inStockStoreCount: type: integer example: 42 + unknownStockStoreCount: + type: integer + example: 0 totalStockQuantity: type: integer + nullable: true example: 110 Gs25StoreSearchResponse: type: object @@ -1589,8 +1607,12 @@ components: inStockStoreCount: type: integer example: 35 + unknownStockStoreCount: + type: integer + example: 0 totalStockQuantity: type: integer + nullable: true example: 90 stores: type: array @@ -1823,12 +1845,15 @@ components: example: "12:47" totalSeats: type: integer + nullable: true example: 32 bookedSeats: type: integer + nullable: true example: 4 remainingSeats: type: integer + nullable: true example: 28 LotteCinemaTheaterSearchResponse: type: object @@ -2035,9 +2060,11 @@ components: example: "11:20" totalSeats: type: integer + nullable: true example: 150 remainingSeats: type: integer + nullable: true example: 42 CgvTheaterSearchResponse: type: object diff --git a/package-lock.json b/package-lock.json index 47d6d640..f04b3a48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "hono": "^4.13.7", - "proj4": "^2.22.0", - "workers-mcp": "^0.0.13" + "proj4": "^2.22.0" }, "bin": { "daiso": "dist/bin.js" @@ -44,6 +43,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -53,6 +53,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -62,6 +63,7 @@ "version": "7.29.9", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.8" @@ -77,6 +79,7 @@ "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -295,27 +298,6 @@ "keyv": "^5.6.0" } }, - "node_modules/@clack/core": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.3.5.tgz", - "integrity": "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==", - "license": "MIT", - "dependencies": { - "picocolors": "^1.0.0", - "sisteransi": "^1.0.5" - } - }, - "node_modules/@clack/prompts": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.8.2.tgz", - "integrity": "sha512-6b9Ab2UiZwJYA9iMyboYyW9yJvAO9V753ZhS+DHKEjZRKAxPPOb7MXXu84lsPFG+vZt6FRFniZ8rXi+zCIw4yQ==", - "license": "MIT", - "dependencies": { - "@clack/core": "0.3.5", - "picocolors": "^1.0.0", - "sisteransi": "^1.0.5" - } - }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", @@ -476,6 +458,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -492,6 +475,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -508,6 +492,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -524,6 +509,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -540,6 +526,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -556,6 +543,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -572,6 +560,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -588,6 +577,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -604,6 +594,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -620,6 +611,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -636,6 +628,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -652,6 +645,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -668,6 +662,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -684,6 +679,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -700,6 +696,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -716,6 +713,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -732,6 +730,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -748,6 +747,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -764,6 +764,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -780,6 +781,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -796,6 +798,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -812,6 +815,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -828,6 +832,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -844,6 +849,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -860,6 +866,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -876,6 +883,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1694,18 +1702,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@jsdoc/salty": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.12.tgz", - "integrity": "sha512-TuB0x50EoAvEX/UEWITd8Mkn3WhiTjSvbTMCLj0BhsQEl5iUzjXdA0bETEVpTk+5TGTLR6QktI9H4hLviVeaAQ==", - "license": "Apache-2.0", - "dependencies": { - "lodash": "^4.18.1" - }, - "engines": { - "node": ">=v12.0.0" - } - }, "node_modules/@keyv/bigmap": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", @@ -1770,41 +1766,6 @@ } } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@oxc-project/types": { "version": "0.150.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", @@ -2141,12 +2102,6 @@ "license": "MIT", "peer": true }, - "node_modules/@silvia-odwyer/photon-node": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", - "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", - "license": "Apache-2.0" - }, "node_modules/@sindresorhus/is": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", @@ -2206,28 +2161,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "license": "MIT" - }, - "node_modules/@types/markdown-it": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", - "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", - "license": "MIT", - "dependencies": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" - } - }, - "node_modules/@types/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", @@ -2627,21 +2560,6 @@ } } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/array-back": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.3.tgz", - "integrity": "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==", - "license": "MIT", - "engines": { - "node": ">=12.17" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2681,12 +2599,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "license": "MIT" - }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -2737,18 +2649,6 @@ "node": "20 || >=22" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2758,26 +2658,6 @@ "node": ">= 0.8" } }, - "node_modules/cache-point": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/cache-point/-/cache-point-3.0.1.tgz", - "integrity": "sha512-itTIMLEKbh6Dw5DruXbxAgcyLnh/oPGVLBfTPqBOftASxHe8bAeXy7JkO4F0LvHqht7XqP5O/09h5UcHS2w0FA==", - "license": "MIT", - "dependencies": { - "array-back": "^6.2.2" - }, - "engines": { - "node": ">=12.17" - }, - "peerDependencies": { - "@75lb/nature": "latest" - }, - "peerDependenciesMeta": { - "@75lb/nature": { - "optional": true - } - } - }, "node_modules/cacheable": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", @@ -2821,18 +2701,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/catharsis": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.9.0.tgz", - "integrity": "sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.15" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2843,24 +2711,6 @@ "node": ">=18" } }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" - }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -2932,15 +2782,6 @@ "node": ">= 8" } }, - "node_modules/current-module-paths": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/current-module-paths/-/current-module-paths-1.1.3.tgz", - "integrity": "sha512-7AH+ZTRKikdK4s1RmY0l6067UD/NZc7p3zZVZxvmnH80G31kr0y0W0E6ibYM4IS01MEm8DiC5FnTcgcgkbFHoA==", - "license": "MIT", - "engines": { - "node": ">=12.17" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3013,18 +2854,6 @@ "node": ">= 0.8" } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/error-stack-parser-es": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", @@ -3076,6 +2905,7 @@ "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -3445,34 +3275,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3503,15 +3305,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fastq": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", - "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3540,39 +3333,6 @@ "flat-cache": "^6.1.23" } }, - "node_modules/file-set": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/file-set/-/file-set-5.3.0.tgz", - "integrity": "sha512-FKCxdjLX0J6zqTWdT0RXIxNF/n7MyXXnsSUp0syLEOCKdexvPZ02lNNv2a+gpK9E3hzUYF3+eFZe32ci7goNUg==", - "license": "MIT", - "dependencies": { - "array-back": "^6.2.2", - "fast-glob": "^3.3.2" - }, - "engines": { - "node": ">=12.17" - }, - "peerDependencies": { - "@75lb/nature": "latest" - }, - "peerDependenciesMeta": { - "@75lb/nature": { - "optional": true - } - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -3648,24 +3408,11 @@ "node": ">= 0.8" } }, - "node_modules/fs-extra": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", - "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3760,12 +3507,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3903,6 +3644,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3912,6 +3654,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -3920,15 +3663,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -3967,79 +3701,6 @@ "dev": true, "license": "MIT" }, - "node_modules/js2xmlparser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz", - "integrity": "sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==", - "license": "Apache-2.0", - "dependencies": { - "xmlcreate": "^2.0.4" - } - }, - "node_modules/jsdoc": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.5.tgz", - "integrity": "sha512-P4C6MWP9yIlMiK8nwoZvxN84vb6MsnXcHuy7XzVOvQoCizWX5JFCBsWIIWKXBltpoRZXddUOVQmCTOZt9yDj9g==", - "license": "Apache-2.0", - "dependencies": { - "@babel/parser": "^7.20.15", - "@jsdoc/salty": "^0.2.1", - "@types/markdown-it": "^14.1.1", - "bluebird": "^3.7.2", - "catharsis": "^0.9.0", - "escape-string-regexp": "^2.0.0", - "js2xmlparser": "^4.0.2", - "klaw": "^3.0.0", - "markdown-it": "^14.1.0", - "markdown-it-anchor": "^8.6.7", - "marked": "^4.0.10", - "mkdirp": "^1.0.4", - "requizzle": "^0.2.3", - "strip-json-comments": "^3.1.0", - "underscore": "~1.13.2" - }, - "bin": { - "jsdoc": "jsdoc.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/jsdoc-api": { - "version": "9.3.6", - "resolved": "https://registry.npmjs.org/jsdoc-api/-/jsdoc-api-9.3.6.tgz", - "integrity": "sha512-8JW0532+rXVw8LoZ1LIAeKofsV8QQZhnY3chxMHV9hQdsuDTshsajwk0b4EMxCqen2vZ2op/r/qeEsqZxsEQyg==", - "license": "MIT", - "dependencies": { - "array-back": "^6.2.3", - "cache-point": "^3.0.1", - "current-module-paths": "^1.1.3", - "file-set": "^5.3.0", - "jsdoc": "^4.0.5", - "object-to-spawn-args": "^2.0.1", - "walk-back": "^5.1.2" - }, - "engines": { - "node": ">=12.17" - }, - "peerDependencies": { - "@75lb/nature": "latest" - }, - "peerDependenciesMeta": { - "@75lb/nature": { - "optional": true - } - } - }, - "node_modules/jsdoc/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -4059,30 +3720,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/just-filter-object": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/just-filter-object/-/just-filter-object-3.2.0.tgz", - "integrity": "sha512-OeorYJxmp2zhy/0LxjS1UjbJ7XMY8M4gVa1RRKxnIVheCYmng2E2hE0lEbDGv4aRh/HI7FgNUXtOMnmNxpoXRQ==", - "license": "MIT" - }, - "node_modules/just-map-values": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/just-map-values/-/just-map-values-3.2.0.tgz", - "integrity": "sha512-TyqCKtK3NxiUgOjRYMIKURvBTHesi3XzomDY0QVPZ3rYzLCF+nNq5rSi0B/L5aOd/WMTZo6ukzA4wih4HUbrDg==", - "license": "MIT" - }, "node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", @@ -4093,15 +3730,6 @@ "@keyv/serialize": "^1.1.1" } }, - "node_modules/klaw": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", - "integrity": "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.9" - } - }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -4411,25 +4039,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/linkify-it": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", - "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/markdown-it" - } - ], - "license": "MIT", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4446,12 +4055,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, "node_modules/magic-string": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", @@ -4474,55 +4077,6 @@ "source-map-js": "^1.2.1" } }, - "node_modules/markdown-it": { - "version": "14.3.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", - "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/markdown-it" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.5.0", - "linkify-it": "^5.0.2", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" - }, - "bin": { - "markdown-it": "bin/markdown-it.mjs" - } - }, - "node_modules/markdown-it-anchor": { - "version": "8.6.7", - "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz", - "integrity": "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==", - "license": "Unlicense", - "peerDependencies": { - "@types/markdown-it": "*", - "markdown-it": "*" - } - }, - "node_modules/marked": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", - "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 12" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4532,12 +4086,6 @@ "node": ">= 0.4" } }, - "node_modules/mdurl": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", - "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", - "license": "MIT" - }, "node_modules/media-typer": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", @@ -4563,46 +4111,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/mgrs": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz", "integrity": "sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==", "license": "MIT" }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -4662,18 +4176,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4736,62 +4238,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/npm-path": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/npm-path/-/npm-path-2.0.4.tgz", - "integrity": "sha512-IFsj0R9C7ZdR5cP+ET342q77uSRdtWOlWpih5eC+lu29tIDbNEgDbzgVJ5UFvYHWhxDZ5TFkJafFioO0pPQjCw==", - "license": "MIT", - "dependencies": { - "which": "^1.2.10" - }, - "bin": { - "npm-path": "bin/npm-path" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/npm-path/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, - "node_modules/npm-which": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/npm-which/-/npm-which-3.0.1.tgz", - "integrity": "sha512-CM8vMpeFQ7MAPin0U3wzDhSGV0hMHNwHU0wjo402IVizPDrs45jSfSuoC+wThevY88LQti8VvaAnqYAeVy3I1A==", - "license": "MIT", - "dependencies": { - "commander": "^2.9.0", - "npm-path": "^2.0.2", - "which": "^1.2.10" - }, - "bin": { - "npm-which": "bin/npm-which.js" - }, - "engines": { - "node": ">=4.2.0" - } - }, - "node_modules/npm-which/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4813,15 +4259,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-to-spawn-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/object-to-spawn-args/-/object-to-spawn-args-2.0.1.tgz", - "integrity": "sha512-6FuKFQ39cOID+BMZ3QaphcC8Y4cw6LXBLyIgPU+OhIYwviJamPAn+4mITapnSBQrejB+NNp+FMskhD8Cq+Ys3w==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/obug": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", @@ -4956,7 +4393,9 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" + "dev": true, + "license": "ISC", + "peer": true }, "node_modules/picomatch": { "version": "4.0.7", @@ -5113,15 +4552,6 @@ "node": ">=6" } }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/qified": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", @@ -5158,26 +4588,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/range-parser": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", @@ -5215,25 +4625,6 @@ "node": ">=0.10.0" } }, - "node_modules/requizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.4.tgz", - "integrity": "sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.21" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rolldown": { "version": "1.2.9", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", @@ -5285,29 +4676,6 @@ "node": ">= 18" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -5528,12 +4896,6 @@ "dev": true, "license": "ISC" }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "license": "MIT" - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5567,18 +4929,6 @@ "dev": true, "license": "MIT" }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", @@ -5639,36 +4989,6 @@ "node": ">=14.0.0" } }, - "node_modules/tmp": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", - "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, - "node_modules/tmp-promise": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", - "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", - "license": "MIT", - "dependencies": { - "tmp": "^0.2.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -5691,31 +5011,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/ts-blank-space": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/ts-blank-space/-/ts-blank-space-0.4.4.tgz", - "integrity": "sha512-G6GkD6oEJ7j5gG2e5qAizfE4Ap7JXMpnN0CEp9FEt4LExdaqsdwB90aQsaAwcKhiSxVk5KoqFW9xfxTQ4lBUnQ==", - "license": "Apache-2.0", - "dependencies": { - "typescript": "5.1.6 - 5.7.x" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/ts-blank-space/node_modules/typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5728,6 +5023,7 @@ "version": "4.23.15", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -5824,18 +5120,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "license": "MIT" - }, - "node_modules/underscore": { - "version": "1.13.8", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", - "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", - "license": "MIT" - }, "node_modules/undici": { "version": "7.29.1", "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", @@ -5863,15 +5147,6 @@ "pathe": "^2.0.3" } }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -6062,23 +5337,6 @@ } } }, - "node_modules/walk-back": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/walk-back/-/walk-back-5.1.2.tgz", - "integrity": "sha512-uCgzIY1U7fyXvJm+mesY0xjf2HXu7mtTnptONwVQ11ur1JhMrUyQJn2fDje1CGFQDnTFTo1Slr1vRuvUS9PYoQ==", - "license": "MIT", - "engines": { - "node": ">=12.17" - }, - "peerDependencies": { - "@75lb/nature": "latest" - }, - "peerDependenciesMeta": { - "@75lb/nature": { - "optional": true - } - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6151,32 +5409,6 @@ "@cloudflare/workerd-windows-64": "1.20260923.1" } }, - "node_modules/workers-mcp": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/workers-mcp/-/workers-mcp-0.0.13.tgz", - "integrity": "sha512-JoFASGioyaVAI1Rn+a4PJcV66cWthL7wnwZZYl1u3pwUz014Ym76sBPvGLuODWXjOkE7D3z+6CM4nH8pW4tlcA==", - "license": "Apache-2.0", - "dependencies": { - "@clack/prompts": "^0.8.2", - "@modelcontextprotocol/sdk": "^1.0.3", - "@silvia-odwyer/photon-node": "^0.3.3", - "chalk": "^5.3.0", - "fs-extra": "^11.2.0", - "jsdoc-api": "^9.3.4", - "just-filter-object": "^3.2.0", - "just-map-values": "^3.2.0", - "npm-which": "^3.0.1", - "tmp-promise": "^3.0.3", - "ts-blank-space": "^0.4.4", - "tsx": "^4.19.2" - }, - "bin": { - "workers-mcp": "dist/cli.js" - }, - "engines": { - "node": ">=16.17.0" - } - }, "node_modules/wrangler": { "version": "4.139.0", "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.139.0.tgz", @@ -6732,12 +5964,6 @@ } } }, - "node_modules/xmlcreate": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", - "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==", - "license": "Apache-2.0" - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index ce30937a..166c385b 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,6 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "hono": "^4.13.7", - "proj4": "^2.22.0", - "workers-mcp": "^0.0.13" + "proj4": "^2.22.0" } } diff --git a/scripts/relay/guard-runtime.ts b/scripts/relay/guard-runtime.ts index c428f3c3..2ac6b946 100644 --- a/scripts/relay/guard-runtime.ts +++ b/scripts/relay/guard-runtime.ts @@ -29,6 +29,7 @@ export async function runGuard( const crashDir = await realpath(crashPath); await marker.writeFile(JSON.stringify({ guardPid: pid, state: 'launching', crashDir })); await marker.sync(); + let cleanupDeadline = 0; const guard = createGuardController( async () => { const server = await launch(crashDir); @@ -42,7 +43,14 @@ export async function runGuard( return { root, endpoint: server.wsEndpoint(), - close: () => closeOwnedGroup(root, () => server.close(), snapshot), + close: () => + closeOwnedGroup( + root, + () => server.close(), + snapshot, + process.kill, + cleanupDeadline - 500, + ), }; } catch (error) { await server.kill(); @@ -59,7 +67,8 @@ export async function runGuard( const stop = () => { if (stopping) return; stopping = true; - void bounded(guard.stop(), 8000) + cleanupDeadline ||= Date.now() + 8000; + void bounded(guard.stop(), Math.max(0, cleanupDeadline - Date.now())) .then(() => { if (channel.connected) channel.send({ type: 'closed' }); disposeListeners(); @@ -75,6 +84,18 @@ export async function runGuard( channel.once('SIGINT', stop); const onMessage = (message: unknown) => { if (message === 'close') stop(); + else if ( + message && + typeof message === 'object' && + 'type' in message && + message.type === 'close' && + 'deadline' in message && + typeof message.deadline === 'number' && + Number.isFinite(message.deadline) + ) { + if (!stopping) cleanupDeadline = message.deadline; + stop(); + } }; channel.on('message', onMessage); const disposeListeners = () => { diff --git a/scripts/relay/lifecycle.ts b/scripts/relay/lifecycle.ts index c27c9a79..5f4e85b3 100644 --- a/scripts/relay/lifecycle.ts +++ b/scripts/relay/lifecycle.ts @@ -5,7 +5,7 @@ import type { BrowserRunner } from './oliveyoung.js'; export interface BrowserOwner { browser: Pick; pid: number; - close(): Promise; + close(deadline?: number): Promise; rss(): Promise; } export async function bounded(work: Promise, ms: number): Promise { @@ -61,10 +61,13 @@ export function createBrowserLifecycle( context?.removeAllListeners('page'); page = undefined; context = undefined; - cleanup = bounded(previous.close(), 10000).catch((error) => { - failed = error; - throw error; - }); + const deadline = Date.now() + 10000; + cleanup = bounded(previous.close(deadline), Math.max(0, deadline - Date.now())).catch( + (error) => { + failed = error; + throw error; + }, + ); // 이벤트 핸들러에서 발생한 실패도 보존하고 다음 요청/종료에 전달합니다. void cleanup.catch(() => undefined); return cleanup; diff --git a/scripts/relay/ownership.ts b/scripts/relay/ownership.ts index 002b0422..6f467987 100644 --- a/scripts/relay/ownership.ts +++ b/scripts/relay/ownership.ts @@ -57,24 +57,33 @@ export async function closeOwnedGroup( close: () => Promise, snapshot = processSnapshot, kill: (pid: number, signal: NodeJS.Signals) => unknown = process.kill, + deadline = Date.now() + 7000, ): Promise { - const initial = await snapshot(); + const remainingMs = () => Math.max(0, deadline - Date.now()); + const inspect = () => bounded(snapshot(), remainingMs()); + // 프로세스 소멸을 확인한 뒤에만 Playwright의 종료 거절을 허용합니다. + const finishGone = (closing: Promise) => + bounded( + closing.catch(() => undefined), + remainingMs(), + ); + const initial = await inspect(); const group = members(root, initial); if (!group.length) { - await bounded(close(), 3000); + await finishGone(close()); return; } // 루트 프로세스가 이미 종료되었으면 소유권을 새로 추정하지 않습니다. const known = ownedGroup(root, initial); const closing = close(); try { - await bounded(closing, 3000); + await bounded(closing, Math.min(3000, remainingMs())); } catch { /* 아래에서 정체를 확인한 그룹만 강제 회수합니다. */ } - const remaining = members(root, await snapshot()); + const remaining = members(root, await inspect()); if (!remaining.length) { - await bounded(closing, 3000); + await finishGone(closing); return; } const remainingGroup = remaining.filter((row) => row.pgid === root.pgid); @@ -96,11 +105,11 @@ export async function closeOwnedGroup( } } for (let attempt = 0; attempt < 30; attempt++) { - if (!members(root, await snapshot()).length) { - await bounded(closing, 3000); + if (!members(root, await inspect()).length) { + await finishGone(closing); return; } - await new Promise((resolve) => setTimeout(resolve, 100)); + await bounded(new Promise((resolve) => setTimeout(resolve, 100)), remainingMs()); } throw new Error('Browser process group still alive'); } diff --git a/scripts/relay/supervisor.ts b/scripts/relay/supervisor.ts index 07c88e18..cea2e1df 100644 --- a/scripts/relay/supervisor.ts +++ b/scripts/relay/supervisor.ts @@ -16,9 +16,9 @@ export async function launchGuardedBrowser(markerPath: string): Promise { - if (!exited && guard.connected) guard.send('close'); - const code = await bounded(exit, 9000); + const closed = async (deadline = Date.now() + 9000) => { + if (!exited && guard.connected) guard.send({ type: 'close', deadline }); + const code = await bounded(exit, Math.max(0, deadline - Date.now())); if (code !== 0) throw new Error('Browser guard cleanup unconfirmed'); }; try { diff --git a/src/api/cgvHandlers.ts b/src/api/cgvHandlers.ts index f5d4bbf1..f7e7a852 100644 --- a/src/api/cgvHandlers.ts +++ b/src/api/cgvHandlers.ts @@ -1,3 +1,4 @@ +import { hasInvalidCinemaQuery, CINEMA_INVALID_INPUT } from '../services/cgv/validation.js'; import { geocodeBindings } from '../utils/geocode.js'; /** * CGV GET API 핸들러 @@ -12,15 +13,15 @@ import { import { isCgvUpstreamUnavailableError } from '../services/cgv/errors.js'; import { fetchCgvNearbyTheaters, resolveCgvNearestTheater } from '../services/cgv/location.js'; import { filterAndSortTimetable } from '../services/cgv/timetable.js'; -import { type ApiContext, errorResponse, serviceErrorResponse, successResponse } from './response.js'; +import { + type ApiContext, + errorResponse, + serviceErrorResponse, + successResponse, +} from './response.js'; function parseOptionalNumber(value: string | undefined): number | undefined { - if (!value) { - return undefined; - } - - const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) ? parsed : undefined; + return value === undefined ? undefined : Number(value); } /** @@ -28,6 +29,8 @@ function parseOptionalNumber(value: string | undefined): number | undefined { * GET /api/cgv/theaters?playDate={YYYYMMDD}®ionCode={지역코드} */ export async function handleCgvFindTheaters(c: ApiContext) { + if (hasInvalidCinemaQuery((key) => c.req.query(key))) + return errorResponse(c, 'INVALID_INPUT', CINEMA_INVALID_INPUT, 400); const playDate = c.req.query('playDate') || toYyyymmdd(); const regionCode = c.req.query('regionCode') || undefined; const keyword = c.req.query('keyword') || undefined; @@ -95,6 +98,8 @@ export async function handleCgvFindTheaters(c: ApiContext) { * GET /api/cgv/movies?playDate={YYYYMMDD}&theaterCode={극장코드} */ export async function handleCgvSearchMovies(c: ApiContext) { + if (hasInvalidCinemaQuery((key) => c.req.query(key))) + return errorResponse(c, 'INVALID_INPUT', CINEMA_INVALID_INPUT, 400); const playDate = c.req.query('playDate') || toYyyymmdd(); let theaterCode = c.req.query('theaterCode') || undefined; const keyword = c.req.query('keyword') || undefined; @@ -167,6 +172,8 @@ export async function handleCgvSearchMovies(c: ApiContext) { * GET /api/cgv/timetable?playDate={YYYYMMDD}&theaterCode={극장코드}&movieCode={영화코드} */ export async function handleCgvGetTimetable(c: ApiContext) { + if (hasInvalidCinemaQuery((key) => c.req.query(key))) + return errorResponse(c, 'INVALID_INPUT', CINEMA_INVALID_INPUT, 400); const playDate = c.req.query('playDate') || toYyyymmdd(); let theaterCode = c.req.query('theaterCode') || undefined; const movieCode = c.req.query('movieCode') || undefined; @@ -213,11 +220,8 @@ export async function handleCgvGetTimetable(c: ApiContext) { zyteApiKey: c.env?.ZYTE_API_KEY, }); - const exactFiltered = filterAndSortTimetable(timetable, { theaterCode, movieCode, limit }); - const filterRelaxed = Boolean(movieCode && exactFiltered.length === 0 && timetable.length > 0); - const filtered = filterRelaxed - ? filterAndSortTimetable(timetable, { theaterCode, limit }) - : exactFiltered; + const filtered = filterAndSortTimetable(timetable, { theaterCode, movieCode, limit }); + const filterRelaxed = false; return successResponse( c, diff --git a/src/api/compareHandlers.ts b/src/api/compareHandlers.ts index 9b3da803..d9843bdc 100644 --- a/src/api/compareHandlers.ts +++ b/src/api/compareHandlers.ts @@ -1,5 +1,6 @@ import { compareProducts, parseCompareServices } from '../services/compare/client.js'; import { type ApiContext, errorResponse, successResponse } from './response.js'; +import { convenienceTransportFromBindings } from '../utils/convenienceTransport.js'; export async function handleCompareProducts(c: ApiContext) { const keyword = c.req.query('keyword') || c.req.query('q') || ''; @@ -15,7 +16,7 @@ export async function handleCompareProducts(c: ApiContext) { keyword, limit, services, - }); + }, convenienceTransportFromBindings(c.env)); return successResponse(c, result, { total: result.resultCount, diff --git a/src/api/daisoHandlers.ts b/src/api/daisoHandlers.ts index c04532fe..3925c3a4 100644 --- a/src/api/daisoHandlers.ts +++ b/src/api/daisoHandlers.ts @@ -103,7 +103,7 @@ export async function handleCheckInventory(c: ApiContext) { fetchProductById(productId).catch(() => null), ]); - const inStockStores = storeResult.stores.filter((s) => s.quantity > 0); + const inStockStores = storeResult.stores.filter((s) => s.quantity !== null && s.quantity > 0); const product = productDoc ? toProductSummary(productDoc) : undefined; const result = { @@ -114,6 +114,7 @@ export async function handleCheckInventory(c: ApiContext) { storeInventory: { totalStores: storeResult.totalCount, inStockCount: inStockStores.length, + unknownStockCount: storeResult.stores.filter((store) => store.quantity === null).length, stores: storeResult.stores, }, }; diff --git a/src/api/gs25Handlers.ts b/src/api/gs25Handlers.ts index 4b8cef18..301781c4 100644 --- a/src/api/gs25Handlers.ts +++ b/src/api/gs25Handlers.ts @@ -1,3 +1,4 @@ +import { ServiceError } from '../core/errors.js'; import { selectGs25InventoryProduct } from '../services/gs25/productSelection.js'; import { convenienceTransportFromBindings } from '../utils/convenienceTransport.js'; import { geocodeBindings } from '../utils/geocode.js'; @@ -6,7 +7,7 @@ import { geocodeBindings } from '../utils/geocode.js'; */ /* c8 ignore start */ -import { type ApiContext, errorResponse, successResponse } from './response.js'; +import { type ApiContext, errorResponse, serviceErrorResponse, successResponse } from './response.js'; import { attachDistanceToGs25Stores, fetchGs25SearchProducts, @@ -142,6 +143,7 @@ export async function handleGs25FindStores(c: ApiContext) { }, ); } catch (error) { + if (error instanceof ServiceError) return serviceErrorResponse(c, error, 'gs25_request'); const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'GS25_STORE_SEARCH_FAILED', message, 500); } @@ -187,6 +189,7 @@ export async function handleGs25SearchProducts(c: ApiContext) { }, ); } catch (error) { + if (error instanceof ServiceError) return serviceErrorResponse(c, error, 'gs25_request'); const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'GS25_PRODUCT_SEARCH_FAILED', message, 500); } @@ -354,9 +357,10 @@ export async function handleGs25CheckInventory(c: ApiContext) { null; const productPrice = filtered.find((item) => item.searchItemSellPrice !== null)?.searchItemSellPrice ?? null; - const inStockStoreCount = filtered.filter((item) => item.realStockQuantity > 0).length; - const totalStockQuantity = filtered.reduce( - (sum, item) => sum + Math.max(item.realStockQuantity, 0), + const inStockStoreCount = filtered.filter((item) => item.realStockQuantity !== null && item.realStockQuantity > 0).length; + const unknownStockStoreCount = filtered.filter((store) => store.realStockQuantity === null).length; + const totalStockQuantity = unknownStockStoreCount === filtered.length ? null : filtered.reduce( + (sum, item) => sum + Math.max(item.realStockQuantity ?? 0, 0), 0, ); @@ -382,12 +386,14 @@ export async function handleGs25CheckInventory(c: ApiContext) { totalStoreCount: stockResult.totalCount, matchedStoreCount: filtered.length, inStockStoreCount, + unknownStockStoreCount, totalStockQuantity, count: stores.length, stores, }, }); } catch (error) { + if (error instanceof ServiceError) return serviceErrorResponse(c, error, 'gs25_request'); if (isGs25UpstreamUnavailableError(error)) { return errorResponse(c, 'GS25_UPSTREAM_UNAVAILABLE', error.message, 503); } diff --git a/src/api/handlers.ts b/src/api/handlers.ts index b5af8505..817f06e1 100644 --- a/src/api/handlers.ts +++ b/src/api/handlers.ts @@ -13,6 +13,7 @@ import { fetchOliveyoungStores, } from '../services/oliveyoung/client.js'; import { fetchCuStock, fetchCuStores, geocodeCuAddress } from '../services/cu/client.js'; +import { selectCuStockItem, cuStockSelectionReason } from '../services/cu/productSelection.js'; import { ServiceError } from '../core/errors.js'; import { type ApiContext, errorResponse, serviceErrorResponse, successResponse } from './response.js'; export { @@ -211,7 +212,7 @@ export async function handleOliveyoungCheckInventory(c: ApiContext) { timeout: timeoutMs, } ); - const inStockCount = enrichedInventory.products.filter((product) => product.inStock).length; + const inStockCount = enrichedInventory.products.filter((product) => product.stockSource === 'nearby_store' && product.stockStatus === 'in_stock').length; return successResponse( c, @@ -228,7 +229,8 @@ export async function handleOliveyoungCheckInventory(c: ApiContext) { stockCheckedCount: enrichedInventory.checkedCount, stockUncheckedCount: Math.max(0, productResult.products.length - enrichedInventory.checkedCount), inStockCount, - outOfStockCount: enrichedInventory.products.length - inStockCount, + outOfStockCount: enrichedInventory.products.filter((product) => product.stockSource === 'nearby_store' && product.stockStatus === 'out_of_stock').length, + notSoldCount: enrichedInventory.products.filter((product) => product.stockSource === 'nearby_store' && product.stockStatus === 'not_sold').length, products: enrichedInventory.products, }, }, @@ -280,6 +282,7 @@ export async function handleCuFindStores(c: ApiContext) { { total: result.totalCount, pageSize: limit }, ); } catch (error) { + if (error instanceof ServiceError) return serviceErrorResponse(c, error, 'store_search'); const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'CU_STORE_SEARCH_FAILED', message, 500); } @@ -323,7 +326,7 @@ export async function handleCuCheckInventory(c: ApiContext) { }, ); - const firstStockItem = stockResult.items.find((item) => item.itemCode.trim().length > 0) || null; + const firstStockItem = selectCuStockItem(stockResult.items, keyword); const hasInputLocation = typeof lat === 'number' && typeof lng === 'number'; const resolvedLat = hasInputLocation ? lat : undefined; const resolvedLng = hasInputLocation ? lng : undefined; @@ -408,6 +411,7 @@ export async function handleCuCheckInventory(c: ApiContext) { totalCount: storeResult.totalCount, stockItemCode: firstStockItem?.itemCode || null, stockItemName: firstStockItem?.itemName || null, + selectionReason: cuStockSelectionReason(firstStockItem, keyword), stores: storeResult.stores.slice(0, storeLimit), }, inventory: { @@ -421,6 +425,7 @@ export async function handleCuCheckInventory(c: ApiContext) { { total: stockResult.totalCount, page: Math.floor(offset / Math.max(size, 1)) + 1, pageSize: size }, ); } catch (error) { + if (error instanceof ServiceError) return serviceErrorResponse(c, error, 'inventory_check'); const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'CU_INVENTORY_CHECK_FAILED', message, 500); } diff --git a/src/api/lottecinemaHandlers.ts b/src/api/lottecinemaHandlers.ts index d01d9196..83cd0b19 100644 --- a/src/api/lottecinemaHandlers.ts +++ b/src/api/lottecinemaHandlers.ts @@ -1,12 +1,10 @@ +import { hasInvalidCinemaQuery, CINEMA_INVALID_INPUT } from '../services/cgv/validation.js'; import { geocodeBindings } from '../utils/geocode.js'; /** * 롯데시네마 GET API 핸들러 */ -import { - fetchLotteCinemaNowShowing, - toYyyymmdd, -} from '../services/lottecinema/client.js'; +import { fetchLotteCinemaNowShowing, toYyyymmdd } from '../services/lottecinema/client.js'; import { fetchLotteCinemaNearbyTheaters, resolveLotteCinemaNearestTheater, @@ -14,15 +12,12 @@ import { import { type ApiContext, errorResponse, successResponse } from './response.js'; function parseOptionalNumber(value: string | undefined): number | undefined { - if (!value) { - return undefined; - } - - const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) ? parsed : undefined; + return value === undefined ? undefined : Number(value); } export async function handleLotteCinemaFindNearbyTheaters(c: ApiContext) { + if (hasInvalidCinemaQuery((key) => c.req.query(key))) + return errorResponse(c, 'INVALID_INPUT', CINEMA_INVALID_INPUT, 400); const keyword = c.req.query('keyword') || undefined; const latitude = parseOptionalNumber(c.req.query('lat')); const longitude = parseOptionalNumber(c.req.query('lng')); @@ -55,6 +50,8 @@ export async function handleLotteCinemaFindNearbyTheaters(c: ApiContext) { } export async function handleLotteCinemaListNowShowing(c: ApiContext) { + if (hasInvalidCinemaQuery((key) => c.req.query(key))) + return errorResponse(c, 'INVALID_INPUT', CINEMA_INVALID_INPUT, 400); const playDate = c.req.query('playDate') || toYyyymmdd(); let theaterId = c.req.query('theaterId') || undefined; const movieId = c.req.query('movieId') || undefined; @@ -120,6 +117,8 @@ export async function handleLotteCinemaListNowShowing(c: ApiContext) { } export async function handleLotteCinemaGetRemainingSeats(c: ApiContext) { + if (hasInvalidCinemaQuery((key) => c.req.query(key))) + return errorResponse(c, 'INVALID_INPUT', CINEMA_INVALID_INPUT, 400); const playDate = c.req.query('playDate') || toYyyymmdd(); let theaterId = c.req.query('theaterId') || undefined; const movieId = c.req.query('movieId') || undefined; diff --git a/src/api/megaboxHandlers.ts b/src/api/megaboxHandlers.ts index 48acfee9..cade1be9 100644 --- a/src/api/megaboxHandlers.ts +++ b/src/api/megaboxHandlers.ts @@ -1,19 +1,18 @@ +import { hasInvalidCinemaQuery, CINEMA_INVALID_INPUT } from '../services/cgv/validation.js'; import { geocodeBindings } from '../utils/geocode.js'; /** * 메가박스 GET API 핸들러 */ import { fetchMegaboxBookingList, toYyyymmdd } from '../services/megabox/client.js'; -import { fetchMegaboxNearbyTheaters, resolveMegaboxNearestTheater } from '../services/megabox/location.js'; +import { + fetchMegaboxNearbyTheaters, + resolveMegaboxNearestTheater, +} from '../services/megabox/location.js'; import { type ApiContext, errorResponse, successResponse } from './response.js'; function parseOptionalNumber(value: string | undefined): number | undefined { - if (!value) { - return undefined; - } - - const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) ? parsed : undefined; + return value === undefined ? undefined : Number(value); } /** @@ -21,6 +20,8 @@ function parseOptionalNumber(value: string | undefined): number | undefined { * GET /api/megabox/theaters?lat={위도}&lng={경도}&playDate={YYYYMMDD}&areaCode={지역코드} */ export async function handleMegaboxFindNearbyTheaters(c: ApiContext) { + if (hasInvalidCinemaQuery((key) => c.req.query(key))) + return errorResponse(c, 'INVALID_INPUT', CINEMA_INVALID_INPUT, 400); const keyword = c.req.query('keyword') || undefined; const lat = parseOptionalNumber(c.req.query('lat')); const lng = parseOptionalNumber(c.req.query('lng')); @@ -46,11 +47,7 @@ export async function handleMegaboxFindNearbyTheaters(c: ApiContext) { }, ); - return successResponse( - c, - result, - { total: result.count, pageSize: limit } - ); + return successResponse(c, result, { total: result.count, pageSize: limit }); } catch (error) { const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'MEGABOX_THEATER_SEARCH_FAILED', message, 500); @@ -62,6 +59,8 @@ export async function handleMegaboxFindNearbyTheaters(c: ApiContext) { * GET /api/megabox/movies?playDate={YYYYMMDD}&theaterId={지점ID}&movieId={영화ID} */ export async function handleMegaboxListNowShowing(c: ApiContext) { + if (hasInvalidCinemaQuery((key) => c.req.query(key))) + return errorResponse(c, 'INVALID_INPUT', CINEMA_INVALID_INPUT, 400); const playDate = c.req.query('playDate') || toYyyymmdd(); const keyword = c.req.query('keyword') || undefined; const lat = parseOptionalNumber(c.req.query('lat')); @@ -90,7 +89,8 @@ export async function handleMegaboxListNowShowing(c: ApiContext) { timeout: timeoutMs, }, ); - theaterId = resolved.theater?.theaterId; + if (!resolved.theater) throw new Error('요청 위치의 메가박스 극장을 찾을 수 없습니다.'); + theaterId = resolved.theater.theaterId; resolvedTheater = resolved.theater; resolvedLocation = resolved.location; } @@ -121,7 +121,7 @@ export async function handleMegaboxListNowShowing(c: ApiContext) { movies: result.movies, showtimes: result.showtimes, }, - { total: result.showtimes.length } + { total: result.showtimes.length }, ); } catch (error) { const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; @@ -134,6 +134,8 @@ export async function handleMegaboxListNowShowing(c: ApiContext) { * GET /api/megabox/seats?playDate={YYYYMMDD}&theaterId={지점ID}&movieId={영화ID} */ export async function handleMegaboxGetRemainingSeats(c: ApiContext) { + if (hasInvalidCinemaQuery((key) => c.req.query(key))) + return errorResponse(c, 'INVALID_INPUT', CINEMA_INVALID_INPUT, 400); const playDate = c.req.query('playDate') || toYyyymmdd(); const keyword = c.req.query('keyword') || undefined; const lat = parseOptionalNumber(c.req.query('lat')); @@ -163,7 +165,8 @@ export async function handleMegaboxGetRemainingSeats(c: ApiContext) { timeout: timeoutMs, }, ); - theaterId = resolved.theater?.theaterId; + if (!resolved.theater) throw new Error('요청 위치의 메가박스 극장을 찾을 수 없습니다.'); + theaterId = resolved.theater.theaterId; resolvedTheater = resolved.theater; resolvedLocation = resolved.location; } @@ -203,7 +206,7 @@ export async function handleMegaboxGetRemainingSeats(c: ApiContext) { resolvedTheater, seats, }, - { total: seats.length, pageSize: limit } + { total: seats.length, pageSize: limit }, ); } catch (error) { const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; diff --git a/src/api/sevenelevenHandlers.ts b/src/api/sevenelevenHandlers.ts index 73158df0..8f817d6a 100644 --- a/src/api/sevenelevenHandlers.ts +++ b/src/api/sevenelevenHandlers.ts @@ -1,10 +1,11 @@ +import { ServiceError } from '../core/errors.js'; import { convenienceTransportFromBindings } from '../utils/convenienceTransport.js'; /** * 세븐일레븐 GET API 핸들러 */ /* c8 ignore start */ -import { type ApiContext, errorResponse, successResponse } from './response.js'; +import { type ApiContext, errorResponse, serviceErrorResponse, successResponse } from './response.js'; import { fetchSevenElevenStoresByKeyword, fetchSevenElevenCatalogSnapshot, @@ -71,6 +72,7 @@ export async function handleSevenElevenSearchProducts(c: ApiContext) { }, ); } catch (error) { + if (error instanceof ServiceError) return serviceErrorResponse(c, error, 'seveneleven_request'); const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'SEVENELEVEN_PRODUCT_SEARCH_FAILED', message, 500); } @@ -114,6 +116,7 @@ export async function handleSevenElevenSearchStores(c: ApiContext) { }, ); } catch (error) { + if (error instanceof ServiceError) return serviceErrorResponse(c, error, 'seveneleven_request'); const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'SEVENELEVEN_STORE_SEARCH_FAILED', message, 500); } @@ -177,6 +180,7 @@ export async function handleSevenElevenCheckInventory(c: ApiContext) { }, ); } catch (error) { + if (error instanceof ServiceError) return serviceErrorResponse(c, error, 'seveneleven_request'); const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'SEVENELEVEN_INVENTORY_CHECK_FAILED', message, 500); } @@ -206,6 +210,7 @@ export async function handleSevenElevenGetSearchPopwords(c: ApiContext) { : '홈 인기 검색어를 조회했습니다.', }); } catch (error) { + if (error instanceof ServiceError) return serviceErrorResponse(c, error, 'seveneleven_request'); const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'SEVENELEVEN_POPWORD_FETCH_FAILED', message, 500); } @@ -248,6 +253,7 @@ export async function handleSevenElevenGetCatalogSnapshot(c: ApiContext) { }, }); } catch (error) { + if (error instanceof ServiceError) return serviceErrorResponse(c, error, 'seveneleven_request'); const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'SEVENELEVEN_CATALOG_FETCH_FAILED', message, 500); } diff --git a/src/cli/http.ts b/src/cli/http.ts index 38ac3e44..c8000f20 100644 --- a/src/cli/http.ts +++ b/src/cli/http.ts @@ -40,7 +40,7 @@ function buildCliHint(command: string, url: URL, bodyText: string): string[] { ]; } - if (url.pathname === '/api/lottemart/products') { + if (url.pathname === '/api/lottemart/products' && code !== 'SERVICE_RETIRED') { return [ '힌트: 롯데마트 상품 조회는 매장 정보가 필요합니다.', '다음 명령 예시: daiso lottemart-products <상품명> --storeName <매장명>', diff --git a/src/cli/interactiveItemSearch.ts b/src/cli/interactiveItemSearch.ts index c3863a15..a8acaafa 100644 --- a/src/cli/interactiveItemSearch.ts +++ b/src/cli/interactiveItemSearch.ts @@ -207,7 +207,8 @@ export async function runDaisoItemSearch( deps.writeOut(`- 매장: ${store.name}`); if (isRecord(match)) { - const quantity = toText(match.quantity) || '0'; + const quantity = typeof match.quantity === 'number' && Number.isFinite(match.quantity) && match.quantity >= 0 + ? String(match.quantity) : '확인 불가'; deps.writeOut(`- 재고 수량: ${quantity}`); return; } diff --git a/src/cliRenderer.ts b/src/cliRenderer.ts index 5d22fcb6..42501f30 100644 --- a/src/cliRenderer.ts +++ b/src/cliRenderer.ts @@ -25,6 +25,12 @@ function toText(value: unknown): string { return JSON.stringify(value); } +/** 미확인 재고를 실제 0개와 구분합니다. */ +function inventoryText(value: unknown): string { + return value === null || value === undefined || (typeof value === 'number' && (!Number.isFinite(value) || value < 0)) + ? '확인 불가' : toText(value); +} + function formatMeta(meta: unknown): string[] { if (!isRecord(meta)) { return []; @@ -111,12 +117,12 @@ function formatCollection(title: string, items: unknown): string[] { entry.distance !== undefined ? `${toText(entry.distance)}km` : undefined, entry.distanceKm !== undefined ? `${toText(entry.distanceKm)}km` : undefined, entry.distanceM !== undefined ? `${toText(entry.distanceM)}m` : undefined, - entry.quantity !== undefined ? `수량 ${toText(entry.quantity)}` : undefined, - entry.stock !== undefined ? `수량 ${toText(entry.stock)}` : undefined, - entry.realStockQuantity !== undefined ? `수량 ${toText(entry.realStockQuantity)}` : undefined, - entry.bizQty !== undefined ? `수량 ${toText(entry.bizQty)}` : undefined, - entry.remainQuantity !== undefined ? `수량 ${toText(entry.remainQuantity)}` : undefined, - entry.o2oRemainQuantity !== undefined ? `수량 ${toText(entry.o2oRemainQuantity)}` : undefined, + entry.quantity !== undefined ? `수량 ${inventoryText(entry.quantity)}` : undefined, + entry.stock !== undefined ? `수량 ${inventoryText(entry.stock)}` : undefined, + entry.realStockQuantity !== undefined ? `수량 ${inventoryText(entry.realStockQuantity)}` : undefined, + entry.bizQty !== undefined ? `수량 ${inventoryText(entry.bizQty)}` : undefined, + entry.remainQuantity !== undefined ? `수량 ${inventoryText(entry.remainQuantity)}` : undefined, + entry.o2oRemainQuantity !== undefined ? `수량 ${inventoryText(entry.o2oRemainQuantity)}` : undefined, entry.stockLabel, ].filter((value): value is string | number | boolean => value !== undefined); if (detailParts.length > 0) { @@ -203,7 +209,7 @@ function formatInventory(data: Record): string[] { const onlineStock = data.onlineStock; if (onlineStock !== undefined) { - lines.push(`온라인 재고: ${toText(onlineStock)}`); + lines.push(`온라인 재고: ${inventoryText(onlineStock)}`); } const storeInventory = data.storeInventory; diff --git a/src/core/outputSchema.ts b/src/core/outputSchema.ts index 17cf5ae9..9bf07530 100644 --- a/src/core/outputSchema.ts +++ b/src/core/outputSchema.ts @@ -74,7 +74,7 @@ function createFallbackOutputSchema(toolName: string): ToolOutputSchema { itemCode: z.string().nullable().optional(), location: z.unknown().optional(), product: z.unknown().optional(), - onlineStock: z.number().optional(), + onlineStock: z.number().nullable().optional(), inventory: z.unknown().optional(), storeInventory: z.unknown().optional(), }; @@ -100,7 +100,7 @@ function createFallbackOutputSchema(toolName: string): ToolOutputSchema { if (toolName.includes('theater')) { return { - keyword: z.string().optional(), + keyword: z.string().nullable().optional(), location: z.unknown().optional(), count: z.number().optional(), theaters: z.array(z.unknown()).optional(), @@ -137,10 +137,11 @@ function createFallbackOutputSchema(toolName: string): ToolOutputSchema { } if (toolName.includes('catalog')) { + const section = z.object({ totalCount: z.number(), items: z.array(z.unknown()) }); return { - pages: z.array(z.unknown()).optional(), - issues: z.array(z.unknown()).optional(), - exhibitions: z.array(z.unknown()).optional(), + pages: section.optional(), + issues: section.optional(), + exhibitions: section.optional(), count: z.number().optional(), }; } diff --git a/src/index.ts b/src/index.ts index a333c4e4..95b78d24 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,7 +77,7 @@ const createRegistry = (bindings?: AppBindings) => { apiKey: bindings?.GS25_API_KEY, }), () => createSevenElevenService({ ...convenienceTransportFromBindings(bindings), zyteApiKey: bindings?.ZYTE_API_KEY }), - createCompareService, + () => createCompareService(convenienceTransportFromBindings(bindings)), () => createFeedbackService({ supabaseUrl: bindings?.SUPABASE_URL, diff --git a/src/pages/openapiSpecComponentsCgv.ts b/src/pages/openapiSpecComponentsCgv.ts index 66ff14e7..8996ae7f 100644 --- a/src/pages/openapiSpecComponentsCgv.ts +++ b/src/pages/openapiSpecComponentsCgv.ts @@ -34,8 +34,8 @@ export const OPENAPI_CGV_COMPONENT_SCHEMAS = { playDate: { type: 'string', example: '20260304' }, startTime: { type: 'string', example: '09:30' }, endTime: { type: 'string', example: '11:20' }, - totalSeats: { type: 'integer', example: 150 }, - remainingSeats: { type: 'integer', example: 42 }, + totalSeats: { type: 'integer', nullable: true, example: 150 }, + remainingSeats: { type: 'integer', nullable: true, example: 42 }, }, }, CgvTheaterSearchResponse: { diff --git a/src/pages/openapiSpecComponentsCu.ts b/src/pages/openapiSpecComponentsCu.ts index d475978e..12e0ad33 100644 --- a/src/pages/openapiSpecComponentsCu.ts +++ b/src/pages/openapiSpecComponentsCu.ts @@ -14,7 +14,7 @@ export const OPENAPI_CU_COMPONENT_SCHEMAS = { latitude: { type: 'number', format: 'float', example: 37.318482 }, longitude: { type: 'number', format: 'float', example: 126.841838 }, distanceM: { type: 'number', example: 97 }, - stock: { type: 'integer', example: 10 }, + stock: { type: 'integer', nullable: true, example: 10 }, deliveryYn: { type: 'boolean', example: true }, pickupYn: { type: 'boolean', example: false }, reserveYn: { type: 'boolean', example: false }, diff --git a/src/pages/openapiSpecComponentsDaisoOliveyoung.ts b/src/pages/openapiSpecComponentsDaisoOliveyoung.ts index 5d0a33b1..a5366425 100644 --- a/src/pages/openapiSpecComponentsDaisoOliveyoung.ts +++ b/src/pages/openapiSpecComponentsDaisoOliveyoung.ts @@ -80,7 +80,7 @@ export const OPENAPI_DAISO_OLIVEYOUNG_COMPONENT_SCHEMAS = { storeName: { type: 'string', description: '매장명', example: '다이소 강남역점' }, address: { type: 'string', description: '주소' }, distance: { type: 'string', description: '거리', example: '0.5km' }, - quantity: { type: 'integer', description: '재고 수량', example: 12 }, + quantity: { type: 'integer', nullable: true, description: '재고 수량', example: 12 }, options: { type: 'object', properties: { @@ -149,12 +149,13 @@ export const OPENAPI_DAISO_OLIVEYOUNG_COMPONENT_SCHEMAS = { longitude: { type: 'number', format: 'float' }, }, }, - onlineStock: { type: 'integer', description: '온라인 재고 수량', example: 150 }, + onlineStock: { type: 'integer', nullable: true, description: '온라인 재고 수량', example: 150 }, storeInventory: { type: 'object', properties: { totalStores: { type: 'integer', description: '전체 매장 수' }, inStockCount: { type: 'integer', description: '재고 있는 매장 수' }, + unknownStockCount: { type: 'integer', description: '수량을 확인하지 못한 매장 수' }, stores: { type: 'array', items: { $ref: '#/components/schemas/StoreInventory' }, @@ -202,7 +203,7 @@ export const OPENAPI_DAISO_OLIVEYOUNG_COMPONENT_SCHEMAS = { o2oStockFlag: { type: 'boolean', description: '올리브영 원본 재고 가능 플래그', example: true }, o2oRemainQuantity: { type: 'integer', description: '올리브영 원본 잔여 수량 필드. 0이어도 재고가 있을 수 있음', example: 0 }, inStock: { type: 'boolean', description: '현재 조회한 주변 매장 기준 재고 여부. storeInventory가 있으면 그 결과를 우선 반영', example: true }, - stockStatus: { type: 'string', enum: ['in_stock', 'out_of_stock'], example: 'in_stock' }, + stockStatus: { type: 'string', enum: ['in_stock', 'out_of_stock', 'not_sold', 'unknown'], example: 'in_stock' }, stockSource: { type: 'string', enum: ['global_search', 'nearby_store'], example: 'nearby_store' }, storeInventory: { $ref: '#/components/schemas/OliveyoungProductStoreInventory' }, }, @@ -322,6 +323,7 @@ export const OPENAPI_DAISO_OLIVEYOUNG_COMPONENT_SCHEMAS = { stockUncheckedCount: { type: 'integer' }, inStockCount: { type: 'integer' }, outOfStockCount: { type: 'integer' }, + notSoldCount: { type: 'integer' }, products: { type: 'array', items: { $ref: '#/components/schemas/OliveyoungProduct' }, diff --git a/src/pages/openapiSpecComponentsEmart24.ts b/src/pages/openapiSpecComponentsEmart24.ts index 1a8d242a..bc1ff6ed 100644 --- a/src/pages/openapiSpecComponentsEmart24.ts +++ b/src/pages/openapiSpecComponentsEmart24.ts @@ -127,7 +127,7 @@ export const OPENAPI_EMART24_COMPONENT_SCHEMAS = { type: 'object', properties: { bizNo: { type: 'string', example: '28339' }, - bizQty: { type: 'integer', example: 3 }, + bizQty: { type: 'integer', nullable: true, example: 3 }, storeName: { type: 'string', example: '강남스퀘어점' }, address: { type: 'string' }, phone: { type: 'string' }, diff --git a/src/pages/openapiSpecComponentsGs25.ts b/src/pages/openapiSpecComponentsGs25.ts index 81b2f5fb..dd7ed448 100644 --- a/src/pages/openapiSpecComponentsGs25.ts +++ b/src/pages/openapiSpecComponentsGs25.ts @@ -12,7 +12,7 @@ export const OPENAPI_GS25_COMPONENT_SCHEMAS = { phone: { type: 'string', example: '02-1234-5678' }, latitude: { type: 'number', format: 'float', example: 37.4982 }, longitude: { type: 'number', format: 'float', example: 127.0276 }, - realStockQuantity: { type: 'integer', example: 3 }, + realStockQuantity: { type: 'integer', nullable: true, example: 3 }, distanceM: { type: 'number', nullable: true, example: 120 }, propertyNames: { type: 'array', @@ -28,7 +28,8 @@ export const OPENAPI_GS25_COMPONENT_SCHEMAS = { sellPrice: { type: 'integer', nullable: true, example: 1700 }, matchedStoreCount: { type: 'integer', example: 120 }, inStockStoreCount: { type: 'integer', example: 42 }, - totalStockQuantity: { type: 'integer', example: 110 }, + unknownStockStoreCount: { type: 'integer', example: 0 }, + totalStockQuantity: { type: 'integer', nullable: true, example: 110 }, }, }, Gs25StoreSearchResponse: { @@ -96,7 +97,8 @@ export const OPENAPI_GS25_COMPONENT_SCHEMAS = { totalStoreCount: { type: 'integer', example: 17684 }, matchedStoreCount: { type: 'integer', example: 100 }, inStockStoreCount: { type: 'integer', example: 35 }, - totalStockQuantity: { type: 'integer', example: 90 }, + unknownStockStoreCount: { type: 'integer', example: 0 }, + totalStockQuantity: { type: 'integer', nullable: true, example: 90 }, stores: { type: 'array', items: { $ref: '#/components/schemas/Gs25Store' }, diff --git a/src/pages/openapiSpecComponentsLottecinema.ts b/src/pages/openapiSpecComponentsLottecinema.ts index 57afd627..3a35a61e 100644 --- a/src/pages/openapiSpecComponentsLottecinema.ts +++ b/src/pages/openapiSpecComponentsLottecinema.ts @@ -49,9 +49,9 @@ export const OPENAPI_LOTTECINEMA_COMPONENT_SCHEMAS = { playDate: { type: 'string', example: '20260310' }, startTime: { type: 'string', example: '10:40' }, endTime: { type: 'string', example: '12:47' }, - totalSeats: { type: 'integer', example: 32 }, - bookedSeats: { type: 'integer', example: 4 }, - remainingSeats: { type: 'integer', example: 28 }, + totalSeats: { type: 'integer', nullable: true, example: 32 }, + bookedSeats: { type: 'integer', nullable: true, example: 4 }, + remainingSeats: { type: 'integer', nullable: true, example: 28 }, }, }, LotteCinemaTheaterSearchResponse: { diff --git a/src/pages/openapiSpecComponentsMegabox.ts b/src/pages/openapiSpecComponentsMegabox.ts index e80444fa..249f7018 100644 --- a/src/pages/openapiSpecComponentsMegabox.ts +++ b/src/pages/openapiSpecComponentsMegabox.ts @@ -25,8 +25,8 @@ export const OPENAPI_MEGABOX_COMPONENT_SCHEMAS = { playDate: { type: 'string', example: '20260304' }, startTime: { type: 'string', example: '09:30' }, endTime: { type: 'string', example: '11:20' }, - totalSeats: { type: 'integer', example: 120 }, - remainingSeats: { type: 'integer', example: 42 }, + totalSeats: { type: 'integer', nullable: true, example: 120 }, + remainingSeats: { type: 'integer', nullable: true, example: 42 }, }, }, MegaboxTheaterSearchResponse: { diff --git a/src/pages/openapiSpecPathsDtryx.ts b/src/pages/openapiSpecPathsDtryx.ts index 33a261aa..9bf1ec07 100644 --- a/src/pages/openapiSpecPathsDtryx.ts +++ b/src/pages/openapiSpecPathsDtryx.ts @@ -31,9 +31,9 @@ const showtime = { endTime: text, runningMinutes: integer, rating: text, - totalSeats: integer, - remainingSeats: integer, - bookedSeats: integer, + totalSeats: { ...integer, nullable: true }, + remainingSeats: { ...integer, nullable: true }, + bookedSeats: { ...integer, nullable: true }, planStatus: text, }, }; diff --git a/src/services/cgv/client.ts b/src/services/cgv/client.ts index 74255771..9f55602e 100644 --- a/src/services/cgv/client.ts +++ b/src/services/cgv/client.ts @@ -2,7 +2,7 @@ * CGV API 클라이언트 */ -import { formatTime, toNumber, toYyyymmdd } from '../../utils/format.js'; +import { formatTime, toSeatCount, toYyyymmdd } from '../../utils/format.js'; import { CGV_API } from './api.js'; import { requestCgv } from './transport.js'; import type { @@ -30,7 +30,11 @@ function asArray(value: T[] | null | undefined): T[] { return Array.isArray(value) ? value : []; } -async function resolveTheaterCode(playDate: string, theaterCode: string | undefined, params: CommonFetchParams) { +async function resolveTheaterCode( + playDate: string, + theaterCode: string | undefined, + params: CommonFetchParams, +) { if (theaterCode) { return theaterCode; } @@ -104,8 +108,8 @@ async function fetchTimetableByMovieCode( playDate: item.scnYmd as string, startTime: formatTime(item.scnsrtTm), endTime: formatTime(item.scnendTm), - totalSeats: toNumber(item.stcnt), - remainingSeats: toNumber(item.frSeatCnt || item.frtmpSeatCnt), + totalSeats: toSeatCount(item.stcnt), + remainingSeats: toSeatCount(item.frSeatCnt ?? item.frtmpSeatCnt), })); } @@ -139,8 +143,8 @@ async function fetchTimetableBySite( playDate: item.scnYmd as string, startTime: formatTime(item.scnsrtTm), endTime: formatTime(item.scnendTm), - totalSeats: toNumber(item.stcnt), - remainingSeats: toNumber(item.frSeatCnt || item.frtmpSeatCnt), + totalSeats: toSeatCount(item.stcnt), + remainingSeats: toSeatCount(item.frSeatCnt ?? item.frtmpSeatCnt), })); } @@ -183,10 +187,10 @@ function pickFallbackTheaterCodes(theaters: CgvTheater[]): string[] { .map((theater) => theater.theaterCode) .filter((code, index, array) => code && array.indexOf(code) === index); - return [DEFAULT_THEATER_CODE, ...uniqueCodes.filter((code) => code !== DEFAULT_THEATER_CODE)].slice( - 0, - MAX_FALLBACK_THEATERS, - ); + return [ + DEFAULT_THEATER_CODE, + ...uniqueCodes.filter((code) => code !== DEFAULT_THEATER_CODE), + ].slice(0, MAX_FALLBACK_THEATERS); } export async function fetchCgvTimetable(params: CommonFetchParams): Promise { @@ -200,8 +204,13 @@ export async function fetchCgvTimetable(params: CommonFetchParams): Promise 0) { return filteredByMovie; } - const timetableByMovieCode = await fetchTimetableByMovieCode(playDate, theaterCode, params.movieCode, params); - return timetableByMovieCode.length > 0 ? timetableByMovieCode : timetableBySite; + const timetableByMovieCode = await fetchTimetableByMovieCode( + playDate, + theaterCode, + params.movieCode, + params, + ); + return timetableByMovieCode.filter((item) => item.movieCode === params.movieCode); } return timetableBySite; } @@ -226,7 +235,12 @@ export async function fetchCgvTimetable(params: CommonFetchParams): Promise 0) { timetableByTheater.push(...timetable); } diff --git a/src/services/cgv/tools/findTheaters.ts b/src/services/cgv/tools/findTheaters.ts index 4864be0a..2bf219f7 100644 --- a/src/services/cgv/tools/findTheaters.ts +++ b/src/services/cgv/tools/findTheaters.ts @@ -1,3 +1,10 @@ +import { + validateCinemaOptions, + cinemaDateSchema, + cinemaLimitSchema, + cinemaLatitudeSchema, + cinemaLongitudeSchema, +} from '../validation.js'; import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * CGV 극장 검색 도구 @@ -84,24 +91,33 @@ async function findTheaters( }; } -export function createFindTheatersTool(apiKey?: string, googleMapsApiKey?: string, geocodeOptions: GeocodeOptions = {}): ToolRegistration { +export function createFindTheatersTool( + apiKey?: string, + googleMapsApiKey?: string, + geocodeOptions: GeocodeOptions = {}, +): ToolRegistration { return { name: 'cgv_find_theaters', metadata: { title: 'CGV 극장 검색', description: '지역 코드 또는 위치 키워드 기준으로 CGV 극장 목록을 조회합니다.', inputSchema: { - playDate: z.string().optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), + playDate: cinemaDateSchema.optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), regionCode: z.string().optional().describe('지역 코드 (예: 01 서울)'), keyword: z.string().optional().describe('위치 키워드 (예: 안산 중앙역, 강남역)'), - latitude: z.number().optional().describe('위도'), - longitude: z.number().optional().describe('경도'), - limit: z.number().optional().default(30).describe('최대 결과 수 (기본값: 30)'), - timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), + latitude: cinemaLatitudeSchema.optional().describe('위도'), + longitude: cinemaLongitudeSchema.optional().describe('경도'), + limit: cinemaLimitSchema.optional().default(30).describe('최대 결과 수 (기본값: 30)'), + timeoutMs: z + .number() + .optional() + .default(15000) + .describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: ((args) => findTheaters(args as FindTheatersArgs, apiKey, googleMapsApiKey, geocodeOptions)) as ( - args: unknown, - ) => Promise, + handler: (async (args) => ( + validateCinemaOptions(args), + findTheaters(args as FindTheatersArgs, apiKey, googleMapsApiKey, geocodeOptions) + )) as (args: unknown) => Promise, }; } diff --git a/src/services/cgv/tools/getTimetable.ts b/src/services/cgv/tools/getTimetable.ts index 7b03dc58..d6e31c93 100644 --- a/src/services/cgv/tools/getTimetable.ts +++ b/src/services/cgv/tools/getTimetable.ts @@ -1,3 +1,10 @@ +import { + validateCinemaOptions, + cinemaDateSchema, + cinemaLimitSchema, + cinemaLatitudeSchema, + cinemaLongitudeSchema, +} from '../validation.js'; import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * CGV 시간표 조회 도구 @@ -39,7 +46,10 @@ async function getTimetable( let resolvedTheater = null; let targetTheaterCode = theaterCode; - if (!targetTheaterCode && (keyword || typeof latitude === 'number' || typeof longitude === 'number')) { + if ( + !targetTheaterCode && + (keyword || typeof latitude === 'number' || typeof longitude === 'number') + ) { const resolved = await resolveCgvNearestTheater( { playDate, @@ -60,7 +70,8 @@ async function getTimetable( } const shouldReturnEmpty = - !targetTheaterCode && (keyword || typeof latitude === 'number' || typeof longitude === 'number'); + !targetTheaterCode && + (keyword || typeof latitude === 'number' || typeof longitude === 'number'); const timetable = shouldReturnEmpty ? [] : await fetchCgvTimetable({ @@ -71,7 +82,11 @@ async function getTimetable( zyteApiKey: apiKey, }); - const filtered = filterAndSortTimetable(timetable, { theaterCode: targetTheaterCode, movieCode, limit }); + const filtered = filterAndSortTimetable(timetable, { + theaterCode: targetTheaterCode, + movieCode, + limit, + }); const result = { playDate, @@ -93,25 +108,35 @@ async function getTimetable( }; } -export function createGetTimetableTool(apiKey?: string, googleMapsApiKey?: string, geocodeOptions: GeocodeOptions = {}): ToolRegistration { +export function createGetTimetableTool( + apiKey?: string, + googleMapsApiKey?: string, + geocodeOptions: GeocodeOptions = {}, +): ToolRegistration { return { name: 'cgv_get_timetable', metadata: { title: 'CGV 시간표 조회', - description: '날짜/극장/영화 조건으로 CGV 상영 시간표를 조회합니다. 극장 코드가 없으면 위치 키워드 기준 최근접 극장을 선택할 수 있습니다.', + description: + '날짜/극장/영화 조건으로 CGV 상영 시간표를 조회합니다. 극장 코드가 없으면 위치 키워드 기준 최근접 극장을 선택할 수 있습니다.', inputSchema: { - playDate: z.string().optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), + playDate: cinemaDateSchema.optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), theaterCode: z.string().optional().describe('CGV 극장 코드 (예: 0056)'), movieCode: z.string().optional().describe('CGV 영화 코드'), keyword: z.string().optional().describe('위치 키워드 (예: 안산 중앙역, 강남역)'), - latitude: z.number().optional().describe('위도'), - longitude: z.number().optional().describe('경도'), - limit: z.number().optional().default(50).describe('최대 결과 수 (기본값: 50)'), - timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), + latitude: cinemaLatitudeSchema.optional().describe('위도'), + longitude: cinemaLongitudeSchema.optional().describe('경도'), + limit: cinemaLimitSchema.optional().default(50).describe('최대 결과 수 (기본값: 50)'), + timeoutMs: z + .number() + .optional() + .default(15000) + .describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: ((args) => getTimetable(args as GetTimetableArgs, apiKey, googleMapsApiKey, geocodeOptions)) as ( - args: unknown, - ) => Promise, + handler: (async (args) => ( + validateCinemaOptions(args), + getTimetable(args as GetTimetableArgs, apiKey, googleMapsApiKey, geocodeOptions) + )) as (args: unknown) => Promise, }; } diff --git a/src/services/cgv/tools/searchMovies.ts b/src/services/cgv/tools/searchMovies.ts index ce7e6ba3..a24058ed 100644 --- a/src/services/cgv/tools/searchMovies.ts +++ b/src/services/cgv/tools/searchMovies.ts @@ -1,3 +1,9 @@ +import { + validateCinemaOptions, + cinemaDateSchema, + cinemaLatitudeSchema, + cinemaLongitudeSchema, +} from '../validation.js'; import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * CGV 영화 검색 도구 @@ -23,11 +29,21 @@ async function searchMovies( googleMapsApiKey?: string, geocodeOptions: GeocodeOptions = {}, ): Promise { - const { playDate = toYyyymmdd(), theaterCode, keyword, latitude, longitude, timeoutMs = 15000 } = args; + const { + playDate = toYyyymmdd(), + theaterCode, + keyword, + latitude, + longitude, + timeoutMs = 15000, + } = args; let resolvedTheater = null; let targetTheaterCode = theaterCode; - if (!targetTheaterCode && (keyword || typeof latitude === 'number' || typeof longitude === 'number')) { + if ( + !targetTheaterCode && + (keyword || typeof latitude === 'number' || typeof longitude === 'number') + ) { const resolved = await resolveCgvNearestTheater( { playDate, @@ -48,7 +64,8 @@ async function searchMovies( } const shouldReturnEmpty = - !targetTheaterCode && (keyword || typeof latitude === 'number' || typeof longitude === 'number'); + !targetTheaterCode && + (keyword || typeof latitude === 'number' || typeof longitude === 'number'); const movies = shouldReturnEmpty ? [] : await fetchCgvMovies({ @@ -76,23 +93,33 @@ async function searchMovies( }; } -export function createSearchMoviesTool(apiKey?: string, googleMapsApiKey?: string, geocodeOptions: GeocodeOptions = {}): ToolRegistration { +export function createSearchMoviesTool( + apiKey?: string, + googleMapsApiKey?: string, + geocodeOptions: GeocodeOptions = {}, +): ToolRegistration { return { name: 'cgv_search_movies', metadata: { title: 'CGV 영화 검색', - description: 'CGV 상영 영화 목록을 조회합니다. 극장 코드가 없으면 위치 키워드 기준 최근접 극장을 선택할 수 있습니다.', + description: + 'CGV 상영 영화 목록을 조회합니다. 극장 코드가 없으면 위치 키워드 기준 최근접 극장을 선택할 수 있습니다.', inputSchema: { - playDate: z.string().optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), + playDate: cinemaDateSchema.optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), theaterCode: z.string().optional().describe('CGV 극장 코드 (예: 0056)'), keyword: z.string().optional().describe('위치 키워드 (예: 안산 중앙역, 강남역)'), - latitude: z.number().optional().describe('위도'), - longitude: z.number().optional().describe('경도'), - timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), + latitude: cinemaLatitudeSchema.optional().describe('위도'), + longitude: cinemaLongitudeSchema.optional().describe('경도'), + timeoutMs: z + .number() + .optional() + .default(15000) + .describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: ((args) => searchMovies(args as SearchMoviesArgs, apiKey, googleMapsApiKey, geocodeOptions)) as ( - args: unknown, - ) => Promise, + handler: (async (args) => ( + validateCinemaOptions(args), + searchMovies(args as SearchMoviesArgs, apiKey, googleMapsApiKey, geocodeOptions) + )) as (args: unknown) => Promise, }; } diff --git a/src/services/cgv/types.ts b/src/services/cgv/types.ts index c708b0f5..9b7f2762 100644 --- a/src/services/cgv/types.ts +++ b/src/services/cgv/types.ts @@ -27,8 +27,8 @@ export interface CgvTimetable { playDate: string; startTime: string; endTime: string; - totalSeats: number; - remainingSeats: number; + totalSeats: number | null; + remainingSeats: number | null; } interface CgvTheaterItem { diff --git a/src/services/cgv/validation.ts b/src/services/cgv/validation.ts new file mode 100644 index 00000000..c69217f9 --- /dev/null +++ b/src/services/cgv/validation.ts @@ -0,0 +1,39 @@ +/** 영화관 REST와 MCP 공통 입력 검증 */ +import * as z from 'zod'; + +export const cinemaDateSchema = z.string().refine((value) => { + if (!/^\d{8}$/.test(value)) return false; + const dashed = `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`; + const date = new Date(`${dashed}T00:00:00Z`); + return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === dashed; +}); +export const cinemaLimitSchema = z.number().int().positive(); +export const cinemaLatitudeSchema = z.number().min(-90).max(90); +export const cinemaLongitudeSchema = z.number().min(-180).max(180); +const optionsSchema = z + .object({ + playDate: cinemaDateSchema.optional(), + limit: cinemaLimitSchema.optional(), + latitude: cinemaLatitudeSchema.optional(), + longitude: cinemaLongitudeSchema.optional(), + }) + .refine((value) => (value.latitude === undefined) === (value.longitude === undefined)); +export const CINEMA_INVALID_INPUT = + '입력 오류: 날짜는 유효한 YYYYMMDD, limit은 양의 정수, 좌표는 유효한 위도·경도 쌍이어야 합니다.'; + +export function validateCinemaOptions(options: unknown): void { + if (!optionsSchema.safeParse(options).success) throw new Error(CINEMA_INVALID_INPUT); +} + +export function hasInvalidCinemaQuery(query: (key: string) => string | undefined): boolean { + const number = (key: string) => { + const value = query(key); + return value === undefined ? undefined : value.trim() ? Number(value) : NaN; + }; + return !optionsSchema.safeParse({ + playDate: query('playDate'), + limit: number('limit'), + latitude: number('lat'), + longitude: number('lng'), + }).success; +} diff --git a/src/services/compare/client.ts b/src/services/compare/client.ts index 24ffde4d..a62dc522 100644 --- a/src/services/compare/client.ts +++ b/src/services/compare/client.ts @@ -3,6 +3,7 @@ import { fetchGs25SearchProducts } from '../gs25/client.js'; import { searchSevenElevenProducts } from '../seveneleven/client.js'; import { searchEmart24Products } from '../emart24/client.js'; import type { ComparableProduct, CompareProductsResult, CompareServiceId } from './types.js'; +import type { ConvenienceTransportOptions } from '../../utils/convenienceTransport.js'; const DEFAULT_COMPARE_SERVICES: CompareServiceId[] = ['daiso', 'gs25', 'seveneleven', 'emart24']; @@ -62,10 +63,11 @@ async function searchService( service: CompareServiceId, keyword: string, limit: number, + options: ConvenienceTransportOptions, ): Promise { if (service === 'daiso') { const result = await fetchDaisoProducts(keyword, 1, limit); - return result.products.map((product) => ({ + return result.products.slice(0, limit).map((product) => ({ service, serviceName: SERVICE_NAMES[service], code: product.id, @@ -77,7 +79,7 @@ async function searchService( } if (service === 'gs25') { - const products = await fetchGs25SearchProducts(keyword, { timeout: 15000 }); + const products = await fetchGs25SearchProducts(keyword, { ...options, timeout: 15000 }); return products.slice(0, limit).map((product) => ({ service, serviceName: SERVICE_NAMES[service], @@ -91,8 +93,8 @@ async function searchService( } if (service === 'seveneleven') { - const result = await searchSevenElevenProducts({ query: keyword, page: 1, size: limit }); - return result.products.map((product) => ({ + const result = await searchSevenElevenProducts({ query: keyword, page: 1, size: limit }, options); + return result.products.slice(0, limit).map((product) => ({ service, serviceName: SERVICE_NAMES[service], code: product.itemCode || product.productNo, @@ -111,7 +113,7 @@ async function searchService( sortType: 'PRICE_ASC', saleProductYn: 'N', }); - return result.products.map((product) => ({ + return result.products.slice(0, limit).map((product) => ({ service, serviceName: SERVICE_NAMES[service], code: product.pluCd, @@ -128,6 +130,7 @@ export function parseCompareServices(value: string | undefined): CompareServiceI export async function compareProducts( params: CompareProductsParams, + options: ConvenienceTransportOptions = {}, ): Promise { const keyword = params.keyword.trim(); const limit = Math.max(1, Math.min(Math.trunc(params.limit || 5), 20)); @@ -137,7 +140,7 @@ export async function compareProducts( const settled = await Promise.allSettled( services.map(async (service) => ({ service, - products: await searchService(service, keyword, limit), + products: await searchService(service, keyword, limit, options), })), ); diff --git a/src/services/compare/index.ts b/src/services/compare/index.ts index 520297a1..3b023257 100644 --- a/src/services/compare/index.ts +++ b/src/services/compare/index.ts @@ -1,6 +1,7 @@ import type { ServiceProvider } from '../../core/interfaces.js'; import type { ServiceMetadata, ToolRegistration } from '../../core/types.js'; import { createCompareProductsTool } from './tools/compareProducts.js'; +import type { ConvenienceTransportOptions } from '../../utils/convenienceTransport.js'; const COMPARE_METADATA: ServiceMetadata = { id: 'compare', @@ -11,14 +12,15 @@ const COMPARE_METADATA: ServiceMetadata = { class CompareService implements ServiceProvider { readonly metadata = COMPARE_METADATA; + constructor(private readonly options: ConvenienceTransportOptions) {} getTools(): ToolRegistration[] { - return [createCompareProductsTool()]; + return [createCompareProductsTool(this.options)]; } } -export function createCompareService(): ServiceProvider { - return new CompareService(); +export function createCompareService(options: ConvenienceTransportOptions = {}): ServiceProvider { + return new CompareService(options); } export * from './types.js'; diff --git a/src/services/compare/tools/compareProducts.ts b/src/services/compare/tools/compareProducts.ts index 8b3ad613..7b663238 100644 --- a/src/services/compare/tools/compareProducts.ts +++ b/src/services/compare/tools/compareProducts.ts @@ -1,6 +1,7 @@ import * as z from 'zod'; import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; import { compareProducts, parseCompareServices } from '../client.js'; +import type { ConvenienceTransportOptions } from '../../../utils/convenienceTransport.js'; interface CompareProductsArgs { keyword: string; @@ -8,7 +9,7 @@ interface CompareProductsArgs { services?: string; } -async function compareProductsHandler(args: CompareProductsArgs): Promise { +async function compareProductsHandler(args: CompareProductsArgs, options: ConvenienceTransportOptions): Promise { const keyword = args.keyword?.trim(); if (!keyword) { throw new Error('검색어(keyword)를 입력해주세요.'); @@ -18,7 +19,7 @@ async function compareProductsHandler(args: CompareProductsArgs): Promise Promise, + handler: (args: unknown) => compareProductsHandler(args as CompareProductsArgs, options), }; } diff --git a/src/services/cu/client.ts b/src/services/cu/client.ts index 12513cc6..fc886b7f 100644 --- a/src/services/cu/client.ts +++ b/src/services/cu/client.ts @@ -8,6 +8,7 @@ import { fetchJson } from '../../utils/http.js'; import { fetchJsonWithZyteFallback } from '../../utils/zyteJsonFallback.js'; import { CU_API } from './api.js'; import { cuStockUnavailableReason } from './upstreamError.js'; +import { parseInventoryQuantity } from '../inventoryQuantity.js'; import type { CuStockItem, CuStockMainResponse, CuStore, CuStoreResponse } from './types.js'; interface RequestOptions extends ConvenienceTransportOptions { @@ -201,7 +202,7 @@ function parseCuWebStores(html: string): CuStore[] { latitude: 0, longitude: 0, distanceM: 0, - stock: 0, + stock: null, deliveryYn: false, pickupYn: false, reserveYn: false, @@ -220,7 +221,7 @@ function normalizeCuStore(raw: NonNullable[number] latitude: toNumber(raw.latVal), longitude: toNumber(raw.longVal), distanceM: toNumber(raw.distance), - stock: toNumber(raw.stock), + stock: parseInventoryQuantity(raw.stock), deliveryYn: toYnBoolean(raw.deliveryYn) || toYnBoolean(raw.deliveryPickYn), pickupYn: toYnBoolean(raw.jumpoPickYn), reserveYn: toYnBoolean(raw.reserveYn), diff --git a/src/services/cu/productSelection.ts b/src/services/cu/productSelection.ts new file mode 100644 index 00000000..3e1ac34d --- /dev/null +++ b/src/services/cu/productSelection.ts @@ -0,0 +1,15 @@ +/** 상품명이 정확히 일치하는 재고 후보를 우선 사용합니다. */ +import type { CuStockItem } from './types.js'; + +export function selectCuStockItem(items: CuStockItem[], keyword: string): CuStockItem | null { + const candidates = items.filter((item) => item.itemCode.trim().length > 0); + const normalized = keyword.replace(/\s+/g, '').toLowerCase(); + return candidates.find((item) => item.itemName.replace(/\s+/g, '').toLowerCase() === normalized) + ?? candidates[0] ?? null; +} + +export function cuStockSelectionReason(item: CuStockItem | null, keyword: string): 'exact_match' | 'first_candidate' | null { + if (!item) return null; + return item.itemName.replace(/\s+/g, '').toLowerCase() === keyword.replace(/\s+/g, '').toLowerCase() + ? 'exact_match' : 'first_candidate'; +} diff --git a/src/services/cu/tools/checkInventory.ts b/src/services/cu/tools/checkInventory.ts index 482b2535..ed5cfbee 100644 --- a/src/services/cu/tools/checkInventory.ts +++ b/src/services/cu/tools/checkInventory.ts @@ -1,8 +1,10 @@ +import { ServiceError } from '../../../core/errors.js'; import type { ConvenienceTransportOptions } from '../../../utils/convenienceTransport.js'; /** * CU 재고 확인 도구 */ +import { selectCuStockItem, cuStockSelectionReason } from '../productSelection.js'; import * as z from 'zod'; import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; import { fetchCuStock, fetchCuStores, geocodeCuAddress } from '../client.js'; @@ -65,7 +67,7 @@ async function checkInventory(args: CheckInventoryArgs): Promise item.itemCode.trim().length > 0) || null; + const firstStockItem = selectCuStockItem(stockResult.items, keyword); const hasInputLocation = typeof latitude === 'number' && typeof longitude === 'number'; const resolvedLatitude = hasInputLocation ? latitude : undefined; const resolvedLongitude = hasInputLocation ? longitude : undefined; @@ -141,6 +143,7 @@ async function checkInventory(args: CheckInventoryArgs): Promise { +export async function fetchOnlineStock(productNo: string): Promise { const data = await fetchDaisoJson(DAISOMALL_API.ONLINE_STOCK, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pdNo: productNo }), }); - if (!data.success) { - return 0; - } - - return data.data?.stck || 0; + if (data.success === false) throw new Error('다이소 온라인 재고 조회에 실패했습니다.'); + return parseInventoryQuantity(data.data?.stck); } /** @@ -84,6 +82,7 @@ export async function fetchStoreInventory( }), }); + if (storeSearch.success === false) throw new Error('다이소 매장 검색에 실패했습니다.'); const allStores = storeSearch.data || []; const searchedStores = allStores.slice((page - 1) * pageSize, page * pageSize); if (searchedStores.length === 0) { @@ -107,8 +106,9 @@ export async function fetchStoreInventory( }, ); + if (inventoryResponse.success === false) throw new Error('다이소 매장 재고 조회에 실패했습니다.'); const quantities = new Map( - (inventoryResponse.data || []).map((item) => [item.strCd, parseInt(item.stck) || 0]), + (inventoryResponse.data || []).map((item) => [item.strCd, parseInventoryQuantity(item.stck)]), ); const stores: StoreInventory[] = searchedStores.map((store) => ({ @@ -121,7 +121,7 @@ export async function fetchStoreInventory( lat: store.strLttd, lng: store.strLitd, distance: store.km, - quantity: quantities.get(store.strCd) ?? 0, + quantity: quantities.get(store.strCd) ?? null, options: { parking: store.parkYn === 'Y', simCard: store.usimYn === 'Y', @@ -168,7 +168,7 @@ async function checkInventory(args: CheckInventoryArgs): Promise s.quantity > 0); + const inStockStores = storeResult.stores.filter((s) => (s.quantity ?? 0) > 0); const outOfStockStores = storeResult.stores.filter((s) => s.quantity === 0); const result = { @@ -180,6 +180,7 @@ async function checkInventory(args: CheckInventoryArgs): Promise store.quantity === null).length, page, pageSize, stores: storeResult.stores, diff --git a/src/services/daiso/tools/findInventoryByName.ts b/src/services/daiso/tools/findInventoryByName.ts index 6c198c3f..5c7c5ad9 100644 --- a/src/services/daiso/tools/findInventoryByName.ts +++ b/src/services/daiso/tools/findInventoryByName.ts @@ -84,7 +84,7 @@ function buildInventorySummary(args: { query: string; storeQuery: string; selectedProductName: string; - onlineStock: number; + onlineStock: number | null; inStockCount: number; totalStores: number; }): Record { @@ -93,7 +93,7 @@ function buildInventorySummary(args: { headline: `"${args.query}" 조회 결과, "${args.selectedProductName}" 상품을 기준으로 재고를 확인했습니다.`, selectedProduct: args.selectedProductName, storeQuery: args.storeQuery || '미지정', - inventorySummary: `${storeScope} 매장 ${args.totalStores}곳 중 ${args.inStockCount}곳에서 재고가 확인되었습니다. 온라인 재고는 ${args.onlineStock}개입니다.`, + inventorySummary: `${storeScope} 매장 ${args.totalStores}곳 중 ${args.inStockCount}곳에서 재고가 확인되었습니다. 온라인 재고는 ${args.onlineStock === null ? '확인 불가' : `${args.onlineStock}개`}입니다.`, displayLocationHint: '진열 위치가 필요하면 storeInventory.stores[].storeCode로 daiso_get_display_location을 호출하세요.', }; } @@ -195,7 +195,7 @@ async function findInventoryByName(args: FindInventoryByNameArgs): Promise store.quantity > 0).length; + const inStockCount = stores.filter((store) => (store.quantity ?? 0) > 0).length; const result = { query, @@ -216,6 +216,7 @@ async function findInventoryByName(args: FindInventoryByNameArgs): Promise store.quantity === 0).length, + unknownStockCount: stores.filter((store) => store.quantity === null).length, page, pageSize, stores, @@ -255,12 +256,13 @@ const inventoryByNameOutputSchema = { .describe('사용자에게 바로 보여줄 요약'), productCandidates: z.array(z.unknown()).describe('검색된 상품 후보 목록'), selectedProduct: z.unknown().nullable().describe('재고 조회 기준으로 선택한 상품'), - onlineStock: z.number().describe('온라인 재고 수량'), + onlineStock: z.number().nullable().describe('온라인 재고 수량'), storeInventory: z .object({ totalStores: z.number(), inStockCount: z.number(), outOfStockCount: z.number(), + unknownStockCount: z.number().optional(), page: z.number(), pageSize: z.number(), stores: z.array(z.unknown()), diff --git a/src/services/daiso/types.ts b/src/services/daiso/types.ts index a38ff117..7b336135 100644 --- a/src/services/daiso/types.ts +++ b/src/services/daiso/types.ts @@ -40,7 +40,7 @@ export interface StoreInventory { lat: number; lng: number; distance: string; - quantity: number; + quantity: number | null; options: { parking: boolean; simCard: boolean; @@ -65,7 +65,7 @@ export interface Product { soldOut?: boolean; isNew?: boolean; pickupAvailable?: boolean; - onlineStock?: number; + onlineStock?: number | null; } // 재고 응답용 상품 요약 정보 @@ -109,6 +109,7 @@ export interface OnlineStockResponse { } export interface StoreSearchV2Response { + success?: boolean; message: string | null; data: Array<{ strCd: string; diff --git a/src/services/dtryx/client.ts b/src/services/dtryx/client.ts index 35914b4f..b95d0ad1 100644 --- a/src/services/dtryx/client.ts +++ b/src/services/dtryx/client.ts @@ -2,7 +2,7 @@ * 디트릭스 API 클라이언트 */ -import { formatTime, toNumber, toYyyymmdd } from '../../utils/format.js'; +import { formatTime, toNumber, toSeatCount, toYyyymmdd } from '../../utils/format.js'; import { createTimeoutController } from '../../utils/http.js'; import { hasDtryxRelayOptions, @@ -139,8 +139,8 @@ export async function fetchDtryxTimetable( return response.Recordset.filter((item) => item.CinemaCd && item.MovieCd && item.ScreenCd).map( (item) => { - const totalSeats = toNumber(item.TotalSeatCnt); - const remainingSeats = toNumber(item.RemainSeatCnt); + const totalSeats = toSeatCount(item.TotalSeatCnt); + const remainingSeats = toSeatCount(item.RemainSeatCnt); const compactDate = toCompactDate(item.PlaySDT || playDate); const cinemaCode = String(item.CinemaCd); const screenCode = String(item.ScreenCd); @@ -162,7 +162,10 @@ export async function fetchDtryxTimetable( rating: item.RatingNm || '', totalSeats, remainingSeats, - bookedSeats: Math.max(totalSeats - remainingSeats, 0), + bookedSeats: + totalSeats === null || remainingSeats === null + ? null + : Math.max(totalSeats - remainingSeats, 0), planStatus: item.PlanStatus || '', }; }, diff --git a/src/services/dtryx/types.ts b/src/services/dtryx/types.ts index 7e073da8..a3381f10 100644 --- a/src/services/dtryx/types.ts +++ b/src/services/dtryx/types.ts @@ -35,9 +35,9 @@ export interface DtryxShowtime { endTime: string; runningMinutes: number; rating: string; - totalSeats: number; - remainingSeats: number; - bookedSeats: number; + totalSeats: number | null; + remainingSeats: number | null; + bookedSeats: number | null; planStatus: string; } diff --git a/src/services/emart24/client.ts b/src/services/emart24/client.ts index cf40139f..891309eb 100644 --- a/src/services/emart24/client.ts +++ b/src/services/emart24/client.ts @@ -216,7 +216,7 @@ export async function searchEmart24Products( }, ); - const products = (body.productList || []).map(toProduct).filter((item) => item.pluCd.length > 0); + const products = (body.productList || []).map(toProduct).filter((item) => item.pluCd.length > 0).slice(0, pageSize); return { totalCount: toNumber(body.totalCnt), diff --git a/src/services/emart24/inventoryLookup.ts b/src/services/emart24/inventoryLookup.ts index 3eab077d..820a56ff 100644 --- a/src/services/emart24/inventoryLookup.ts +++ b/src/services/emart24/inventoryLookup.ts @@ -2,6 +2,7 @@ * 이마트24 재고 조회 공통 로직 */ +import { parseInventoryQuantity } from '../inventoryQuantity.js'; import { calculateDistanceM, fetchEmart24StoreDetail, @@ -32,6 +33,7 @@ export interface Emart24InventoryLookupResult { keyword: string; pluCd: string; productCandidates: Emart24Product[]; + selectionReason: 'explicit_code' | 'exact_match' | 'first_candidate'; location: { latitude: number; longitude: number } | null; storeFilters: { storeKeyword: string; @@ -59,12 +61,13 @@ async function resolvePluCd( productPage: number, productPageSize: number, timeoutMs: number, -): Promise<{ pluCd: string; products: Emart24Product[] }> { +): Promise<{ pluCd: string; products: Emart24Product[]; selectionReason: Emart24InventoryLookupResult['selectionReason'] }> { const normalizedPluCd = (pluCd || '').trim(); if (normalizedPluCd.length > 0) { return { pluCd: normalizedPluCd, products: [], + selectionReason: 'explicit_code', }; } @@ -83,7 +86,8 @@ async function resolvePluCd( }, ); - const firstProduct = productResult.products.find((item) => item.pluCd.trim().length > 0); + const firstProduct = productResult.products.find((item) => item.pluCd.trim().length > 0 && item.goodsName.replace(/\s+/g, '').toLowerCase() === keyword.replace(/\s+/g, '').toLowerCase()) + ?? productResult.products.find((item) => item.pluCd.trim().length > 0); if (!firstProduct) { throw new Error('상품 검색 결과에서 PLU 코드를 찾을 수 없습니다.'); } @@ -91,6 +95,7 @@ async function resolvePluCd( return { pluCd: firstProduct.pluCd, products: productResult.products, + selectionReason: firstProduct.goodsName.replace(/\s+/g, '').toLowerCase() === keyword.replace(/\s+/g, '').toLowerCase() ? 'exact_match' : 'first_candidate', }; } @@ -187,7 +192,7 @@ export async function lookupEmart24Inventory( const qtyByBizNo = new Map( (stockResult.storeGoodsQty || []).map((item) => [ String(item.BIZNO || '').trim(), - Number.parseInt(String(item.BIZQTY || 0), 10) || 0, + parseInventoryQuantity(item.BIZQTY), ]), ); @@ -210,7 +215,7 @@ export async function lookupEmart24Inventory( return { bizNo, - bizQty: qtyByBizNo.get(bizNo) ?? 0, + bizQty: qtyByBizNo.get(bizNo) ?? null, storeName: detailInfo?.storeNm || nearby?.storeName || '', address: detailInfo?.storeAddr || nearby?.address || '', phone: detailInfo?.tel || nearby?.phone || '', @@ -222,6 +227,7 @@ export async function lookupEmart24Inventory( keyword, pluCd: resolved.pluCd, productCandidates: resolved.products, + selectionReason: resolved.selectionReason, location: typeof latitude === 'number' && typeof longitude === 'number' ? { latitude, longitude } diff --git a/src/services/emart24/types.ts b/src/services/emart24/types.ts index bbb775ae..d811132b 100644 --- a/src/services/emart24/types.ts +++ b/src/services/emart24/types.ts @@ -29,7 +29,7 @@ export interface Emart24Product { export interface Emart24StoreInventory { bizNo: string; - bizQty: number; + bizQty: number | null; storeName: string; address: string; phone: string; diff --git a/src/services/gs25/client.ts b/src/services/gs25/client.ts index 4617569d..2e0e6647 100644 --- a/src/services/gs25/client.ts +++ b/src/services/gs25/client.ts @@ -90,9 +90,9 @@ function normalizeGs25WebStore(raw: Gs25WebLocationStore): Gs25Store { latitude: toNumber(raw.posY), longitude: toNumber(raw.posX), serviceCode: '01', - realStockQuantity: 0, - pickupStockQuantity: 0, - deliveryStockQuantity: 0, + realStockQuantity: null, + pickupStockQuantity: null, + deliveryStockQuantity: null, isSoldOut: false, searchItemName: '', searchItemSellPrice: null, diff --git a/src/services/gs25/storeUtils.ts b/src/services/gs25/storeUtils.ts index c9baf870..61cb85cd 100644 --- a/src/services/gs25/storeUtils.ts +++ b/src/services/gs25/storeUtils.ts @@ -2,6 +2,7 @@ * GS25 매장 정규화와 정렬 유틸리티 */ +import { parseInventoryQuantity } from '../inventoryQuantity.js'; import type { Gs25ProductCandidate, Gs25Store, @@ -65,10 +66,10 @@ export function normalizeStore(raw: NonNullable 0 ? 1 : 0; + const inStock = (store.realStockQuantity ?? 0) > 0 ? 1 : 0; if (!prev) { map.set(key, { @@ -200,14 +201,19 @@ export function extractGs25ProductCandidates(stores: Gs25Store[]): Gs25ProductCa sellPrice: store.searchItemSellPrice, matchedStoreCount: 1, inStockStoreCount: inStock, - totalStockQuantity: Math.max(store.realStockQuantity, 0), + totalStockQuantity: store.realStockQuantity, + unknownStockStoreCount: store.realStockQuantity === null ? 1 : 0, }); continue; } prev.matchedStoreCount += 1; prev.inStockStoreCount += inStock; - prev.totalStockQuantity += Math.max(store.realStockQuantity, 0); + if (store.realStockQuantity === null) { + prev.unknownStockStoreCount += 1; + } else { + prev.totalStockQuantity = (prev.totalStockQuantity ?? 0) + store.realStockQuantity; + } } return [...map.values()].sort((a, b) => { @@ -216,7 +222,7 @@ export function extractGs25ProductCandidates(stores: Gs25Store[]): Gs25ProductCa } if (b.totalStockQuantity !== a.totalStockQuantity) { - return b.totalStockQuantity - a.totalStockQuantity; + return (b.totalStockQuantity ?? -1) - (a.totalStockQuantity ?? -1); } return a.name.localeCompare(b.name, 'ko'); diff --git a/src/services/gs25/tools/checkInventory.ts b/src/services/gs25/tools/checkInventory.ts index a415eca2..46538733 100644 --- a/src/services/gs25/tools/checkInventory.ts +++ b/src/services/gs25/tools/checkInventory.ts @@ -195,10 +195,12 @@ async function checkInventory(args: CheckInventoryArgs): Promise item.realStockQuantity > 0, + (item) => (item.realStockQuantity ?? 0) > 0, ).length; - const totalStockQuantity = filteredByStoreKeyword.reduce( - (sum, item) => sum + Math.max(item.realStockQuantity, 0), + const knownStockStores = filteredByStoreKeyword.filter((item) => item.realStockQuantity !== null); + const unknownStockStoreCount = filteredByStoreKeyword.length - knownStockStores.length; + const totalStockQuantity = knownStockStores.length === 0 ? null : knownStockStores.reduce( + (sum, item) => sum + Math.max(item.realStockQuantity ?? 0, 0), 0, ); @@ -236,6 +238,7 @@ async function checkInventory(args: CheckInventoryArgs): Promise= 0 ? parsed : null; +} diff --git a/src/services/lottecinema/client.ts b/src/services/lottecinema/client.ts index 346c14d3..8b194754 100644 --- a/src/services/lottecinema/client.ts +++ b/src/services/lottecinema/client.ts @@ -2,7 +2,7 @@ * 롯데시네마 API 클라이언트 */ -import { formatTime, toNumber, toYyyymmdd } from '../../utils/format.js'; +import { formatTime, toNumber, toSeatCount, toYyyymmdd } from '../../utils/format.js'; import { createTimeoutController } from '../../utils/http.js'; import { LOTTECINEMA_API } from './api.js'; import type { @@ -44,7 +44,10 @@ function toNullableNumber(value: string | number | null | undefined): number | n return Number.isFinite(parsed) ? parsed : null; } -function buildRequestPayload(methodName: string, fields: Record): Record { +function buildRequestPayload( + methodName: string, + fields: Record, +): Record { return { MethodName: methodName, channelType: 'HO', @@ -99,7 +102,12 @@ function buildCompositeCinemaId(theater: LotteCinemaTheater): string { return `${theater.regionCode}|${theater.regionDetailCode}|${theater.theaterId}`; } -function buildScheduleId(playDate: string, theaterId: string, screenId: string, playSequence: string): string { +function buildScheduleId( + playDate: string, + theaterId: string, + screenId: string, + playSequence: string, +): string { return `${playDate}-${theaterId}-${screenId}-${playSequence}`; } @@ -114,18 +122,25 @@ export async function fetchLotteCinemaTicketingPage( timeout, ); - const theaters = Array.from(new Map((response.Cinemas?.Cinemas?.Items || []) - .filter((item) => item.CinemaID && item.CinemaNameKR && item.DivisionCode && item.DetailDivisionCode) - .map((item) => ({ - theaterId: String(item.CinemaID), - theaterName: item.CinemaNameKR as string, - regionCode: String(item.DivisionCode), - regionDetailCode: String(item.DetailDivisionCode), - latitude: toNullableNumber(item.Latitude), - longitude: toNullableNumber(item.Longitude), - address: item.CinemaAddrSummary || '', - })) - .map((theater) => [theater.theaterId, theater] as const)).values()); + const theaters = Array.from( + new Map( + (response.Cinemas?.Cinemas?.Items || []) + .filter( + (item) => + item.CinemaID && item.CinemaNameKR && item.DivisionCode && item.DetailDivisionCode, + ) + .map((item) => ({ + theaterId: String(item.CinemaID), + theaterName: item.CinemaNameKR as string, + regionCode: String(item.DivisionCode), + regionDetailCode: String(item.DetailDivisionCode), + latitude: toNullableNumber(item.Latitude), + longitude: toNullableNumber(item.Longitude), + address: item.CinemaAddrSummary || '', + })) + .map((theater) => [theater.theaterId, theater] as const), + ).values(), + ); const movies = (response.Movies?.Movies?.Items || []) .filter((item) => item.RepresentationMovieCode && item.MovieNameKR) @@ -133,7 +148,8 @@ export async function fetchLotteCinemaTicketingPage( movieId: String(item.RepresentationMovieCode), movieName: item.MovieNameKR as string, rating: item.ViewGradeNameKR || undefined, - durationMinutes: item.PlayTime === null || item.PlayTime === undefined ? undefined : toNumber(item.PlayTime), + durationMinutes: + item.PlayTime === null || item.PlayTime === undefined ? undefined : toNumber(item.PlayTime), releaseDate: item.ReleaseDate || undefined, })); @@ -157,12 +173,17 @@ async function fetchPlaySequenceByPair( ); return (response.PlaySeqs?.Items || []) - .filter((item) => item.CinemaID && item.RepresentationMovieCode && item.ScreenID && item.PlaySequence) + .filter( + (item) => item.CinemaID && item.RepresentationMovieCode && item.ScreenID && item.PlaySequence, + ) .map((item) => { - const totalSeats = toNumber(item.TotalSeatCount); + const totalSeats = toSeatCount(item.TotalSeatCount); // 공식 BookingSeatCount는 예매된 수가 아니라 예매 가능(잔여) 좌석 수다. - const remainingSeats = toNumber(item.BookingSeatCount); - const bookedSeats = Math.max(totalSeats - remainingSeats, 0); + const remainingSeats = toSeatCount(item.BookingSeatCount); + const bookedSeats = + totalSeats === null || remainingSeats === null + ? null + : Math.max(totalSeats - remainingSeats, 0); const normalizedPlayDate = normalizeDateForOutput(item.PlayDt || playDate); const theaterId = String(item.CinemaID); const screenId = String(item.ScreenID); @@ -188,7 +209,11 @@ async function fetchPlaySequenceByPair( export async function fetchLotteCinemaNowShowing( params: CommonFetchParams, -): Promise<{ theaters: LotteCinemaTheater[]; movies: LotteCinemaMovie[]; showtimes: LotteCinemaShowtime[] }> { +): Promise<{ + theaters: LotteCinemaTheater[]; + movies: LotteCinemaMovie[]; + showtimes: LotteCinemaShowtime[]; +}> { const playDate = params.playDate || toYyyymmdd(); const timeout = params.timeout || 15000; const ticketingPage = await fetchLotteCinemaTicketingPage(timeout); diff --git a/src/services/lottecinema/tools/findNearbyTheaters.ts b/src/services/lottecinema/tools/findNearbyTheaters.ts index 8a1ccf89..87694f01 100644 --- a/src/services/lottecinema/tools/findNearbyTheaters.ts +++ b/src/services/lottecinema/tools/findNearbyTheaters.ts @@ -1,3 +1,10 @@ +import { + validateCinemaOptions, + cinemaDateSchema, + cinemaLimitSchema, + cinemaLatitudeSchema, + cinemaLongitudeSchema, +} from '../../cgv/validation.js'; import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * 롯데시네마 주변 지점 탐색 도구 @@ -22,7 +29,14 @@ async function findNearbyTheaters( googleMapsApiKey?: string, geocodeOptions: GeocodeOptions = {}, ): Promise { - const { keyword, latitude, longitude, playDate = toYyyymmdd(), limit = 10, timeoutMs = 15000 } = args; + const { + keyword, + latitude, + longitude, + playDate = toYyyymmdd(), + limit = 10, + timeoutMs = 15000, + } = args; const hasCoordinates = typeof latitude === 'number' && typeof longitude === 'number'; const nearby = await fetchLotteCinemaNearbyTheaters( { @@ -36,7 +50,7 @@ async function findNearbyTheaters( { timeout: timeoutMs, ...geocodeOptions, - googleMapsApiKey, + googleMapsApiKey, }, ); @@ -50,7 +64,10 @@ async function findNearbyTheaters( }; } -export function createFindNearbyTheatersTool(googleMapsApiKey?: string, geocodeOptions: GeocodeOptions = {}): ToolRegistration { +export function createFindNearbyTheatersTool( + googleMapsApiKey?: string, + geocodeOptions: GeocodeOptions = {}, +): ToolRegistration { return { name: 'lottecinema_find_nearby_theaters', metadata: { @@ -58,15 +75,23 @@ export function createFindNearbyTheatersTool(googleMapsApiKey?: string, geocodeO description: '위치 키워드 또는 좌표 기준으로 롯데시네마 지점을 거리순으로 조회합니다.', inputSchema: { keyword: z.string().optional().describe('위치 키워드 (예: 안산 중앙역, 잠실역)'), - latitude: z.number().optional().describe('위도 (좌표 또는 위치 키워드 입력)'), - longitude: z.number().optional().describe('경도 (좌표 또는 위치 키워드 입력)'), - playDate: z.string().optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), - limit: z.number().optional().default(10).describe('반환할 최대 지점 수 (기본값: 10)'), - timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), + latitude: cinemaLatitudeSchema.optional().describe('위도 (좌표 또는 위치 키워드 입력)'), + longitude: cinemaLongitudeSchema.optional().describe('경도 (좌표 또는 위치 키워드 입력)'), + playDate: cinemaDateSchema.optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), + limit: cinemaLimitSchema + .optional() + .default(10) + .describe('반환할 최대 지점 수 (기본값: 10)'), + timeoutMs: z + .number() + .optional() + .default(15000) + .describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: ((args) => findNearbyTheaters(args as FindNearbyTheatersArgs, googleMapsApiKey, geocodeOptions)) as ( - args: unknown, - ) => Promise, + handler: (async (args) => ( + validateCinemaOptions(args), + findNearbyTheaters(args as FindNearbyTheatersArgs, googleMapsApiKey, geocodeOptions) + )) as (args: unknown) => Promise, }; } diff --git a/src/services/lottecinema/tools/getRemainingSeats.ts b/src/services/lottecinema/tools/getRemainingSeats.ts index 3226c70c..adfbb00b 100644 --- a/src/services/lottecinema/tools/getRemainingSeats.ts +++ b/src/services/lottecinema/tools/getRemainingSeats.ts @@ -1,3 +1,10 @@ +import { + validateCinemaOptions, + cinemaDateSchema, + cinemaLimitSchema, + cinemaLatitudeSchema, + cinemaLongitudeSchema, +} from '../../cgv/validation.js'; import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * 롯데시네마 잔여 좌석 조회 도구 @@ -37,7 +44,10 @@ async function getRemainingSeats( let resolvedTheater = null; let targetTheaterId = theaterId; - if (!targetTheaterId && (keyword || typeof latitude === 'number' || typeof longitude === 'number')) { + if ( + !targetTheaterId && + (keyword || typeof latitude === 'number' || typeof longitude === 'number') + ) { const resolved = await resolveLotteCinemaNearestTheater( { playDate, @@ -61,11 +71,11 @@ async function getRemainingSeats( const { showtimes } = shouldReturnEmpty ? { showtimes: [] } : await fetchLotteCinemaNowShowing({ - playDate, - theaterId: targetTheaterId, - movieId, - timeout: timeoutMs, - }); + playDate, + theaterId: targetTheaterId, + movieId, + timeout: timeoutMs, + }); const seats = showtimes .filter((item) => (targetTheaterId ? item.theaterId === targetTheaterId : true)) @@ -105,25 +115,37 @@ async function getRemainingSeats( }; } -export function createGetRemainingSeatsTool(googleMapsApiKey?: string, geocodeOptions: GeocodeOptions = {}): ToolRegistration { +export function createGetRemainingSeatsTool( + googleMapsApiKey?: string, + geocodeOptions: GeocodeOptions = {}, +): ToolRegistration { return { name: 'lottecinema_get_remaining_seats', metadata: { title: '롯데시네마 잔여 좌석 조회', - description: '영화/지점/날짜 조건으로 롯데시네마 상영 회차별 남은 좌석 수를 조회합니다. 지점 ID가 없으면 위치 키워드 기준 최근접 극장을 선택할 수 있습니다.', + description: + '영화/지점/날짜 조건으로 롯데시네마 상영 회차별 남은 좌석 수를 조회합니다. 지점 ID가 없으면 위치 키워드 기준 최근접 극장을 선택할 수 있습니다.', inputSchema: { - playDate: z.string().optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), + playDate: cinemaDateSchema.optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), theaterId: z.string().optional().describe('롯데시네마 지점 ID (예: 1016)'), movieId: z.string().optional().describe('롯데시네마 대표 영화 코드 (예: 23816)'), keyword: z.string().optional().describe('위치 키워드 (예: 안산 중앙역, 잠실역)'), - latitude: z.number().optional().describe('위도'), - longitude: z.number().optional().describe('경도'), - limit: z.number().optional().default(50).describe('반환할 최대 회차 수 (기본값: 50)'), - timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), + latitude: cinemaLatitudeSchema.optional().describe('위도'), + longitude: cinemaLongitudeSchema.optional().describe('경도'), + limit: cinemaLimitSchema + .optional() + .default(50) + .describe('반환할 최대 회차 수 (기본값: 50)'), + timeoutMs: z + .number() + .optional() + .default(15000) + .describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: ((args) => getRemainingSeats(args as GetRemainingSeatsArgs, googleMapsApiKey, geocodeOptions)) as ( - args: unknown, - ) => Promise, + handler: (async (args) => ( + validateCinemaOptions(args), + getRemainingSeats(args as GetRemainingSeatsArgs, googleMapsApiKey, geocodeOptions) + )) as (args: unknown) => Promise, }; } diff --git a/src/services/lottecinema/tools/listNowShowing.ts b/src/services/lottecinema/tools/listNowShowing.ts index 1715634f..27906b11 100644 --- a/src/services/lottecinema/tools/listNowShowing.ts +++ b/src/services/lottecinema/tools/listNowShowing.ts @@ -1,3 +1,9 @@ +import { + validateCinemaOptions, + cinemaDateSchema, + cinemaLatitudeSchema, + cinemaLongitudeSchema, +} from '../../cgv/validation.js'; import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * 롯데시네마 영화 목록 조회 도구 @@ -18,12 +24,27 @@ interface ListNowShowingArgs { timeoutMs?: number; } -async function listNowShowing(args: ListNowShowingArgs, googleMapsApiKey?: string, geocodeOptions: GeocodeOptions = {}): Promise { - const { playDate = toYyyymmdd(), theaterId, movieId, keyword, latitude, longitude, timeoutMs = 15000 } = args; +async function listNowShowing( + args: ListNowShowingArgs, + googleMapsApiKey?: string, + geocodeOptions: GeocodeOptions = {}, +): Promise { + const { + playDate = toYyyymmdd(), + theaterId, + movieId, + keyword, + latitude, + longitude, + timeoutMs = 15000, + } = args; let resolvedTheater = null; let targetTheaterId = theaterId; - if (!targetTheaterId && (keyword || typeof latitude === 'number' || typeof longitude === 'number')) { + if ( + !targetTheaterId && + (keyword || typeof latitude === 'number' || typeof longitude === 'number') + ) { const resolved = await resolveLotteCinemaNearestTheater( { playDate, @@ -47,11 +68,11 @@ async function listNowShowing(args: ListNowShowingArgs, googleMapsApiKey?: strin const { theaters, movies, showtimes } = shouldReturnEmpty ? { theaters: [], movies: [], showtimes: [] } : await fetchLotteCinemaNowShowing({ - playDate, - theaterId: targetTheaterId, - movieId, - timeout: timeoutMs, - }); + playDate, + theaterId: targetTheaterId, + movieId, + timeout: timeoutMs, + }); return { content: [ @@ -85,24 +106,33 @@ async function listNowShowing(args: ListNowShowingArgs, googleMapsApiKey?: strin }; } -export function createListNowShowingTool(googleMapsApiKey?: string, geocodeOptions: GeocodeOptions = {}): ToolRegistration { +export function createListNowShowingTool( + googleMapsApiKey?: string, + geocodeOptions: GeocodeOptions = {}, +): ToolRegistration { return { name: 'lottecinema_list_now_showing', metadata: { title: '롯데시네마 영화 목록 조회', - description: '날짜/지점/영화 조건으로 롯데시네마 영화 및 상영 회차 목록을 조회합니다. 지점 ID가 없으면 위치 키워드 기준 최근접 극장을 선택할 수 있습니다.', + description: + '날짜/지점/영화 조건으로 롯데시네마 영화 및 상영 회차 목록을 조회합니다. 지점 ID가 없으면 위치 키워드 기준 최근접 극장을 선택할 수 있습니다.', inputSchema: { - playDate: z.string().optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), + playDate: cinemaDateSchema.optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), theaterId: z.string().optional().describe('롯데시네마 지점 ID (예: 1016)'), movieId: z.string().optional().describe('롯데시네마 대표 영화 코드 (예: 23816)'), keyword: z.string().optional().describe('위치 키워드 (예: 안산 중앙역, 잠실역)'), - latitude: z.number().optional().describe('위도'), - longitude: z.number().optional().describe('경도'), - timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), + latitude: cinemaLatitudeSchema.optional().describe('위도'), + longitude: cinemaLongitudeSchema.optional().describe('경도'), + timeoutMs: z + .number() + .optional() + .default(15000) + .describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: ((args) => listNowShowing(args as ListNowShowingArgs, googleMapsApiKey, geocodeOptions)) as ( - args: unknown, - ) => Promise, + handler: (async (args) => ( + validateCinemaOptions(args), + listNowShowing(args as ListNowShowingArgs, googleMapsApiKey, geocodeOptions) + )) as (args: unknown) => Promise, }; } diff --git a/src/services/lottecinema/types.ts b/src/services/lottecinema/types.ts index b4271eec..9b0ac57e 100644 --- a/src/services/lottecinema/types.ts +++ b/src/services/lottecinema/types.ts @@ -31,9 +31,9 @@ export interface LotteCinemaShowtime { playDate: string; startTime: string; endTime: string; - totalSeats: number; - bookedSeats: number; - remainingSeats: number; + totalSeats: number | null; + bookedSeats: number | null; + remainingSeats: number | null; } interface LotteCinemaTicketingTheaterItem { diff --git a/src/services/megabox/client.ts b/src/services/megabox/client.ts index fe4a5dc1..3770a004 100644 --- a/src/services/megabox/client.ts +++ b/src/services/megabox/client.ts @@ -10,7 +10,7 @@ import type { MegaboxTheater, MegaboxTheaterInfo, } from './types.js'; -import { formatTime, toNumber, toYyyymmdd } from '../../utils/format.js'; +import { formatTime, toSeatCount, toYyyymmdd } from '../../utils/format.js'; import { createTimeoutController } from '../../utils/http.js'; interface FetchBookingListParams { @@ -88,8 +88,8 @@ export async function fetchMegaboxBookingList( playDate: item.playDe || params.playDate, startTime: formatTime(item.playStartTime), endTime: formatTime(item.playEndTime), - totalSeats: toNumber(item.totSeatCnt), - remainingSeats: toNumber(item.restSeatCnt), + totalSeats: toSeatCount(item.totSeatCnt), + remainingSeats: toSeatCount(item.restSeatCnt), })); return { diff --git a/src/services/megabox/tools/findNearbyTheaters.ts b/src/services/megabox/tools/findNearbyTheaters.ts index 8b2aaf7d..b0044c49 100644 --- a/src/services/megabox/tools/findNearbyTheaters.ts +++ b/src/services/megabox/tools/findNearbyTheaters.ts @@ -1,3 +1,10 @@ +import { + validateCinemaOptions, + cinemaDateSchema, + cinemaLimitSchema, + cinemaLatitudeSchema, + cinemaLongitudeSchema, +} from '../../cgv/validation.js'; import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * 메가박스 주변 지점 탐색 도구 @@ -18,7 +25,10 @@ interface FindNearbyTheatersArgs { timeoutMs?: number; } -async function findNearbyTheaters(args: FindNearbyTheatersArgs, geocodeOptions: GeocodeOptions = {}): Promise { +async function findNearbyTheaters( + args: FindNearbyTheatersArgs, + geocodeOptions: GeocodeOptions = {}, +): Promise { const { keyword, latitude, @@ -42,7 +52,7 @@ async function findNearbyTheaters(args: FindNearbyTheatersArgs, geocodeOptions: { ...geocodeOptions, timeout: timeoutMs, - } + }, ); return { @@ -50,7 +60,9 @@ async function findNearbyTheaters(args: FindNearbyTheatersArgs, geocodeOptions: }; } -export function createFindNearbyTheatersTool(geocodeOptions: GeocodeOptions = {}): ToolRegistration { +export function createFindNearbyTheatersTool( + geocodeOptions: GeocodeOptions = {}, +): ToolRegistration { return { name: 'megabox_find_nearby_theaters', metadata: { @@ -58,14 +70,24 @@ export function createFindNearbyTheatersTool(geocodeOptions: GeocodeOptions = {} description: '사용자 좌표 또는 위치 키워드 기준으로 메가박스 지점을 거리순으로 조회합니다.', inputSchema: { keyword: z.string().optional().describe('위치 키워드 (예: 안산 중앙역, 강남역)'), - latitude: z.number().optional().describe('위도 (미입력 시 keyword 해석)'), - longitude: z.number().optional().describe('경도 (미입력 시 keyword 해석)'), - playDate: z.string().optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), + latitude: cinemaLatitudeSchema.optional().describe('위도 (미입력 시 keyword 해석)'), + longitude: cinemaLongitudeSchema.optional().describe('경도 (미입력 시 keyword 해석)'), + playDate: cinemaDateSchema.optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), areaCode: z.string().optional().describe('지역 코드 (미입력 시 위치로 결정)'), - limit: z.number().optional().default(10).describe('반환할 최대 지점 수 (기본값: 10)'), - timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), + limit: cinemaLimitSchema + .optional() + .default(10) + .describe('반환할 최대 지점 수 (기본값: 10)'), + timeoutMs: z + .number() + .optional() + .default(15000) + .describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: ((args: unknown) => findNearbyTheaters(args as FindNearbyTheatersArgs, geocodeOptions)), + handler: async (args: unknown) => ( + validateCinemaOptions(args), + findNearbyTheaters(args as FindNearbyTheatersArgs, geocodeOptions) + ), }; } diff --git a/src/services/megabox/tools/getRemainingSeats.ts b/src/services/megabox/tools/getRemainingSeats.ts index 422c93a5..78126405 100644 --- a/src/services/megabox/tools/getRemainingSeats.ts +++ b/src/services/megabox/tools/getRemainingSeats.ts @@ -1,3 +1,10 @@ +import { + validateCinemaOptions, + cinemaDateSchema, + cinemaLimitSchema, + cinemaLatitudeSchema, + cinemaLongitudeSchema, +} from '../../cgv/validation.js'; import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * 메가박스 잔여 좌석 조회 도구 @@ -20,7 +27,10 @@ interface GetRemainingSeatsArgs { timeoutMs?: number; } -async function getRemainingSeats(args: GetRemainingSeatsArgs, geocodeOptions: GeocodeOptions = {}): Promise { +async function getRemainingSeats( + args: GetRemainingSeatsArgs, + geocodeOptions: GeocodeOptions = {}, +): Promise { const { playDate = toYyyymmdd(), theaterId: inputTheaterId, @@ -36,7 +46,12 @@ async function getRemainingSeats(args: GetRemainingSeatsArgs, geocodeOptions: Ge let resolvedTheater = null; let resolvedLocation = null; - if (!theaterId && (typeof latitude === 'number' || typeof longitude === 'number' || (keyword || '').trim().length > 0)) { + if ( + !theaterId && + (typeof latitude === 'number' || + typeof longitude === 'number' || + (keyword || '').trim().length > 0) + ) { const resolved = await resolveMegaboxNearestTheater( { keyword, @@ -51,7 +66,8 @@ async function getRemainingSeats(args: GetRemainingSeatsArgs, geocodeOptions: Ge timeout: timeoutMs, }, ); - theaterId = resolved.theater?.theaterId; + if (!resolved.theater) throw new Error('요청 위치의 메가박스 극장을 찾을 수 없습니다.'); + theaterId = resolved.theater.theaterId; resolvedTheater = resolved.theater; resolvedLocation = resolved.location; } @@ -105,17 +121,27 @@ export function createGetRemainingSeatsTool(geocodeOptions: GeocodeOptions = {}) title: '메가박스 잔여 좌석 조회', description: '영화/지점/날짜 조건으로 상영 회차별 남은 좌석 수를 조회합니다.', inputSchema: { - playDate: z.string().optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), + playDate: cinemaDateSchema.optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), theaterId: z.string().optional().describe('메가박스 지점 번호 (예: 1372)'), movieId: z.string().optional().describe('메가박스 영화 번호 (예: 25104500)'), keyword: z.string().optional().describe('위치 키워드 (예: 안산 중앙역, 강남역)'), - latitude: z.number().optional().describe('위도'), - longitude: z.number().optional().describe('경도'), + latitude: cinemaLatitudeSchema.optional().describe('위도'), + longitude: cinemaLongitudeSchema.optional().describe('경도'), areaCode: z.string().optional().describe('지역 코드 (미입력 시 위치로 결정)'), - limit: z.number().optional().default(50).describe('반환할 최대 회차 수 (기본값: 50)'), - timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), + limit: cinemaLimitSchema + .optional() + .default(50) + .describe('반환할 최대 회차 수 (기본값: 50)'), + timeoutMs: z + .number() + .optional() + .default(15000) + .describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: ((args: unknown) => getRemainingSeats(args as GetRemainingSeatsArgs, geocodeOptions)), + handler: async (args: unknown) => ( + validateCinemaOptions(args), + getRemainingSeats(args as GetRemainingSeatsArgs, geocodeOptions) + ), }; } diff --git a/src/services/megabox/tools/listNowShowing.ts b/src/services/megabox/tools/listNowShowing.ts index 8728bf92..608c96c2 100644 --- a/src/services/megabox/tools/listNowShowing.ts +++ b/src/services/megabox/tools/listNowShowing.ts @@ -1,3 +1,9 @@ +import { + validateCinemaOptions, + cinemaDateSchema, + cinemaLatitudeSchema, + cinemaLongitudeSchema, +} from '../../cgv/validation.js'; import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * 메가박스 영화 목록 조회 도구 @@ -19,7 +25,10 @@ interface ListNowShowingArgs { timeoutMs?: number; } -async function listNowShowing(args: ListNowShowingArgs, geocodeOptions: GeocodeOptions = {}): Promise { +async function listNowShowing( + args: ListNowShowingArgs, + geocodeOptions: GeocodeOptions = {}, +): Promise { const { playDate = toYyyymmdd(), theaterId: inputTheaterId, @@ -34,7 +43,12 @@ async function listNowShowing(args: ListNowShowingArgs, geocodeOptions: GeocodeO let resolvedTheater = null; let resolvedLocation = null; - if (!theaterId && (typeof latitude === 'number' || typeof longitude === 'number' || (keyword || '').trim().length > 0)) { + if ( + !theaterId && + (typeof latitude === 'number' || + typeof longitude === 'number' || + (keyword || '').trim().length > 0) + ) { const resolved = await resolveMegaboxNearestTheater( { keyword, @@ -49,7 +63,8 @@ async function listNowShowing(args: ListNowShowingArgs, geocodeOptions: GeocodeO timeout: timeoutMs, }, ); - theaterId = resolved.theater?.theaterId; + if (!resolved.theater) throw new Error('요청 위치의 메가박스 극장을 찾을 수 없습니다.'); + theaterId = resolved.theater.theaterId; resolvedTheater = resolved.theater; resolvedLocation = resolved.location; } @@ -97,16 +112,23 @@ export function createListNowShowingTool(geocodeOptions: GeocodeOptions = {}): T title: '메가박스 영화 목록 조회', description: '날짜/지점 조건으로 메가박스 영화 및 상영 회차 목록을 조회합니다.', inputSchema: { - playDate: z.string().optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), + playDate: cinemaDateSchema.optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'), theaterId: z.string().optional().describe('메가박스 지점 번호 (예: 1372)'), movieId: z.string().optional().describe('메가박스 영화 번호 (예: 25104500)'), keyword: z.string().optional().describe('위치 키워드 (예: 안산 중앙역, 강남역)'), - latitude: z.number().optional().describe('위도'), - longitude: z.number().optional().describe('경도'), + latitude: cinemaLatitudeSchema.optional().describe('위도'), + longitude: cinemaLongitudeSchema.optional().describe('경도'), areaCode: z.string().optional().describe('지역 코드 (미입력 시 위치로 결정)'), - timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), + timeoutMs: z + .number() + .optional() + .default(15000) + .describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: ((args: unknown) => listNowShowing(args as ListNowShowingArgs, geocodeOptions)), + handler: async (args: unknown) => ( + validateCinemaOptions(args), + listNowShowing(args as ListNowShowingArgs, geocodeOptions) + ), }; } diff --git a/src/services/megabox/types.ts b/src/services/megabox/types.ts index c9bd1639..ba89b6bf 100644 --- a/src/services/megabox/types.ts +++ b/src/services/megabox/types.ts @@ -22,8 +22,8 @@ export interface MegaboxShowtime { playDate: string; startTime: string; endTime: string; - totalSeats: number; - remainingSeats: number; + totalSeats: number | null; + remainingSeats: number | null; } export interface MegaboxTheaterInfo { diff --git a/src/services/oliveyoung/client.ts b/src/services/oliveyoung/client.ts index 13e37df8..8c685ed6 100644 --- a/src/services/oliveyoung/client.ts +++ b/src/services/oliveyoung/client.ts @@ -58,7 +58,6 @@ const oliveyoungProductSearchCache = new Map< { expiresAt: number; result: OliveyoungProductSearchResult } >(); const oliveyoungStoreSearchCache = new Map(); -const oliveyoungStockStoresCache = new Map(); function createOliveyoungProductSearchCacheKey(params: SearchProductsParams): string { return JSON.stringify([params.keyword, params.page, params.size, params.sort, params.includeSoldOut]); @@ -68,10 +67,6 @@ function createOliveyoungStoreSearchCacheKey(params: FindStoresParams): string { return JSON.stringify([params.latitude, params.longitude, params.pageIdx, params.searchWords]); } -function createOliveyoungStockStoresCacheKey(params: StockStoresParams): string { - return JSON.stringify([params.productId, params.latitude, params.longitude, params.pageIdx, params.searchWords]); -} - function cloneOliveyoungProductSearchResult(result: OliveyoungProductSearchResult): OliveyoungProductSearchResult { return { totalCount: result.totalCount, @@ -87,16 +82,6 @@ function cloneOliveyoungStoreSearchResult(result: OliveyoungStoreSearchResult): }; } -function cloneOliveyoungStockStoresResult(result: OliveyoungProductStoreInventory): OliveyoungProductStoreInventory { - return { - totalCount: result.totalCount, - inStockCount: result.inStockCount, - outOfStockCount: result.outOfStockCount, - notSoldCount: result.notSoldCount, - stores: result.stores.map((store) => ({ ...store })), - }; -} - function readStaleResult( cache: Map, cacheKey: string, @@ -214,7 +199,17 @@ export async function fetchOliveyoungProducts( } catch (error) { const staleResult = readStaleResult(oliveyoungProductSearchCache, cacheKey, cloneOliveyoungProductSearchResult); if (staleResult) { - return staleResult; + return { + ...staleResult, + products: staleResult.products.map((product) => ({ + ...product, + o2oStockFlag: false, + o2oRemainQuantity: 0, + inStock: false, + stockStatus: 'unknown', + storeInventory: undefined, + })), + }; } throw error; } @@ -256,43 +251,35 @@ async function fetchOliveyoungStockStores( params: StockStoresParams, options: RequestOptions = {} ): Promise { - const cacheKey = createOliveyoungStockStoresCacheKey(params); - - try { - const body = await requestOliveyoung( - OLIVEYOUNG_API.STOCK_STORES_PATH, - { - productId: params.productId, - lat: params.latitude, - lon: params.longitude, - pageIdx: params.pageIdx, - searchWords: params.searchWords, - mapLat: params.latitude, - mapLon: params.longitude, - }, - options - ); - - const stores = (body.data?.storeList || []).map((store) => resolveOliveyoungStoreStock(store)); - const inStockCount = stores.filter((store) => store.stockStatus === 'in_stock').length; - const notSoldCount = stores.filter((store) => store.stockStatus === 'not_sold').length; + const body = await requestOliveyoung( + OLIVEYOUNG_API.STOCK_STORES_PATH, + { + productId: params.productId, + lat: params.latitude, + lon: params.longitude, + pageIdx: params.pageIdx, + searchWords: params.searchWords, + mapLat: params.latitude, + mapLon: params.longitude, + }, + options + ); - const result = { - totalCount: body.data?.totalCount || 0, - inStockCount, - outOfStockCount: stores.length - inStockCount - notSoldCount, - notSoldCount, - stores, - }; - writeStaleResult(oliveyoungStockStoresCache, cacheKey, result, cloneOliveyoungStockStoresResult); - return result; - } catch (error) { - const staleResult = readStaleResult(oliveyoungStockStoresCache, cacheKey, cloneOliveyoungStockStoresResult); - if (staleResult) { - return staleResult; - } - throw error; + if (!Array.isArray(body.data?.storeList)) { + throw new Error('올리브영 매장 재고 응답에 storeList가 없습니다.'); } + const stores = body.data.storeList.map((store) => resolveOliveyoungStoreStock(store)); + const inStockCount = stores.filter((store) => store.stockStatus === 'in_stock').length; + const notSoldCount = stores.filter((store) => store.stockStatus === 'not_sold').length; + + const result = { + totalCount: body.data?.totalCount || 0, + inStockCount, + outOfStockCount: stores.length - inStockCount - notSoldCount, + notSoldCount, + stores, + }; + return result; } function sortOliveyoungProducts(products: OliveyoungProduct[]): OliveyoungProduct[] { @@ -353,7 +340,11 @@ export async function enrichOliveyoungProductsWithNearbyStoreInventory( } const inStock = storeInventory.inStockCount > 0; - const stockStatus: OliveyoungProduct['stockStatus'] = inStock ? 'in_stock' : 'out_of_stock'; + const stockStatus: OliveyoungProduct['stockStatus'] = inStock + ? 'in_stock' + : storeInventory.stores.length > 0 && storeInventory.notSoldCount === storeInventory.stores.length + ? 'not_sold' + : 'out_of_stock'; const stockSource: OliveyoungProduct['stockSource'] = 'nearby_store'; return { @@ -388,5 +379,4 @@ export function __testOnlyClearOliveyoungCaches(): void { oliveyoungProductIdCache.clear(); oliveyoungProductSearchCache.clear(); oliveyoungStoreSearchCache.clear(); - oliveyoungStockStoresCache.clear(); } diff --git a/src/services/oliveyoung/tools/checkInventory.ts b/src/services/oliveyoung/tools/checkInventory.ts index f0597dde..b8ccff3d 100644 --- a/src/services/oliveyoung/tools/checkInventory.ts +++ b/src/services/oliveyoung/tools/checkInventory.ts @@ -90,7 +90,8 @@ async function checkInventory(args: CheckInventoryArgs, transport: OliveyoungReq } ); - const inStockProducts = enrichedInventory.products.filter((product) => product.inStock); + const checkedProducts = enrichedInventory.products.filter((product) => product.stockSource === 'nearby_store'); + const inStockProducts = checkedProducts.filter((product) => product.stockStatus === 'in_stock'); const result = { keyword, @@ -116,7 +117,8 @@ async function checkInventory(args: CheckInventoryArgs, transport: OliveyoungReq stockCheckedCount: enrichedInventory.checkedCount, stockUncheckedCount: Math.max(0, productResult.products.length - enrichedInventory.checkedCount), inStockCount: inStockProducts.length, - outOfStockCount: enrichedInventory.products.length - inStockProducts.length, + outOfStockCount: checkedProducts.filter((product) => product.stockStatus === 'out_of_stock').length, + notSoldCount: checkedProducts.filter((product) => product.stockStatus === 'not_sold').length, products: enrichedInventory.products, }, }; diff --git a/src/services/oliveyoung/types.ts b/src/services/oliveyoung/types.ts index 95cb0cb9..9e13ff50 100644 --- a/src/services/oliveyoung/types.ts +++ b/src/services/oliveyoung/types.ts @@ -22,7 +22,7 @@ export interface OliveyoungProduct { o2oStockFlag: boolean; o2oRemainQuantity: number; inStock: boolean; - stockStatus: 'in_stock' | 'out_of_stock'; + stockStatus: 'in_stock' | 'out_of_stock' | 'not_sold' | 'unknown'; stockSource?: 'global_search' | 'nearby_store'; storeInventory?: OliveyoungProductStoreInventory; } diff --git a/src/services/opinet/client.ts b/src/services/opinet/client.ts index cbb82593..8fd4a6d6 100644 --- a/src/services/opinet/client.ts +++ b/src/services/opinet/client.ts @@ -60,7 +60,10 @@ function requireApiKey(apiKey: string | undefined): string { return resolved; } -export function normalizeFuelCode(value: string | undefined, fallback: OpinetFuelCode = 'B027'): OpinetFuelCode { +export function normalizeFuelCode( + value: string | undefined, + fallback: OpinetFuelCode = 'B027', +): OpinetFuelCode { const normalized = (value || fallback).trim().toUpperCase(); if (!FUEL_CODES.has(normalized)) { throw new Error('fuelCode는 B027, D047, B034, C004, K015 중 하나여야 합니다.'); @@ -117,7 +120,11 @@ function unwrapOil(payload: OpinetEnvelope): Record[] { return asArray(payload.RESULT?.OIL ?? payload.OIL); } -async function fetchOpinetJson(path: string, params: Record, options: OpinetClientOptions) { +async function fetchOpinetJson( + path: string, + params: Record, + options: OpinetClientOptions, +) { const url = new URL(`${OPINET_API_BASE_URL}/${path}`); for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, value); @@ -134,13 +141,21 @@ async function fetchOpinetJson(path: string, params: Record, opt try { parsed = JSON.parse(bodyText) as OpinetEnvelope; } catch { - parsed = {}; + if (!response.ok) + throw new Error( + `opinet API 호출 실패: HTTP ${response.status} ${bodyText || `HTTP ${response.status}`}`, + ); + throw new Error('opinet API 응답이 올바른 JSON이 아닙니다.'); } if (!response.ok) { throw new Error(`오피넷 API 호출 실패: HTTP ${response.status} ${bodyText}`); } + const oil = parsed?.RESULT?.OIL ?? parsed?.OIL; + if (!parsed || oil === undefined || oil === null || typeof oil !== 'object') + throw new Error('오피넷 API 응답이 올바르지 않습니다.'); + return parsed; } diff --git a/src/services/places/client.ts b/src/services/places/client.ts index 85221355..d87cf310 100644 --- a/src/services/places/client.ts +++ b/src/services/places/client.ts @@ -178,15 +178,22 @@ export async function searchNaverLocalPlaces( try { parsed = JSON.parse(bodyText) as NaverLocalResponse; } catch { - parsed = {}; + if (!response.ok) + throw new Error( + `places API 호출 실패: HTTP ${response.status} ${bodyText || `HTTP ${response.status}`}`, + ); + throw new Error('places API 응답이 올바른 JSON이 아닙니다.'); } if (!response.ok) { - const message = parsed.errorMessage || bodyText || `HTTP ${response.status}`; + const message = parsed?.errorMessage || bodyText; throw new Error(`네이버 지역 검색 실패: ${message}`); } - const places = (parsed.items ?? []).map(normalizePlace); + if (!parsed || parsed.errorMessage || !Array.isArray(parsed.items)) + throw new Error('네이버 지역 검색 응답이 올바르지 않습니다.'); + + const places = parsed.items.map(normalizePlace); return { provider: 'naverLocal', searchMode: 'keyword', diff --git a/src/services/seveneleven/inventory.ts b/src/services/seveneleven/inventory.ts index 8d5e687e..f7071511 100644 --- a/src/services/seveneleven/inventory.ts +++ b/src/services/seveneleven/inventory.ts @@ -1,3 +1,5 @@ +import { parseInventoryQuantity } from '../inventoryQuantity.js'; +import { ServiceError } from '../../core/errors.js'; import { hasConvenienceRelay, requestConvenienceRelay } from '../../utils/convenienceTransport.js'; /** * 세븐일레븐 재고 조회 클라이언트 @@ -73,8 +75,8 @@ function normalizeStockStore(raw: StockApiStoreRaw): SevenElevenStockStore { address: '', latitude: 0, longitude: 0, - stockQuantity: toNullableNumber(raw.stock) ?? -1, - isSoldOut: toNullableNumber(raw.stock) === 0, + stockQuantity: parseInventoryQuantity(raw.stock) ?? -1, + isSoldOut: parseInventoryQuantity(raw.stock) === 0, distanceM: null, }; } @@ -237,7 +239,7 @@ function filterStoresByKeyword return tokens.every((token) => combined.includes(token)); }); - return tokenMatched.length > 0 ? tokenMatched : stores; + return tokenMatched; } async function tryStockApi( @@ -286,6 +288,7 @@ async function tryStockApi( error: null, }; } catch (error) { + if (error instanceof ServiceError) throw error; return { stores: null, error: normalizeStockError(error), @@ -314,7 +317,7 @@ export async function checkSevenElevenInventory( // 3) 재고 API 시도 (실패 시 graceful fallback) let stockStores: SevenElevenStockStore[] | null = null; let stockError: SevenElevenStockError | null = null; - if (firstProduct && firstProduct.itemCode.length > 0) { + if (matchedStores.length > 0 && firstProduct && firstProduct.itemCode.length > 0) { try { const stockProduct = await fetchSevenElevenStockProductMeta(firstProduct.itemCode, options); if (stockProduct) { @@ -323,6 +326,7 @@ export async function checkSevenElevenInventory( stockError = stockAttempt.error; } } catch (error) { + if (error instanceof ServiceError) throw error; stockError = normalizeStockError(error); } } diff --git a/src/services/seveneleven/productKeyword.ts b/src/services/seveneleven/productKeyword.ts index df661631..34f819db 100644 --- a/src/services/seveneleven/productKeyword.ts +++ b/src/services/seveneleven/productKeyword.ts @@ -142,6 +142,7 @@ export async function searchSevenElevenProductsWithVariants( const seenKeys = new Set(); const collectionIds = new Set(); const mergedProducts: SevenElevenProduct[] = []; + let totalCount = 0; for (const candidate of appliedQueries) { const result = await searchSevenElevenProducts( @@ -158,6 +159,7 @@ export async function searchSevenElevenProductsWithVariants( }, ); + totalCount = Math.max(totalCount, result.totalCount); for (const collectionId of result.collectionIds) { collectionIds.add(collectionId); } @@ -184,12 +186,11 @@ export async function searchSevenElevenProductsWithVariants( return 0; }); - const start = Math.max(page - 1, 0) * size; - const pagedProducts = rankedProducts.slice(start, start + size); + const pagedProducts = rankedProducts.slice(0, size); return { query, - totalCount: rankedProducts.length, + totalCount: Math.max(totalCount, rankedProducts.length), products: pagedProducts, collectionIds: [...collectionIds], appliedQueries, diff --git a/src/services/seveneleven/tools/searchProducts.ts b/src/services/seveneleven/tools/searchProducts.ts index 806aaa57..fef67903 100644 --- a/src/services/seveneleven/tools/searchProducts.ts +++ b/src/services/seveneleven/tools/searchProducts.ts @@ -1,3 +1,4 @@ +import { ServiceError } from '../../../core/errors.js'; import type { ConvenienceTransportOptions } from '../../../utils/convenienceTransport.js'; /** * 세븐일레븐 상품 검색 도구 @@ -51,6 +52,7 @@ async function searchProducts(args: SearchProductsArgs, zyteApiKey?: string, tra products: result.products, }); } catch (error) { + if (error instanceof ServiceError) throw error; return buildTextResponse({ query, page, diff --git a/src/utils/convenienceTransport.ts b/src/utils/convenienceTransport.ts index 192d8240..da8b5468 100644 --- a/src/utils/convenienceTransport.ts +++ b/src/utils/convenienceTransport.ts @@ -3,6 +3,7 @@ import { ServiceError } from '../core/errors.js'; import { diagnosticHeaders } from './diagnostics.js'; import { isValidDtryxRelayUrl } from '../services/dtryx/transport.js'; import { createTimeoutController } from './http.js'; +import { parseRelayQuota } from './relayQuota.js'; export interface ConvenienceTransportOptions { convenienceRelayUrl?: string; convenienceRelayToken?: string; @@ -83,6 +84,7 @@ export async function requestConvenienceRelay( status === 429 || status === 503 || status === 504 ? status : 502, status === 429 || status >= 500, status, + parseRelayQuota(status, response.headers), ); } const data: unknown = await response.json(); diff --git a/src/utils/format.ts b/src/utils/format.ts index 3d9a02da..b7df15ae 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -44,3 +44,11 @@ export function toYyyymmdd(value: Date = new Date()): string { const day = byType.day; return `${year}${month}${day}`; } + +/** 누락되거나 잘못된 좌석 수는 확인 불가로 유지합니다. */ +export function toSeatCount(value: unknown): number | null { + if (typeof value !== 'number' && typeof value !== 'string') return null; + if (typeof value === 'string' && !/^\d+$/.test(value.trim())) return null; + const number = Number(value); + return Number.isSafeInteger(number) && number >= 0 ? number : null; +} diff --git a/tests/api/cgv-handlers.test.ts b/tests/api/cgv-handlers.test.ts index 2b47efa2..a96b2297 100644 --- a/tests/api/cgv-handlers.test.ts +++ b/tests/api/cgv-handlers.test.ts @@ -128,12 +128,30 @@ describe('handleCgvFindTheaters', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), + JSON.stringify({ + documents: [ + { + place_name: '안산 중앙역', + address_name: '대한민국 경기도 안산시 단원구', + y: 37.3171, + x: 126.8389, + }, + ], + }), ), ) .mockResolvedValueOnce( new Response( - JSON.stringify({ documents: [{ place_name: 'CGV 안산', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), + JSON.stringify({ + documents: [ + { + place_name: 'CGV 안산', + address_name: '대한민국 경기도 안산시 단원구 고잔동 535', + y: 37.3172, + x: 126.839, + }, + ], + }), ), ); @@ -170,11 +188,7 @@ describe('handleCgvFindTheaters', () => { const ctx = createMockContext({ playDate: '20260304', lat: 'abc', lng: 'def' }); await handleCgvFindTheaters(ctx); - const payload = (ctx.json as ReturnType).mock.calls[0][0] as { - data: { filters: { latitude: number | null; longitude: number | null } }; - }; - expect(payload.data.filters.latitude).toBeNull(); - expect(payload.data.filters.longitude).toBeNull(); + expect(ctx.json).toHaveBeenCalledWith(expect.objectContaining({ success: false }), 400); }); it('유효한 lat/lng 값은 숫자로 파싱한다', async () => { @@ -197,7 +211,16 @@ describe('handleCgvFindTheaters', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), + JSON.stringify({ + documents: [ + { + place_name: '안산 중앙역', + address_name: '대한민국 경기도 안산시 단원구 고잔동 535', + y: 37.3172, + x: 126.839, + }, + ], + }), ), ); @@ -342,12 +365,30 @@ describe('handleCgvSearchMovies', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), + JSON.stringify({ + documents: [ + { + place_name: '안산 중앙역', + address_name: '대한민국 경기도 안산시 단원구', + y: 37.3171, + x: 126.8389, + }, + ], + }), ), ) .mockResolvedValueOnce( new Response( - JSON.stringify({ documents: [{ place_name: 'CGV 안산', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), + JSON.stringify({ + documents: [ + { + place_name: 'CGV 안산', + address_name: '대한민국 경기도 안산시 단원구 고잔동 535', + y: 37.3172, + x: 126.839, + }, + ], + }), ), ) .mockResolvedValueOnce( @@ -393,7 +434,15 @@ describe('handleCgvSearchMovies', () => { const ctx = createMockContext({ playDate: '20260315', keyword: '안산 중앙역' }); await handleCgvSearchMovies(ctx); - expect(ctx.json).toHaveBeenCalledWith(expect.objectContaining({ success: false, error: expect.objectContaining({ message: expect.stringContaining('위치를 좌표로 변환하지 못했습니다') }) }), 500); + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + message: expect.stringContaining('위치를 좌표로 변환하지 못했습니다'), + }), + }), + 500, + ); }); it('CGV 영화 조회 중 비 Error 예외는 기본 메시지로 처리한다', async () => { @@ -618,8 +667,8 @@ describe('handleCgvGetTimetable', () => { const payload = (ctx.json as ReturnType).mock.calls[0][0] as { data: { filterRelaxed: boolean; timetable: Array<{ movieCode: string }> }; }; - expect(payload.data.filterRelaxed).toBe(true); - expect(payload.data.timetable[0].movieCode).toBe('M2'); + expect(payload.data.filterRelaxed).toBe(false); + expect(payload.data.timetable).toEqual([]); }); it('theaterCode/movieCode가 없으면 null 필터를 반환한다', async () => { @@ -692,12 +741,30 @@ describe('handleCgvGetTimetable', () => { ) .mockResolvedValueOnce( new Response( - JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), + JSON.stringify({ + documents: [ + { + place_name: '안산 중앙역', + address_name: '대한민국 경기도 안산시 단원구', + y: 37.3171, + x: 126.8389, + }, + ], + }), ), ) .mockResolvedValueOnce( new Response( - JSON.stringify({ documents: [{ place_name: 'CGV 안산', address_name: '대한민국 경기도 안산시 단원구 고잔동 535', y: 37.3172, x: 126.839 }] }), + JSON.stringify({ + documents: [ + { + place_name: 'CGV 안산', + address_name: '대한민국 경기도 안산시 단원구 고잔동 535', + y: 37.3172, + x: 126.839, + }, + ], + }), ), ) .mockResolvedValueOnce( @@ -756,7 +823,15 @@ describe('handleCgvGetTimetable', () => { const ctx = createMockContext({ playDate: '20260315', keyword: '안산 중앙역' }); await handleCgvGetTimetable(ctx); - expect(ctx.json).toHaveBeenCalledWith(expect.objectContaining({ success: false, error: expect.objectContaining({ message: expect.stringContaining('위치를 좌표로 변환하지 못했습니다') }) }), 500); + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + message: expect.stringContaining('위치를 좌표로 변환하지 못했습니다'), + }), + }), + 500, + ); }); it('CGV 시간표 조회 중 비 Error 예외는 기본 메시지로 처리한다', async () => { @@ -795,9 +870,17 @@ describe('handleCgvGetTimetable', () => { ); }); }); -it.each([handleCgvSearchMovies, handleCgvGetTimetable])('위치가 유효해도 공식 극장이 없으면 목록을 비운다', async (handler) => { - mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ statusCode: 0, data: [] }))); - const ctx = createMockContext({ lat: '35.115', lng: '129.041', playDate: '20261005' }); - await handler(ctx); - expect(ctx.json).toHaveBeenCalledWith(expect.objectContaining({ success: true, data: expect.objectContaining({ resolvedTheater: null }) })); -}); +it.each([handleCgvSearchMovies, handleCgvGetTimetable])( + '위치가 유효해도 공식 극장이 없으면 목록을 비운다', + async (handler) => { + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ statusCode: 0, data: [] }))); + const ctx = createMockContext({ lat: '35.115', lng: '129.041', playDate: '20261005' }); + await handler(ctx); + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ resolvedTheater: null }), + }), + ); + }, +); diff --git a/tests/api/cu-handlers.test.ts b/tests/api/cu-handlers.test.ts index 80b86ab0..23b670fb 100644 --- a/tests/api/cu-handlers.test.ts +++ b/tests/api/cu-handlers.test.ts @@ -287,6 +287,7 @@ describe('handleCuCheckInventory', () => { totalCount: 0, stockItemCode: '8801', stockItemName: '감자칩', + selectionReason: 'first_candidate', stores: [], }, inventory: expect.objectContaining({ totalCount: 1 }), diff --git a/tests/api/emart24-handlers.test.ts b/tests/api/emart24-handlers.test.ts index 69fa7d44..ecc8aabd 100644 --- a/tests/api/emart24-handlers.test.ts +++ b/tests/api/emart24-handlers.test.ts @@ -285,7 +285,7 @@ describe('handleEmart24CheckInventory', () => { }; expect(payload.data.stores).toHaveLength(2); expect(payload.data.stores[1].bizNo).toBe('99999'); - expect(payload.data.stores[1].bizQty).toBe(0); + expect(payload.data.stores[1].bizQty).toBeNull(); expect(payload.data.stores[1].storeName).toBe(''); }); diff --git a/tests/api/handlers-check-inventory.test.ts b/tests/api/handlers-check-inventory.test.ts index 12021da9..82c5f6f4 100644 --- a/tests/api/handlers-check-inventory.test.ts +++ b/tests/api/handlers-check-inventory.test.ts @@ -102,7 +102,7 @@ describe('handleCheckInventory', () => { it('위치 파라미터를 처리한다', async () => { mockFetch.mockImplementation(async (url: string) => { if (url.includes('selOnlStck')) { - return new Response(JSON.stringify({ success: false })); + return new Response(JSON.stringify({ success: true, data: { stck: 0 } })); } if (url.includes('/ms/msg/selStr')) { return new Response(JSON.stringify({ data: [] })); diff --git a/tests/api/megabox-handlers.test.ts b/tests/api/megabox-handlers.test.ts index 3cac2135..fb39b1c6 100644 --- a/tests/api/megabox-handlers.test.ts +++ b/tests/api/megabox-handlers.test.ts @@ -41,10 +41,12 @@ describe('handleMegaboxFindNearbyTheaters', () => { it('메가박스 주변 지점을 반환한다', async () => { mockFetch .mockResolvedValueOnce( - new Response(JSON.stringify({ areaBrchList: [{ brchNo: '1372', brchNm: '강남' }] })) + new Response(JSON.stringify({ areaBrchList: [{ brchNo: '1372', brchNm: '강남' }] })), ) .mockResolvedValueOnce( - new Response('
도로명주소
서울 강남구 강남대로
지도') + new Response( + '
도로명주소
서울 강남구 강남대로
지도', + ), ); const ctx = createMockContext({ lat: '37.5', lng: '127.0', areaCode: '11' }); @@ -54,7 +56,7 @@ describe('handleMegaboxFindNearbyTheaters', () => { expect.objectContaining({ success: true, data: expect.objectContaining({ theaters: expect.any(Array) }), - }) + }), ); }); @@ -69,7 +71,7 @@ describe('handleMegaboxFindNearbyTheaters', () => { success: false, error: { code: 'MEGABOX_THEATER_SEARCH_FAILED', message: 'megabox fail' }, }), - 500 + 500, ); }); @@ -82,9 +84,12 @@ describe('handleMegaboxFindNearbyTheaters', () => { expect(ctx.json).toHaveBeenCalledWith( expect.objectContaining({ success: false, - error: { code: 'MEGABOX_THEATER_SEARCH_FAILED', message: '알 수 없는 오류가 발생했습니다.' }, + error: { + code: 'MEGABOX_THEATER_SEARCH_FAILED', + message: '알 수 없는 오류가 발생했습니다.', + }, }), - 500 + 500, ); }); @@ -98,13 +103,13 @@ describe('handleMegaboxFindNearbyTheaters', () => { { brchNo: '2', brchNm: 'B' }, { brchNo: '3', brchNm: 'C' }, ], - }) - ) + }), + ), ) .mockRejectedValueOnce(new Error('failed')) .mockResolvedValueOnce(new Response('
주소
좌표없음
')) .mockResolvedValueOnce( - new Response('
도로명주소
서울
지도') + new Response('
도로명주소
서울
지도'), ); const ctx = createMockContext({ lat: '37.5', lng: '127.0', areaCode: '11' }); @@ -116,7 +121,7 @@ describe('handleMegaboxFindNearbyTheaters', () => { data: expect.objectContaining({ theaters: [expect.objectContaining({ theaterId: '3' })], }), - }) + }), ); }); @@ -129,14 +134,14 @@ describe('handleMegaboxFindNearbyTheaters', () => { { brchNo: '1', brchNm: 'A' }, { brchNo: '2', brchNm: 'B' }, ], - }) - ) + }), + ), ) .mockResolvedValueOnce( - new Response('
도로명주소
서울
지도') + new Response('
도로명주소
서울
지도'), ) .mockResolvedValueOnce( - new Response('
도로명주소
서울
지도') + new Response('
도로명주소
서울
지도'), ); const ctx = createMockContext({ lat: '37.5', lng: '127.0', areaCode: '11' }); @@ -151,7 +156,7 @@ describe('handleMegaboxFindNearbyTheaters', () => { expect.objectContaining({ theaterId: '1' }), ], }), - }) + }), ); }); @@ -160,7 +165,16 @@ describe('handleMegaboxFindNearbyTheaters', () => { mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), + JSON.stringify({ + documents: [ + { + place_name: '안산 중앙역', + address_name: '대한민국 경기도 안산시 단원구', + y: 37.3171, + x: 126.8389, + }, + ], + }), ), ) .mockResolvedValueOnce( @@ -171,7 +185,9 @@ describe('handleMegaboxFindNearbyTheaters', () => { ), ) .mockResolvedValueOnce( - new Response('
도로명주소
경기 안산시
지도'), + new Response( + '
도로명주소
경기 안산시
지도', + ), ); const ctx = createMockContext({ keyword: '안산 중앙역', limit: '1' }); @@ -189,27 +205,24 @@ describe('handleMegaboxFindNearbyTheaters', () => { }); it('잘못된 좌표 문자열은 무시하고 처리한다', async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - areaBrchList: [{ brchNo: '1372', brchNm: '강남' }], - }), - ), - ).mockResolvedValueOnce( - new Response('
도로명주소
서울 강남구
지도'), - ); + mockFetch + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + areaBrchList: [{ brchNo: '1372', brchNm: '강남' }], + }), + ), + ) + .mockResolvedValueOnce( + new Response( + '
도로명주소
서울 강남구
지도', + ), + ); const ctx = createMockContext({ lat: 'not-a-number', lng: '127.0', areaCode: '11' }); await handleMegaboxFindNearbyTheaters(ctx); - expect(ctx.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: true, - data: expect.objectContaining({ - latitude: 37.5665, - }), - }), - ); + expect(ctx.json).toHaveBeenCalledWith(expect.objectContaining({ success: false }), 400); }); }); @@ -233,8 +246,8 @@ describe('handleMegaboxListNowShowing', () => { totSeatCnt: 100, }, ], - }) - ) + }), + ), ); const ctx = createMockContext({ playDate: '20260304', theaterId: '1372' }); @@ -247,7 +260,7 @@ describe('handleMegaboxListNowShowing', () => { movies: expect.any(Array), showtimes: expect.any(Array), }), - }) + }), ); }); @@ -262,7 +275,7 @@ describe('handleMegaboxListNowShowing', () => { success: false, error: { code: 'MEGABOX_MOVIE_LIST_FAILED', message: 'movie list fail' }, }), - 500 + 500, ); }); @@ -277,7 +290,7 @@ describe('handleMegaboxListNowShowing', () => { success: false, error: { code: 'MEGABOX_MOVIE_LIST_FAILED', message: '알 수 없는 오류가 발생했습니다.' }, }), - 500 + 500, ); }); @@ -286,7 +299,16 @@ describe('handleMegaboxListNowShowing', () => { mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), + JSON.stringify({ + documents: [ + { + place_name: '안산 중앙역', + address_name: '대한민국 경기도 안산시 단원구', + y: 37.3171, + x: 126.8389, + }, + ], + }), ), ) .mockResolvedValueOnce( @@ -297,7 +319,9 @@ describe('handleMegaboxListNowShowing', () => { ), ) .mockResolvedValueOnce( - new Response('
도로명주소
경기 안산시
지도'), + new Response( + '
도로명주소
경기 안산시
지도', + ), ) .mockResolvedValueOnce( new Response( @@ -354,8 +378,8 @@ describe('handleMegaboxGetRemainingSeats', () => { totSeatCnt: 100, }, ], - }) - ) + }), + ), ); const ctx = createMockContext({ playDate: '20260304', theaterId: '1372' }); @@ -365,7 +389,7 @@ describe('handleMegaboxGetRemainingSeats', () => { expect.objectContaining({ success: true, data: expect.objectContaining({ seats: expect.any(Array) }), - }) + }), ); }); @@ -397,8 +421,8 @@ describe('handleMegaboxGetRemainingSeats', () => { totSeatCnt: 100, }, ], - }) - ) + }), + ), ); const ctx = createMockContext({ playDate: '20260304', movieId: 'M1' }); @@ -413,7 +437,7 @@ describe('handleMegaboxGetRemainingSeats', () => { expect.objectContaining({ theaterName: '코엑스' }), ], }), - }) + }), ); }); @@ -445,8 +469,8 @@ describe('handleMegaboxGetRemainingSeats', () => { totSeatCnt: 100, }, ], - }) - ) + }), + ), ); const ctx = createMockContext({ playDate: '20260304', movieId: 'M1' }); @@ -461,7 +485,7 @@ describe('handleMegaboxGetRemainingSeats', () => { expect.objectContaining({ scheduleId: 'S2' }), ], }), - }) + }), ); }); @@ -476,7 +500,7 @@ describe('handleMegaboxGetRemainingSeats', () => { success: false, error: { code: 'MEGABOX_SEAT_LIST_FAILED', message: 'seat fail' }, }), - 500 + 500, ); }); @@ -491,7 +515,7 @@ describe('handleMegaboxGetRemainingSeats', () => { success: false, error: { code: 'MEGABOX_SEAT_LIST_FAILED', message: '알 수 없는 오류가 발생했습니다.' }, }), - 500 + 500, ); }); @@ -500,7 +524,16 @@ describe('handleMegaboxGetRemainingSeats', () => { mockFetch .mockResolvedValueOnce( new Response( - JSON.stringify({ documents: [{ place_name: '안산 중앙역', address_name: '대한민국 경기도 안산시 단원구', y: 37.3171, x: 126.8389 }] }), + JSON.stringify({ + documents: [ + { + place_name: '안산 중앙역', + address_name: '대한민국 경기도 안산시 단원구', + y: 37.3171, + x: 126.8389, + }, + ], + }), ), ) .mockResolvedValueOnce( @@ -511,7 +544,9 @@ describe('handleMegaboxGetRemainingSeats', () => { ), ) .mockResolvedValueOnce( - new Response('
도로명주소
경기 안산시
지도'), + new Response( + '
도로명주소
경기 안산시
지도', + ), ) .mockResolvedValueOnce( new Response( diff --git a/tests/api/quality-oliveyoung-counters.test.ts b/tests/api/quality-oliveyoung-counters.test.ts new file mode 100644 index 00000000..88437a72 --- /dev/null +++ b/tests/api/quality-oliveyoung-counters.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; +import { handleOliveyoungCheckInventory } from '../../src/api/handlers.js'; +import { enrichOliveyoungProductsWithNearbyStoreInventory, fetchOliveyoungProducts, fetchOliveyoungStores } from '../../src/services/oliveyoung/client.js'; + +vi.mock('../../src/services/oliveyoung/client.js', () => ({ + fetchOliveyoungProducts: vi.fn(), fetchOliveyoungStores: vi.fn(), enrichOliveyoungProductsWithNearbyStoreInventory: vi.fn(), +})); +afterEach(() => vi.resetAllMocks()); + +describe('REST 지역 재고 집계', () => { + it.each([ + ['global_search', 'in_stock', 0, 0, 0, 0], + ['nearby_store', 'in_stock', 1, 1, 0, 0], + ['nearby_store', 'out_of_stock', 1, 0, 1, 0], + ['nearby_store', 'not_sold', 1, 0, 0, 1], + ['global_search', 'unknown', 0, 0, 0, 0], + ] as const)('%s %s를 지역 확인 집계에 정확히 반영한다', async (stockSource, stockStatus, checked, inStock, outOfStock, notSold) => { + const product = { goodsNumber: 'fixture', goodsName: '핸드크림', priceToPay: 1, originalPrice: 1, discountRate: 0, o2oStockFlag: true, o2oRemainQuantity: 1, inStock: stockStatus === 'in_stock', stockSource, stockStatus }; + vi.mocked(fetchOliveyoungStores).mockResolvedValue({ totalCount: 0, stores: [] }); + vi.mocked(fetchOliveyoungProducts).mockResolvedValue({ totalCount: 1, products: [product], nextPage: false }); + vi.mocked(enrichOliveyoungProductsWithNearbyStoreInventory).mockResolvedValue({ products: [product], checkedCount: checked }); + const app = new Hono(); app.get('/inventory', handleOliveyoungCheckInventory); + const response = await app.request('/inventory?keyword=핸드크림'); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ data: { inventory: { stockCheckedCount: checked, stockUncheckedCount: 1 - checked, inStockCount: inStock, outOfStockCount: outOfStock, notSoldCount: notSold } } }); + }); +}); diff --git a/tests/app/app-api-daiso.test.ts b/tests/app/app-api-daiso.test.ts index 2b25e619..0b299f5b 100644 --- a/tests/app/app-api-daiso.test.ts +++ b/tests/app/app-api-daiso.test.ts @@ -110,10 +110,10 @@ describe('GET /api/daiso/stores', () => { describe('GET /api/daiso/inventory', () => { it('재고 정보를 반환한다', async () => { - // 온라인 재고 응답 - mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ success: false }))); - // 매장 재고 응답 - mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ success: false }))); + // 확인된 실제 온라인 재고 0개 + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ success: true, data: { stck: 0 } }))); + // 정상 빈 매장 검색 + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ success: true, data: [] }))); // 상품 메타데이터 응답 mockFetch.mockResolvedValueOnce( new Response( @@ -153,7 +153,7 @@ describe('GET /api/daiso/inventory', () => { it('역명 키워드가 비면 붙여쓴 변형으로 재시도한다', async () => { mockFetch.mockImplementation(async (url: string, init?: RequestInit) => { if (url.includes('selOnlStck')) { - return new Response(JSON.stringify({ success: false })); + return new Response(JSON.stringify({ success: true, data: { stck: 0 } })); } if (url.includes('FindStoreGoods')) { diff --git a/tests/app/app-api-opinet.test.ts b/tests/app/app-api-opinet.test.ts index d2d4f74b..b1c189c0 100644 --- a/tests/app/app-api-opinet.test.ts +++ b/tests/app/app-api-opinet.test.ts @@ -99,7 +99,7 @@ describe('GET /api/opinet', () => { }); it('주유소 상세 API는 결과가 없어도 빈 상세 결과를 반환한다', async () => { - mockFetch.mockResolvedValue(new Response(JSON.stringify({ RESULT: {} }))); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ RESULT: { OIL: [] } }))); const res = await app.request('/api/opinet/station?stationId=A0&timeoutMs=1000', undefined, { OPINET_API_KEY: 'key', diff --git a/tests/app/cliInteractive.helpers.test.ts b/tests/app/cliInteractive.helpers.test.ts index 1b206f91..45125643 100644 --- a/tests/app/cliInteractive.helpers.test.ts +++ b/tests/app/cliInteractive.helpers.test.ts @@ -292,7 +292,7 @@ describe('cliInteractiveTestables', () => { createPrompt(['박스', '1']), store, ); - expect(out.join('\n')).toContain('재고 수량: 0'); + expect(out.join('\n')).toContain('재고 수량: 확인 불가'); }); it('runOliveyoungItemSearch의 예외 분기를 처리한다', async () => { diff --git a/tests/app/quality-cli.test.ts b/tests/app/quality-cli.test.ts new file mode 100644 index 00000000..504319f2 --- /dev/null +++ b/tests/app/quality-cli.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { runCli } from '../../src/cli.js'; +import { renderApiEnvelope } from '../../src/cliRenderer.js'; + +describe('CLI 미확인과 지원 종료 표시', () => { + it('지원 종료 응답에 폐기된 명령을 안내하지 않는다', async () => { + const errors: string[] = []; + const exit = await runCli(['get', '/api/lottemart/products', '--keyword', '콜라'], { + fetchImpl: async () => Response.json({ success: false, error: { code: 'SERVICE_RETIRED', message: '롯데마트 서비스 지원이 종료되었습니다.' } }, { status: 410 }), + writeOut: () => {}, writeErr: message => errors.push(message), + getVersion: () => '1.2.9', nowIso: () => '', isInteractiveTerminal: () => false, + }); + expect(exit).toBe(1); + expect(errors.join('\n')).toContain('지원이 종료'); + expect(errors.join('\n')).not.toContain('lottemart-products'); + expect(errors.join('\n')).not.toContain('매장 정보가 필요'); + }); + it.each(['inventory', 'gs25-inventory', 'cu-inventory'])('%s 미확인 수량은 0과 구분해 표시한다', (command) => { + const stores = [{ storeName: '매장', quantity: null, stock: null, realStockQuantity: null }]; + const text = renderApiEnvelope(command, new URL('https://mcp.aka.page/api/test'), { + success: true, data: { onlineStock: null, storeInventory: { stores }, inventory: { stores }, nearbyStores: { stores } }, + }); + expect(text).toContain('확인 불가'); + expect(text).not.toContain('수량 0'); + }); + it('실제 0개와 양수 재고는 그대로 표시한다', () => { + const text = renderApiEnvelope('gs25-inventory', new URL('https://mcp.aka.page/api/test'), { success: true, data: { inventory: { stores: [{ storeName: '품절', realStockQuantity: 0 }, { storeName: '재고', realStockQuantity: 2 }] } } }); + expect(text).toContain('수량 0'); + expect(text).toContain('수량 2'); + }); +}); diff --git a/tests/app/quality-contracts.test.ts b/tests/app/quality-contracts.test.ts new file mode 100644 index 00000000..9d83611c --- /dev/null +++ b/tests/app/quality-contracts.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; +import { createToolOutputSchema } from '../../src/core/outputSchema.js'; +import { createCompareService } from '../../src/services/compare/index.js'; +import { handleCompareProducts } from '../../src/api/compareHandlers.js'; +import { handleGs25CheckInventory } from '../../src/api/gs25Handlers.js'; +import { requestConvenienceRelay } from '../../src/utils/convenienceTransport.js'; +import { compareProducts } from '../../src/services/compare/client.js'; +import { handleCuCheckInventory, handleCuFindStores } from '../../src/api/handlers.js'; +import { handleSevenElevenSearchProducts } from '../../src/api/sevenelevenHandlers.js'; + +const options = { convenienceRelayUrl: 'https://relay.example', convenienceRelayToken: 'fixture' }; +const bindings = { CONVENIENCE_RELAY_URL: options.convenienceRelayUrl, CONVENIENCE_RELAY_TOKEN: options.convenienceRelayToken }; +afterEach(() => vi.unstubAllGlobals()); + +describe('조회 품질 공통 계약', () => { + it('GS25 빈 매장 응답의 재고 합계는 MCP와 같은 미확인 null이다', async () => { + vi.stubGlobal('fetch', async () => Response.json({ stores: [] })); + const app = new Hono<{ Bindings: typeof bindings }>(); app.get('/inventory', handleGs25CheckInventory); + const response = await app.request('/inventory?itemCode=P&lat=37.4981&lng=127.0276', {}, bindings); + expect(await response.json()).toMatchObject({ data: { inventory: { count: 0, totalStockQuantity: null, unknownStockStoreCount: 0 } } }); + }); + it.each(['CU', 'CU stores', 'Seven'] as const)('%s REST도 매분 한도 이유와 재시도 시간을 유지한다', async (service) => { + vi.stubGlobal('fetch', async () => new Response('', { status: 429, headers: { 'Retry-After': '30', 'x-relay-quota-reason': 'minute' } })); + const app = new Hono<{ Bindings: typeof bindings }>(); + app.get('/cu', handleCuCheckInventory); app.get('/seven', handleSevenElevenSearchProducts); + app.get('/cu-stores', handleCuFindStores); + const response = await app.request(service === 'CU' ? '/cu?keyword=콜라&lat=37.5&lng=127' : service === 'CU stores' ? '/cu-stores?lat=37.5&lng=127' : '/seven?query=콜라', {}, bindings); + expect(response.status).toBe(429); + expect(response.headers.get('Retry-After')).toBe('30'); + expect(await response.json()).toMatchObject({ diagnostics: { quotaReason: 'minute', retryAfter: 30 } }); + }); + it.each(['daiso', 'gs25', 'seveneleven', 'emart24'] as const)('비교 %s는 upstream 초과 결과에도 서비스별 한도를 지킨다', async (service) => { + const docs = [1, 2, 3].map(i => ({ field: { itemCode: `g${i}`, itemName: '콜라', itemCd: `s${i}`, itemOnm: '콜라', onlinePrice: 1 } })); + vi.stubGlobal('fetch', async () => Response.json({ + resultSet: { result: [{ totalSize: 3, resultDocuments: [1, 2, 3].map(i => ({ PD_NO: `d${i}`, PDNM: '콜라', PD_PRC: '1' })) }] }, + SearchQueryResult: { Collection: [{ Documentset: { totalCount: 3, Document: docs } }] }, + data: { SearchQueryResult: { Collection: [{ CollectionId: 'goods', Documentset: { totalCount: 3, Document: docs } }] } }, + totalCnt: 3, productList: [1, 2, 3].map(i => ({ pluCd: `e${i}`, goodsNm: '콜라', viewPrice: 1 })), + })); + const result = await compareProducts({ keyword: '콜라', services: [service], limit: 1 }); + expect(result.errors).toEqual([]); + expect(result.results).toHaveLength(1); + }); + it('좌표 전용 극장 검색의 null 검색어와 미확인 온라인 재고를 허용한다', () => { + expect(createToolOutputSchema('megabox_find_nearby_theaters').safeParse({ keyword: null, theaters: [] }).success).toBe(true); + expect(createToolOutputSchema('daiso_check_inventory').safeParse({ onlineStock: null }).success).toBe(true); + }); + it('카탈로그는 개수와 항목 객체를 검증하고 잘못된 구조는 거절한다', () => { + const schema = createToolOutputSchema('seveneleven_get_catalog_snapshot'); + expect(schema.safeParse({ pages: { totalCount: 1, items: [{}] }, issues: { totalCount: 0, items: [] }, exhibitions: { totalCount: 0, items: [] } }).success).toBe(true); + expect(schema.safeParse({ pages: { totalCount: '1', items: {} } }).success).toBe(false); + }); + it('중계 429의 이유와 재시도 시간을 전송 오류에 유지한다', async () => { + vi.stubGlobal('fetch', async () => new Response('', { status: 429, headers: { 'Retry-After': '30', 'x-relay-quota-reason': 'minute' } })); + await expect(requestConvenienceRelay('gs25-stock', {}, options)).rejects.toMatchObject({ status: 429, quotaReason: 'minute', retryAfter: 30 }); + }); + it('실제 GS25 REST handler가 중계 429를 500으로 바꾸지 않는다', async () => { + vi.stubGlobal('fetch', async () => new Response('', { status: 429, headers: { 'Retry-After': '30', 'x-relay-quota-reason': 'minute' } })); + const app = new Hono<{ Bindings: typeof bindings }>(); + app.get('/inventory', handleGs25CheckInventory); + const response = await app.request('/inventory?itemCode=P&lat=37.4981&lng=127.0276', {}, bindings); + expect(response.status).toBe(429); + expect(response.headers.get('Retry-After')).toBe('30'); + expect(await response.json()).toMatchObject({ diagnostics: { quotaReason: 'minute', retryAfter: 30, upstreamStatus: 429 } }); + }); + it.each(['MCP', 'REST'])('가격 비교 %s는 두 편의점 검색에 중계 설정을 전달한다', async (kind) => { + const fetcher = vi.fn(async () => Response.json({ data: { SearchQueryResult: { Collection: [] } }, SearchQueryResult: { Collection: [] } })); + vi.stubGlobal('fetch', fetcher); + if (kind === 'MCP') { + await createCompareService(options).getTools()[0].handler({ keyword: '콜라', services: 'gs25,seveneleven', limit: 1 }); + } else { + const app = new Hono<{ Bindings: typeof bindings }>(); + app.get('/compare', handleCompareProducts); + expect((await app.request('/compare?keyword=콜라&services=gs25,seveneleven&limit=1', {}, bindings)).status).toBe(200); + } + expect(fetcher).toHaveBeenCalledTimes(2); + for (const [url, init] of fetcher.mock.calls) { + expect(String(url)).toMatch(/^https:\/\/relay.example\/v1\/convenience\//); + expect(init?.headers).toMatchObject({ Authorization: 'Bearer fixture' }); + } + }); +}); diff --git a/tests/relay/guard-runtime.test.ts b/tests/relay/guard-runtime.test.ts index 2cff7d04..6c37748f 100644 --- a/tests/relay/guard-runtime.test.ts +++ b/tests/relay/guard-runtime.test.ts @@ -40,9 +40,19 @@ it('소유 마커를 기록하고 부모 단절 시 종료 확인 후 삭제한 it('정상 IPC 종료 및 중복 종료는 한 번만 처리한다', async () => { const f = await fixture(); await runGuard(f.marker, async () => f.server, f.channel, 1, f.snapshot); - f.channel.emit('message', 'noop'); + for (const message of [ + 'noop', + null, + {}, + { type: 'other' }, + { type: 'close' }, + { type: 'close', deadline: 'bad' }, + { type: 'close', deadline: Infinity }, + ]) + f.channel.emit('message', message); f.channel.emit('message', 'close'); f.channel.emit('SIGTERM'); + f.channel.emit('message', { type: 'close', deadline: Date.now() + 5000 }); await vi.waitFor(() => expect(f.channel.exit).toHaveBeenCalledWith(0)); expect(f.server.close).toHaveBeenCalledTimes(1); expect(f.channel.send).toHaveBeenCalledWith({ type: 'closed' }); @@ -75,3 +85,35 @@ it('회수 중 소유권이 사라지면 마커를 보존하고 실패 종료한 await access(f.marker); expect(f.channel.listenerCount('message')).toBe(0); }); +it('IPC 공유 기한 안에 느린 close와 소멸 확인을 마치고 마커를 삭제한다', async () => { + const f = await fixture(); + await runGuard(f.marker, async () => f.server, f.channel, 1, f.snapshot); + vi.useFakeTimers(); + const started = Date.now(); + f.server.close.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 6900))); + f.snapshot.mockImplementation(async () => (Date.now() - started < 5900 ? [f.root] : [])); + const kill = vi.spyOn(process, 'kill').mockReturnValue(true); + f.channel.emit('message', { type: 'close', deadline: started + 8000 }); + await vi.advanceTimersByTimeAsync(7000); + kill.mockRestore(); + vi.useRealTimers(); + await vi.waitFor(() => expect(f.channel.exit).toHaveBeenCalledWith(0)); + await expect(access(f.marker)).rejects.toThrow(); +}); +it('최악 경로에서 프로필 종료가 공유 기한을 넘으면 확인 실패와 마커를 보존한다', async () => { + const f = await fixture(); + await runGuard(f.marker, async () => f.server, f.channel, 1, f.snapshot); + vi.useFakeTimers(); + const started = Date.now(); + f.server.close.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 8900))); + f.snapshot.mockImplementation(async () => (Date.now() - started < 5900 ? [f.root] : [])); + const kill = vi.spyOn(process, 'kill').mockReturnValue(true); + f.channel.emit('message', { type: 'close', deadline: started + 8000 }); + await vi.advanceTimersByTimeAsync(7600); + expect(f.channel.exit).toHaveBeenCalledWith(1); + expect(f.channel.send).not.toHaveBeenCalledWith({ type: 'closed' }); + expect(kill).toHaveBeenCalledExactlyOnceWith(-123, 'SIGKILL'); + kill.mockRestore(); + vi.useRealTimers(); + await access(f.marker); +}); diff --git a/tests/relay/lifecycle.test.ts b/tests/relay/lifecycle.test.ts index c59ed03a..4b006d05 100644 --- a/tests/relay/lifecycle.test.ts +++ b/tests/relay/lifecycle.test.ts @@ -229,7 +229,12 @@ it('자동 교체 중 종료는 준비 중인 브라우저를 회수하고 타 const life = createBrowserLifecycle(f.launch); await life.start(); let finish!: () => void; - f.page.goto.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + f.page.goto.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); await vi.advanceTimersByTimeAsync(30 * 60 * 1000); expect(f.launch).toHaveBeenCalledTimes(2); expect(life.status().state).toBe('starting'); @@ -259,7 +264,12 @@ it('조회 중 RSS 초과는 조회 종료 후 한 번만 새 세션을 준비 const life = createBrowserLifecycle(f.launch); await life.start(); let finish!: (v: unknown) => void; - f.page.evaluate.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + f.page.evaluate.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); await vi.advanceTimersByTimeAsync(29000); const pending = life.run('/p', {}); f.owner.rss.mockResolvedValue(2 ** 30 + 1); @@ -303,7 +313,12 @@ it('자동 준비 중 팝업 회수 실패는 준비 완료로 보고하지 않 const life = createBrowserLifecycle(f.launch); await life.start(); let finish!: () => void; - f.page.waitForURL.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + f.page.waitForURL.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); await vi.advanceTimersByTimeAsync(30 * 60 * 1000); f.context.emit('page', { close: vi.fn().mockRejectedValue(new Error('popup')) }); await vi.advanceTimersByTimeAsync(0); @@ -315,13 +330,62 @@ it('자동 준비 중 팝업 회수 실패는 준비 완료로 보고하지 않 await life.close(); }); it('브라우저 시작/회수 사유를 기록하고 관측기 실패를 격리한다', async () => { - const f = fixture(); const events: string[] = []; - const life = createBrowserLifecycle(f.launch, event => { events.push(event); if(event==='shutdown') throw Error('log'); }); - await life.start(); await life.close(); expect(events).toEqual(['ready','shutdown']); - const second = createBrowserLifecycle(f.launch, async()=>{throw Error('async log')}); - await second.start(); await second.close(); + const f = fixture(); + const events: string[] = []; + const life = createBrowserLifecycle(f.launch, (event) => { + events.push(event); + if (event === 'shutdown') throw Error('log'); + }); + await life.start(); + await life.close(); + expect(events).toEqual(['ready', 'shutdown']); + const second = createBrowserLifecycle(f.launch, async () => { + throw Error('async log'); + }); + await second.start(); + await second.close(); }); -it('브라우저 프로세스 실행 실패도 별도 사건으로 기록한다',async()=>{ - const events:string[]=[];const life=createBrowserLifecycle(async()=>{throw Error('launch')},e=>{events.push(e)}); - await expect(life.start()).rejects.toThrow('launch');expect(events).toContain('launch-failed');await life.close(); +it('브라우저 프로세스 실행 실패도 별도 사건으로 기록한다', async () => { + const events: string[] = []; + const life = createBrowserLifecycle( + async () => { + throw Error('launch'); + }, + (e) => { + events.push(e); + }, + ); + await expect(life.start()).rejects.toThrow('launch'); + expect(events).toContain('launch-failed'); + await life.close(); +}); +it('수명 계층이 종료 시작 때 하나의 기한을 생성한다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + await life.start(); + const now = Date.now(); + await life.close(); + expect(f.owner.close).toHaveBeenCalledWith(now + 10000); +}); +it('소멸 확인 뒤 close 거절은 다음 요청의 새 브라우저 실행을 허용한다', async () => { + const { closeOwnedGroup } = await import('../../scripts/relay/ownership.js'); + const f = fixture(); + f.owner.close.mockImplementation(() => + closeOwnedGroup( + { pid: 123, ppid: 1, pgid: 123, rss: 1, birth: 'start' }, + async () => { + throw Error('Target closed'); + }, + async () => [], + vi.fn(), + ), + ); + const life = createBrowserLifecycle(f.launch); + await life.start(); + f.page.emit('crash'); + await life.run('/p', {}); + expect(f.launch).toHaveBeenCalledTimes(2); + expect(life.status()).toMatchObject({ state: 'ready', pages: 1 }); + await life.close(); }); diff --git a/tests/relay/ownership.test.ts b/tests/relay/ownership.test.ts index 67055dc4..bbc1f146 100644 --- a/tests/relay/ownership.test.ts +++ b/tests/relay/ownership.test.ts @@ -28,10 +28,13 @@ it('원래 그룹의 정체를 확인할 수 없으면 강제 종료하지 않 await expect(closeOwnedGroup(root, async () => {}, snapshot, kill)).rejects.toThrow('identity'); expect(kill).not.toHaveBeenCalled(); }); -it.skipIf(process.platform === 'win32')('실제 POSIX 스냅샷에서 현재 프로세스를 찾는다', async () => { - const { processSnapshot } = await import('../../scripts/relay/ownership.js'); - expect((await processSnapshot()).some((row) => row.pid === process.pid)).toBe(true); -}); +it.skipIf(process.platform === 'win32')( + '실제 POSIX 스냅샷에서 현재 프로세스를 찾는다', + async () => { + const { processSnapshot } = await import('../../scripts/relay/ownership.js'); + expect((await processSnapshot()).some((row) => row.pid === process.pid)).toBe(true); + }, +); it('잘못된 형식과 이미 사라진 그룹을 처리한다', async () => { expect(() => parseProcesses('bad')).toThrow('Invalid'); const close = vi.fn().mockResolvedValue(undefined); @@ -164,3 +167,79 @@ it('강제 종료 후에도 Playwright 프로필 정리 완료를 기다린다', await pending; vi.useRealTimers(); }); +it('프로세스 소멸이 확인되면 Playwright 종료 거절은 복구를 막지 않는다', async () => { + const kill = vi.fn(); + await expect( + closeOwnedGroup( + root, + async () => { + throw Error('Target closed'); + }, + async () => [], + kill, + ), + ).resolves.toBeUndefined(); + expect(kill).not.toHaveBeenCalled(); +}); +it('느린 close와 강제 종료 확인 및 프로필 정리도 한 예산 안에 끝낸다', async () => { + vi.useFakeTimers(); + const started = Date.now(); + const snapshot = vi + .fn() + .mockImplementation(async () => (Date.now() - started < 5900 ? [root] : [])); + const closing = new Promise((resolve) => setTimeout(resolve, 6900)); + const pending = closeOwnedGroup(root, () => closing, snapshot, vi.fn()); + await vi.advanceTimersByTimeAsync(7000); + await expect(pending).resolves.toBeUndefined(); + expect(Date.now() - started).toBe(7000); + vi.useRealTimers(); +}); + +it('공유 종료 기한을 넘긴 프로필 정리는 실패로 남긴다', async () => { + vi.useFakeTimers(); + const started = Date.now(); + const snapshot = vi + .fn() + .mockImplementation(async () => (Date.now() - started < 5900 ? [root] : [])); + const closing = new Promise((resolve) => setTimeout(resolve, 6900)); + const pending = closeOwnedGroup(root, () => closing, snapshot, vi.fn(), started + 6500); + const rejected = expect(pending).rejects.toThrow('watchdog'); + await vi.advanceTimersByTimeAsync(7000); + await rejected; + vi.useRealTimers(); +}); +it('정상 종료 또는 강제 종료 후 소멸이 확인되어도 close 거절을 허용한다', async () => { + for (const snapshots of [ + [[root], []], + [[root], [root], []], + ]) { + const snapshot = vi.fn(); + for (const rows of snapshots) snapshot.mockResolvedValueOnce(rows); + const kill = vi.fn(); + await expect( + closeOwnedGroup( + root, + async () => { + throw Error('Target closed'); + }, + snapshot, + kill, + ), + ).resolves.toBeUndefined(); + expect(kill).toHaveBeenCalledTimes(snapshots.length === 3 ? 1 : 0); + } +}); +it('스냅샷 실패를 소멸로 간주하거나 임의 프로세스를 종료하지 않는다', async () => { + const kill = vi.fn(); + await expect( + closeOwnedGroup( + root, + async () => {}, + async () => { + throw Error('snapshot denied'); + }, + kill, + ), + ).rejects.toThrow('snapshot denied'); + expect(kill).not.toHaveBeenCalled(); +}); diff --git a/tests/relay/supervisor.test.ts b/tests/relay/supervisor.test.ts index fe8f3f24..4def1067 100644 --- a/tests/relay/supervisor.test.ts +++ b/tests/relay/supervisor.test.ts @@ -30,7 +30,7 @@ it('준비된 소유 브라우저에 연결하고 그룹 RSS 및 정상 종료 expect(await owner.rss()).toBe(30); expect(owner.pid).toBe(123); await owner.close(); - expect(child.send).toHaveBeenCalledWith('close'); + expect(child.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'close' })); await owner.close(); expect(child.send).toHaveBeenCalledTimes(1); }); @@ -48,7 +48,7 @@ it('브라우저 연결 실패도 소유 감시 프로세스를 종료한다', a const pending = launchGuardedBrowser('/tmp/test-owner'); child.emit('message', { type: 'ready', endpoint: 'ws://localhost', root }); await expect(pending).rejects.toThrow('connect failed'); - expect(child.send).toHaveBeenCalledWith('close'); + expect(child.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'close' })); }); it('감시 프로세스 종료가 멈추면 확인 실패로 종료한다', async () => { vi.useFakeTimers(); @@ -64,3 +64,12 @@ it('감시 프로세스 종료가 멈추면 확인 실패로 종료한다', asyn await rejected; expect(child.send).not.toHaveBeenCalled(); }); +it('수명 계층의 종료 기한을 IPC에 그대로 전달한다', async () => { + const child = fixture(); + const pending = launchGuardedBrowser('/tmp/test-owner'); + child.emit('message', { type: 'ready', endpoint: 'ws://localhost', root }); + const owner = await pending; + const deadline = Date.now() + 6000; + await owner.close(deadline); + expect(child.send).toHaveBeenCalledWith({ type: 'close', deadline }); +}); diff --git a/tests/services/cgv/cinema-validation.test.ts b/tests/services/cgv/cinema-validation.test.ts new file mode 100644 index 00000000..9399108b --- /dev/null +++ b/tests/services/cgv/cinema-validation.test.ts @@ -0,0 +1,48 @@ +import { expect, test, vi } from 'vitest'; +import { createCgvService } from '../../../src/services/cgv/index.js'; +import { createMegaboxService } from '../../../src/services/megabox/index.js'; +import { createLotteCinemaService } from '../../../src/services/lottecinema/index.js'; +import * as cgv from '../../../src/api/cgvHandlers.js'; +import * as mega from '../../../src/api/megaboxHandlers.js'; +import * as lotte from '../../../src/api/lottecinemaHandlers.js'; + +vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('offline fetch should not run'); + }), +); +const invalid = [ + { playDate: '20260230' }, + { playDate: 'garbage' }, + { limit: -1 }, + { limit: 1.5 }, + { latitude: 91, longitude: 127 }, + { latitude: 37 }, + { latitude: 37, longitude: 181 }, +]; +for (const factory of [createCgvService, createMegaboxService, createLotteCinemaService]) { + for (const tool of factory().getTools()) { + test.each(invalid)(`${tool.name} rejects %j`, async (args) => { + await expect(tool.handler(args)).rejects.toThrow(/입력|날짜|limit|좌표/); + }); + } +} +for (const handler of [...Object.values(cgv), ...Object.values(mega), ...Object.values(lotte)]) { + test.each([ + { playDate: '20260230' }, + { limit: '-1' }, + { limit: '2junk' }, + { limit: '' }, + { lat: 'NaN', lng: '127' }, + { lat: '91', lng: '127' }, + { lat: '37' }, + ])(`${handler.name} rejects %j`, async (args) => { + const result = await handler({ + req: { query: (key: string) => args[key as keyof typeof args] }, + env: {}, + json: (body: unknown, status: number) => ({ body, status }), + } as never); + expect((result as unknown as { status: number }).status).toBe(400); + }); +} diff --git a/tests/services/cgv/client.test.ts b/tests/services/cgv/client.test.ts index eb2a6c6e..fc2745d5 100644 --- a/tests/services/cgv/client.test.ts +++ b/tests/services/cgv/client.test.ts @@ -53,7 +53,9 @@ describe('fetchCgvTheaters', () => { mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); await expect(fetchCgvTheaters({ zyteApiKey: 'test-key' })).rejects.toThrow('CGV'); expect(mockFetch).toHaveBeenCalledTimes(1); - expect(mockFetch.mock.calls.some(([url]) => new URL(String(url)).hostname === 'api.zyte.com')).toBe(false); + expect( + mockFetch.mock.calls.some(([url]) => new URL(String(url)).hostname === 'api.zyte.com'), + ).toBe(false); }); it('HTTP 에러를 처리한다', async () => { @@ -343,8 +345,8 @@ describe('fetchCgvTimetable', () => { expect(result).toHaveLength(1); expect(result[0].startTime).toBe(''); expect(result[0].endTime).toBe(''); - expect(result[0].totalSeats).toBe(0); - expect(result[0].remainingSeats).toBe(0); + expect(result[0].totalSeats).toBeNull(); + expect(result[0].remainingSeats).toBeNull(); }); it('응답 필드 일부가 비어 있어도 기본값으로 정규화한다', async () => { @@ -408,8 +410,8 @@ describe('fetchCgvTimetable', () => { }); expect(result).toHaveLength(1); - expect(result[0].totalSeats).toBe(0); - expect(result[0].remainingSeats).toBe(0); + expect(result[0].totalSeats).toBeNull(); + expect(result[0].remainingSeats).toBeNull(); }); it('movieCode 조회에서 필수 필드가 없는 항목은 제외한다', async () => { @@ -527,7 +529,7 @@ describe('fetchCgvTimetable', () => { expect(result).toHaveLength(1); expect(result[0].scheduleId).toBe('202603040056'); expect(result[0].movieName).toBe(''); - expect(result[0].remainingSeats).toBe(0); + expect(result[0].remainingSeats).toBeNull(); }); it('theaterCode가 있고 사이트 시간표가 비면 같은 극장에서 fallback 탐색한다', async () => { diff --git a/tests/services/cgv/quality-regressions.test.ts b/tests/services/cgv/quality-regressions.test.ts new file mode 100644 index 00000000..817a485f --- /dev/null +++ b/tests/services/cgv/quality-regressions.test.ts @@ -0,0 +1,329 @@ +import { createSearchMoviesTool } from '../../../src/services/cgv/tools/searchMovies.js'; +import { createListNowShowingTool } from '../../../src/services/megabox/tools/listNowShowing.js'; +import { + handleMegaboxGetRemainingSeats, + handleMegaboxListNowShowing, +} from '../../../src/api/megaboxHandlers.js'; +import assert from 'node:assert/strict'; +import { test, afterEach } from 'vitest'; +import { fetchCgvTimetable } from '../../../src/services/cgv/client.ts'; +import { createGetTimetableTool } from '../../../src/services/cgv/tools/getTimetable.ts'; +import { handleCgvGetTimetable } from '../../../src/api/cgvHandlers.ts'; +import { createGetRemainingSeatsTool } from '../../../src/services/megabox/tools/getRemainingSeats.ts'; +import { fetchMegaboxBookingList } from '../../../src/services/megabox/client.ts'; +import { fetchLotteCinemaNowShowing } from '../../../src/services/lottecinema/client.ts'; +import { fetchDtryxTimetable } from '../../../src/services/dtryx/client.ts'; +import { searchNaverLocalPlaces } from '../../../src/services/places/client.ts'; +import { fetchOpinetAveragePrices } from '../../../src/services/opinet/client.ts'; + +const originalFetch = globalThis.fetch; +const json = (body: unknown) => new Response(JSON.stringify(body)); +const parseTool = (response: { content: Array<{ type: string; text?: string }> }) => + JSON.parse(response.content[0].text!); +afterEach(() => { + globalThis.fetch = originalFetch; +}); +test('preserves numeric zero seats', async () => { + // Every global fetch is replaced; no production request can escape. + globalThis.fetch = async () => + json({ + data: [ + { + siteNo: '0056', + siteNm: 'fixture theater', + movNo: 'OTHER', + movNm: 'Other movie', + scnYmd: '20261005', + scnSseq: '1', + frSeatCnt: 0, + frtmpSeatCnt: 17, + stcnt: 100, + }, + ], + }); + assert.equal( + (await fetchCgvTimetable({ theaterCode: '0056', playDate: '20261005' }))[0].remainingSeats, + 0, + ); +}); +test('CGV strict movie filter', async () => { + globalThis.fetch = async () => + json({ data: [{ siteNo: '0056', movNo: 'OTHER', scnYmd: '20261005' }] }); + + const args = { theaterCode: '0056', playDate: '20261005', movieCode: 'REQUESTED' }; + const mcp = parseTool(await createGetTimetableTool().handler(args)); + const rest = await handleCgvGetTimetable({ + req: { query: (key: keyof typeof args) => args[key] }, + env: {}, + json: (body: unknown, status?: number) => ({ body, status }), + } as never); + assert.equal(mcp.count, 0); + assert.equal( + ( + rest as unknown as { + body: { success: boolean; data: { filterRelaxed: boolean; timetable: unknown[] } }; + } + ).body.success, + true, + ); + assert.equal( + ( + rest as unknown as { + body: { success: boolean; data: { filterRelaxed: boolean; timetable: unknown[] } }; + } + ).body.data.filterRelaxed, + false, + ); + assert.equal( + ( + rest as unknown as { + body: { success: boolean; data: { filterRelaxed: boolean; timetable: unknown[] } }; + } + ).body.data.timetable.length, + 0, + ); +}); +test('missing and malformed seats remain unknown', async () => { + globalThis.fetch = async () => + json({ + movieFormList: [ + { + playSchdlNo: '1', + movieNo: 'M', + brchNo: 'T', + totSeatCnt: 100, + restSeatCnt: 'unknown', + }, + ], + }); + assert.equal( + (await fetchMegaboxBookingList({ playDate: '20261005' })).showtimes[0].remainingSeats, + null, + ); + const queue: unknown[] = [ + { + IsOK: true, + Cinemas: { + Cinemas: { + Items: [ + { CinemaID: 'T', CinemaNameKR: 'Fixture', DivisionCode: '1', DetailDivisionCode: '1' }, + ], + }, + }, + Movies: { Movies: { Items: [{ RepresentationMovieCode: 'M', MovieNameKR: 'Movie' }] } }, + }, + { + IsOK: true, + PlaySeqs: { + Items: [ + { + CinemaID: 'T', + RepresentationMovieCode: 'M', + ScreenID: '1', + PlaySequence: '1', + TotalSeatCount: 100, + }, + ], + }, + }, + ]; + globalThis.fetch = async () => json(queue.shift()); + const lotte = ( + await fetchLotteCinemaNowShowing({ theaterId: 'T', movieId: 'M', playDate: '20261005' }) + ).showtimes[0]; + assert.equal(lotte.remainingSeats, null); + assert.equal(lotte.bookedSeats, null); + globalThis.fetch = async () => + json({ + RetCode: 'success', + Recordset: [{ CinemaCd: 'C', MovieCd: 'M', ScreenCd: '1', TotalSeatCnt: 100 }], + }); + const dtryx = ( + await fetchDtryxTimetable({ brandCode: 'etc', cinemaCode: 'C', playDate: '20261005' }) + )[0]; + assert.equal(dtryx.remainingSeats, null); + assert.equal(dtryx.bookedSeats, null); +}); +test('HTML upstream is an error', async () => { + const offlineHtmlFetch: typeof fetch = async () => new Response('maintenance'); + await assert.rejects( + searchNaverLocalPlaces({ + naverClientId: 'offline', + naverClientSecret: 'offline', + location: 'Fixture', + fetchImpl: offlineHtmlFetch, + }), + ); + await assert.rejects( + fetchOpinetAveragePrices({ apiKey: 'offline', fetchImpl: offlineHtmlFetch }), + ); +}); +test('CGV rejects invalid input', async () => { + globalThis.fetch = async () => + json({ + data: [1, 2, 3].map((n) => ({ + siteNo: 'T', + movNo: 'M', + scnYmd: '20261005', + scnSseq: String(n), + scnsrtTm: `1${n}00`, + })), + }); + await assert.rejects( + createGetTimetableTool().handler({ theaterCode: 'T', playDate: 'garbage', limit: -1 }), + ); +}); +test('Megabox unresolved location cannot broaden', async () => { + const bookingBodies: string[] = []; + globalThis.fetch = async (url, options) => { + if (String(url).includes('selectBokdList')) { + bookingBodies.push(String(options?.body)); + return json({ + areaBrchList: [{ brchNo: 'UNRELATED', brchNm: 'Fixture theater' }], + movieFormList: [{ playSchdlNo: '1', movieNo: 'M', brchNo: 'UNRELATED', restSeatCnt: 50 }], + }); + } + if (String(url).includes('infoPage')) return new Response('No coordinates'); + throw new Error(`Unexpected offline request: ${String(url)}`); + }; + await assert.rejects( + createGetRemainingSeatsTool().handler({ + latitude: 35.1796, + longitude: 129.0756, + areaCode: '26', + playDate: '20261005', + }), + ); + assert.equal(bookingBodies.length, 1); +}); + +test('lifestyle malformed envelopes fail while valid empty arrays succeed', async () => { + for (const payload of [null, {}, { errorMessage: 'maintenance' }, { items: {} }]) { + await assert.rejects( + searchNaverLocalPlaces({ + naverClientId: 'offline', + naverClientSecret: 'offline', + keyword: 'Fixture', + fetchImpl: async () => json(payload), + }), + ); + } + const emptyPlaces = await searchNaverLocalPlaces({ + naverClientId: 'offline', + naverClientSecret: 'offline', + keyword: 'Fixture', + fetchImpl: async () => json({ items: [], total: 0 }), + }); + assert.equal(emptyPlaces.count, 0); + for (const payload of [null, {}, { RESULT: {} }, { RESULT: { OIL: null } }, { OIL: 'error' }]) { + await assert.rejects( + fetchOpinetAveragePrices({ apiKey: 'offline', fetchImpl: async () => json(payload) }), + ); + } + const emptyOil = await fetchOpinetAveragePrices({ + apiKey: 'offline', + fetchImpl: async () => json({ OIL: [] }), + }); + assert.equal(emptyOil.count, 0); +}); + +for (const call of [ + (args: Record) => createListNowShowingTool().handler(args), + async (args: Record) => { + for (const handler of [handleMegaboxGetRemainingSeats, handleMegaboxListNowShowing]) { + const result = await handler({ + req: { + query: (key: string) => + ({ + lat: args.latitude, + lng: args.longitude, + areaCode: args.areaCode, + playDate: args.playDate, + })[key as 'lat']?.toString(), + }, + env: {}, + json: (body: unknown, status: number) => ({ body, status }), + } as never); + assert.equal((result as unknown as { status: number }).status, 500); + } + throw new Error('resolved failure'); + }, +]) { + test('Megabox movie and REST resolution failures never request other theaters', async () => { + let bookingCalls = 0; + globalThis.fetch = async (url) => { + if (String(url).includes('selectBokdList')) { + bookingCalls += 1; + return json({ areaBrchList: [{ brchNo: 'OTHER', brchNm: 'Fixture' }] }); + } + return new Response('no coordinates'); + }; + await assert.rejects( + call({ latitude: 35.1796, longitude: 129.0756, areaCode: '26', playDate: '20261005' }), + ); + assert.ok(bookingCalls <= 2); + }); +} + +test('upstream HTTP errors retain status and raw message', async () => { + for (const body of ['', JSON.stringify({})]) { + const fetchImpl = async () => new Response(body, { status: 503 }); + await assert.rejects(fetchOpinetAveragePrices({ apiKey: 'offline', fetchImpl }), /HTTP 503/); + await assert.rejects( + searchNaverLocalPlaces({ + naverClientId: 'offline', + naverClientSecret: 'offline', + keyword: 'Fixture', + fetchImpl, + }), + ); + } +}); + +test('CGV unresolved coordinates keep an empty requested scope', async () => { + globalThis.fetch = async () => json({ data: [] }); + const args = { latitude: 37.5, longitude: 127, playDate: '20261005' }; + const result = parseTool(await createGetTimetableTool().handler(args)); + assert.equal(result.count, 0); + assert.equal(parseTool(await createSearchMoviesTool().handler(args)).count, 0); +}); + +test('location helpers still reject coordinates outside Korea directly', async () => { + const { resolveCgvLocation } = await import('../../../src/services/cgv/location.js'); + const { resolveMegaboxLocation } = await import('../../../src/services/megabox/location.js'); + await assert.rejects(resolveCgvLocation({ latitude: 0, longitude: 0 })); + await assert.rejects(resolveMegaboxLocation({ latitude: 0, longitude: 0 })); +}); + +test('CGV nearby lookup preserves empty and equal-distance candidates', async () => { + const { fetchCgvNearbyTheaters } = await import('../../../src/services/cgv/location.js'); + globalThis.fetch = async (url) => + String(url).includes('kakao') + ? json({ + documents: [ + { place_name: 'CGV A', x: '127', y: '37.5' }, + { place_name: 'CGV B', x: '127', y: '37.5' }, + { x: '127', y: '37.5' }, + ], + }) + : json({ + data: [ + { + siteList: [ + { siteNo: 'A', siteNm: 'A' }, + { siteNo: 'B', siteNm: 'B' }, + ], + }, + ], + }); + assert.equal((await fetchCgvNearbyTheaters({ playDate: '20261005' })).count, 0); + assert.equal( + ( + await fetchCgvNearbyTheaters( + { playDate: '20261005', latitude: 37.5, longitude: 127 }, + { kakaoRestApiKey: 'offline' }, + ) + ).count, + 2, + ); +}); diff --git a/tests/services/cgv/seat-count.test.ts b/tests/services/cgv/seat-count.test.ts new file mode 100644 index 00000000..462f7b0c --- /dev/null +++ b/tests/services/cgv/seat-count.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from 'vitest'; +import { toSeatCount, toNumber } from '../../../src/utils/format.js'; + +test.each([0, '0', ' 0 ', 17, '17'])('preserves valid seat count %j', (value) => { + expect(toSeatCount(value)).toBe(Number(value)); +}); +test.each([null, undefined, '', ' ', 'unknown', '17seats', true, {}, -1, 1.5, NaN, Infinity])( + 'rejects unavailable seat count %j', + (value) => { + expect(toSeatCount(value)).toBeNull(); + }, +); + +test('retains non-seat numeric conversion behavior', () => { + expect(toNumber(12)).toBe(12); + expect(toNumber('bad')).toBe(0); +}); diff --git a/tests/services/cu/client.test.ts b/tests/services/cu/client.test.ts index d210885f..98b4346b 100644 --- a/tests/services/cu/client.test.ts +++ b/tests/services/cu/client.test.ts @@ -12,6 +12,14 @@ import { const mockFetch = vi.fn(); +it('상품 가격의 숫자가 아닌 문자열은 유효 가격으로 해석하지 않는다', async () => { + mockFetch.mockResolvedValueOnce(Response.json({})).mockResolvedValueOnce(Response.json({ + data: { stockResult: { result: { total_count: 1, rows: [{ fields: { item_cd: 'P', item_nm: '콜라', hyun_maega: 'invalid-price' } }] } } }, + })); + const result = await fetchCuStock({ keyword: '콜라', limit: 1, offset: 0, searchSort: 'recom' }); + expect(result.items[0].price).toBe(0); +}); + it('CU 조회 실패 응답을 빈 재고로 처리하지 않는다', async () => { mockFetch.mockResolvedValueOnce(new Response('{}')).mockResolvedValueOnce(new Response( JSON.stringify({ resp_cd: '3000', resp_msg: '재고조회 조회 실패' }), @@ -314,7 +322,7 @@ describe('fetchCuStores', () => { expect(result.stores[0].storeCode).toBe(''); expect(result.stores[0].latitude).toBe(0); expect(result.stores[0].longitude).toBe(0); - expect(result.stores[0].stock).toBe(0); + expect(result.stores[0].stock).toBeNull(); }); }); diff --git a/tests/services/daiso/tools/checkInventory.test.ts b/tests/services/daiso/tools/checkInventory.test.ts index b6f6bcd9..4cbcc690 100644 --- a/tests/services/daiso/tools/checkInventory.test.ts +++ b/tests/services/daiso/tools/checkInventory.test.ts @@ -31,24 +31,22 @@ describe('fetchOnlineStock', () => { expect(stock).toBe(50); }); - it('실패 시 0을 반환한다', async () => { + it('명시적 조회 실패는 오류를 반환한다', async () => { mockFetch.mockResolvedValue( new Response(JSON.stringify({ success: false })) ); - const stock = await fetchOnlineStock('12345'); - - expect(stock).toBe(0); + await expect(fetchOnlineStock('12345')).rejects.toThrow(); }); - it('성공 응답에 재고 데이터가 없으면 0을 반환한다', async () => { + it('성공 응답에 재고 데이터가 없으면 null을 반환한다', async () => { mockFetch.mockResolvedValue( new Response(JSON.stringify({ success: true })) ); const stock = await fetchOnlineStock('12345'); - expect(stock).toBe(0); + expect(stock).toBeNull(); }); }); @@ -117,7 +115,7 @@ describe('fetchStoreInventory', () => { }); }); - it('재고 응답에 없거나 숫자가 아닌 매장은 0으로 처리한다', async () => { + it('재고 응답에 없거나 숫자가 아닌 매장은 null로 처리한다', async () => { mockFetch .mockResolvedValueOnce( new Response(JSON.stringify({ @@ -166,11 +164,11 @@ describe('fetchStoreInventory', () => { const result = await fetchStoreInventory('12345', 37.5, 127.0); - expect(result.stores[0].quantity).toBe(0); - expect(result.stores[1].quantity).toBe(0); + expect(result.stores[0].quantity).toBeNull(); + expect(result.stores[1].quantity).toBeNull(); }); - it('재고 응답 data가 없으면 모든 매장을 0으로 처리한다', async () => { + it('재고 응답 data가 없으면 모든 매장을 null로 처리한다', async () => { mockFetch .mockResolvedValueOnce( new Response(JSON.stringify({ @@ -204,7 +202,7 @@ describe('fetchStoreInventory', () => { const result = await fetchStoreInventory('12345', 37.5, 127.0); - expect(result.stores[0].quantity).toBe(0); + expect(result.stores[0].quantity).toBeNull(); }); it('매장 검색 결과가 비면 붙여쓴 키워드로 재시도한다', async () => { @@ -310,6 +308,7 @@ describe('createCheckInventoryTool', () => { expect(parsed.product).toBeUndefined(); expect(parsed.onlineStock).toBe(7); expect(parsed.storeInventory.totalStores).toBe(0); + expect(parsed.storeInventory.unknownStockCount).toBe(0); }); it('상품 요약 조회 결과가 비어도 재고 결과를 반환한다', async () => { @@ -336,6 +335,7 @@ describe('createCheckInventoryTool', () => { expect(parsed.product).toBeUndefined(); expect(parsed.onlineStock).toBe(7); expect(parsed.storeInventory.totalStores).toBe(0); + expect(parsed.storeInventory.unknownStockCount).toBe(0); }); it('온라인 재고와 매장 재고를 함께 반환한다', async () => { diff --git a/tests/services/daiso/tools/findInventoryByName.test.ts b/tests/services/daiso/tools/findInventoryByName.test.ts index 728f3fad..7bdc8845 100644 --- a/tests/services/daiso/tools/findInventoryByName.test.ts +++ b/tests/services/daiso/tools/findInventoryByName.test.ts @@ -96,6 +96,71 @@ describe('createFindInventoryByNameTool', () => { expect(parsed.nextSteps.storeCodeSource).toContain('storeInventory.stores[].storeCode'); }); + it('온라인 수량 누락은 확인 불가로 안내한다', async () => { + mockFetch + .mockResolvedValueOnce(new Response(JSON.stringify(createMockProductResponse([ + { PD_NO: '1049516', PDNM: '수납박스', PD_PRC: '1000' }, + ], 1)))) + .mockResolvedValueOnce(new Response(createMockStoreHtml())) + .mockResolvedValueOnce(new Response(JSON.stringify({ success: true, data: { pdNo: '1049516' } }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ + data: [ + { + strCd: '11199', + strNm: '강남역점', + strAddr: '서울 강남구', + strTno: '02', + opngTime: '0900', + clsngTime: '2200', + strLttd: 37.5, + strLitd: 127, + km: '0.2km', + parkYn: 'N', + usimYn: 'N', + pkupYn: 'Y', + taxfYn: 'N', + elvtYn: 'Y', + entrRampYn: 'N', + nocashYn: 'N', + }, + ], + }))) + .mockResolvedValueOnce(new Response('sample-token', { headers: { 'X-DM-UID': 'dm-uid-123' } })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + success: true, + data: [{ pdNo: '1049516', strCd: '11199', stck: '2' }], + }))); + + const tool = createFindInventoryByNameTool(); + const result = await tool.handler({ query: '수납박스', storeQuery: '강남역', pageSize: 5 }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.query).toBe('수납박스'); + expect(parsed.summary).toMatchObject({ + headline: expect.stringContaining('수납박스'), + selectedProduct: '수납박스', + storeQuery: '강남역', + displayLocationHint: expect.stringContaining('daiso_get_display_location'), + }); + expect(parsed.location).toMatchObject({ + latitude: 37.4979, + longitude: 127.0276, + source: 'storeQuery', + storeName: '다이소 강남역점', + }); + expect(parsed.selectedProduct.id).toBe('1049516'); + expect(parsed.productCandidates).toHaveLength(1); + expect(parsed.onlineStock).toBeNull(); + expect(parsed.summary.inventorySummary).toContain('확인 불가'); + expect(parsed.storeInventory.stores[0]).toMatchObject({ + storeCode: '11199', + storeName: '강남역점', + quantity: 2, + }); + expect(parsed.nextSteps.displayLocationTool).toBe('daiso_get_display_location'); + expect(parsed.nextSteps.storeCodeSource).toContain('storeInventory.stores[].storeCode'); + }); + it('정확히 일치하는 비품절 상품 후보를 우선 선택한다', async () => { mockFetch .mockResolvedValueOnce(new Response(JSON.stringify(createMockProductResponse([ diff --git a/tests/services/emart24/tools/checkInventory.test.ts b/tests/services/emart24/tools/checkInventory.test.ts index 81cf3f05..d8ed3b48 100644 --- a/tests/services/emart24/tools/checkInventory.test.ts +++ b/tests/services/emart24/tools/checkInventory.test.ts @@ -198,6 +198,6 @@ describe('createCheckInventoryTool', () => { const parsed = JSON.parse(result.content[0].text); expect(parsed.inventory.count).toBe(1); expect(parsed.inventory.stores[0].bizNo).toBe('A'); - expect(parsed.inventory.stores[0].bizQty).toBe(0); + expect(parsed.inventory.stores[0].bizQty).toBeNull(); }); }); diff --git a/tests/services/gs25/client.test.ts b/tests/services/gs25/client.test.ts index 036a8778..1a309551 100644 --- a/tests/services/gs25/client.test.ts +++ b/tests/services/gs25/client.test.ts @@ -170,7 +170,7 @@ describe('fetchGs25Stores', () => { address: '', phone: '', serviceCode: '', - realStockQuantity: 0, + realStockQuantity: null, searchItemSellPrice: null, propertyNames: ['픽업'], }), @@ -302,10 +302,10 @@ describe('유틸 함수', () => { storeName: '', longitude: 0, latitude: 0, - realStockQuantity: 0, - pickupStockQuantity: 0, - deliveryStockQuantity: 0, - isSoldOut: true, + realStockQuantity: null, + pickupStockQuantity: null, + deliveryStockQuantity: null, + isSoldOut: false, searchItemName: '', searchItemSellPrice: 0, properties: [], @@ -399,9 +399,9 @@ describe('유틸 함수', () => { latitude: 37.5, longitude: 127, serviceCode: '01', - realStockQuantity: 0, - pickupStockQuantity: 0, - deliveryStockQuantity: 0, + realStockQuantity: null, + pickupStockQuantity: null, + deliveryStockQuantity: null, isSoldOut: false, searchItemName: '', searchItemSellPrice: null, @@ -421,13 +421,13 @@ describe('유틸 함수', () => { storeCode: '1', storeName: 'B', distanceM: 500, - realStockQuantity: 0, + realStockQuantity: null, }, { storeCode: '2', storeName: 'A', distanceM: 100, - realStockQuantity: 0, + realStockQuantity: null, }, ] as never); @@ -484,7 +484,7 @@ describe('유틸 함수', () => { storeCode: '2', storeName: 'B', distanceM: 500, - realStockQuantity: 0, + realStockQuantity: null, }, ] as never); diff --git a/tests/services/gs25/web-store-migration.test.ts b/tests/services/gs25/web-store-migration.test.ts index 6ea3b5f4..e76288a8 100644 --- a/tests/services/gs25/web-store-migration.test.ts +++ b/tests/services/gs25/web-store-migration.test.ts @@ -10,7 +10,7 @@ it('새 공개 매장 API에서 좌표를 정규화한다', async () => { ]))); vi.stubGlobal('fetch', fetchMock); const result = await fetchGs25WebStores('강남'); - expect(result.stores[0]).toMatchObject({ storeCode: 'VY010', latitude: 37.49, longitude: 127.02 }); + expect(result.stores[0]).toMatchObject({ storeCode: 'VY010', latitude: 37.49, longitude: 127.02, realStockQuantity:null, pickupStockQuantity:null, deliveryStockQuantity:null, isSoldOut:false }); const url = new URL(fetchMock.mock.calls[0][0]); expect(url.origin).toBe('https://www.gsretail.com'); expect(url.pathname).toBe('/api/homepage/brand/storeSearch/selectGs25Stores'); diff --git a/tests/services/inventory-quality.test.ts b/tests/services/inventory-quality.test.ts new file mode 100644 index 00000000..fee93016 --- /dev/null +++ b/tests/services/inventory-quality.test.ts @@ -0,0 +1,174 @@ +import { it, expect, afterEach } from 'vitest'; +import assert from 'node:assert/strict'; +import { fetchOnlineStock, fetchStoreInventory } from '../../src/services/daiso/tools/checkInventory.ts'; +import { fetchCuStores } from '../../src/services/cu/client.ts'; +import { createCheckInventoryTool as createCuInventoryTool } from '../../src/services/cu/tools/checkInventory.ts'; +import { lookupEmart24Inventory } from '../../src/services/emart24/inventoryLookup.ts'; +import { searchEmart24Products } from '../../src/services/emart24/client.ts'; +import { searchSevenElevenProductsWithVariants } from '../../src/services/seveneleven/productKeyword.ts'; +import { checkSevenElevenInventory } from '../../src/services/seveneleven/inventory.ts'; +import { clearSevenElevenReadCache } from '../../src/services/seveneleven/readCache.ts'; +import { normalizeStore } from '../../src/services/gs25/storeUtils.ts'; + + +const originalFetch = globalThis.fetch; +const json = (value: unknown) => new Response(JSON.stringify(value)); +const urlOf = (input: RequestInfo | URL) => input instanceof Request ? input.url : String(input); +const unexpected = (url: string): never => { throw new Error(`Unexpected mocked request: ${url}`); }; +afterEach(() => { globalThis.fetch=originalFetch; clearSevenElevenReadCache(); }); +it('daiso success false is an error', async () => { + globalThis.fetch = async () => json({ success: false, message: 'fixture lookup failure' }); + await expect(fetchOnlineStock('P')).rejects.toThrow(); + globalThis.fetch = async input => { + const url = urlOf(input); + if (url.includes('/auth/request')) return new Response('fixture-token', { headers: { 'x-dm-uid': 'fixture-uid' } }); + if (url.includes('selStrPkupStck')) return json({ success: false, message: 'fixture lookup failure' }); + if (url.includes('/ms/msg/selStr')) return json({ data: [{ strCd: 'S', strNm: 'fixture store' }] }); + return unexpected(url); + }; + + await expect(fetchStoreInventory('P',37,127)).rejects.toThrow(); + + +}); +it('CU prefers exact product candidate', async () => { + let cuSeed = ''; + globalThis.fetch = async (input, init) => { + const url = urlOf(input); + const body = JSON.parse(String(init?.body || '{}')); + if ('prevSearchWord' in body) return json({ data: { stockResult: { result: { rows: [ + { fields: { item_cd: 'WRONG', item_nm: '콜라제로' } }, + { fields: { item_cd: 'RIGHT', item_nm: '콜라' } }, + ] } } } }); + if (body.itemCd) { cuSeed = body.itemCd; return json({ storeList: [] }); } + if (url.includes('display')) return json({}); + // The stock screen warm-up is intentionally allowed without live I/O. + if (init?.method === 'POST' && Object.keys(body).length === 0) return json({}); + return unexpected(url); + }; + const cu = JSON.parse((await createCuInventoryTool().handler({ keyword: '콜라', latitude: 37.5, longitude: 127 })).content[0].text); + assert.equal(cuSeed, 'RIGHT'); + assert.equal(cu.nearbyStores.stockItemCode, 'RIGHT'); + expect(cu.nearbyStores.selectionReason).toBe('exact_match'); + + +}); +it('Emart24 prefers exact product candidate', async () => { + let emartPlu = ''; + globalThis.fetch = async (input, init) => { + const url = urlOf(input); + if (init?.method === 'POST') return json({ productList: [ + { pluCd: 'WRONG', goodsNm: '콜라제로' }, { pluCd: 'RIGHT', goodsNm: '콜라' }, + ] }); + if (url.includes('/stock-search/store')) { emartPlu = new URL(url).searchParams.get('searchPluCode') || ''; return json({ storeGoodsQty: [] }); } + return unexpected(url); + }; + const emartSelection = await lookupEmart24Inventory({ keyword: '콜라', bizNos: ['S'] }); + assert.equal(emartSelection.pluCd, 'RIGHT'); + expect(emartSelection.selectionReason).toBe('exact_match'); + assert.equal(emartPlu, 'RIGHT'); + + +}); +it('Seven respects unmatched requested region', async () => { + clearSevenElevenReadCache(); + globalThis.fetch = async input => { + const url = urlOf(input); + if (url.includes('/search/goods')) return json({ success: true, data: { content: [{ itemCd: 'P', itemOnm: '콜라' }] } }); + if (url.includes('/search/store')) return json({ success: true, data: { SearchQueryResult: { Collection: [{ Documentset: { Document: [ + { storeCd: 'S', storeNm: '서울강남점', address: '서울 강남구', latitude: 37.5, longitude: 127 }, + ] } }] } } }); + if (url.includes('/real-stock/')) return json({ success: true, data: { storeList: [{ storeCd: 'S', stock: 5 }] } }); + if (url.includes('itemCd=P')) return json({ itemCd: 'P', smCd: 'M', stokMngCd: 'M' }); + return unexpected(url); + }; + const sevenLocation = await checkSevenElevenInventory({ productKeyword: '콜라', storeKeyword: '부산 해운대' }); + + + expect(sevenLocation.stores).toEqual([]); + expect(sevenLocation.stockAvailable).toBe(false); +}); +it('Seven preserves upstream page and total', async () => { + clearSevenElevenReadCache(); + let sevenRequestedPage: number | undefined; + globalThis.fetch = async (_input, init) => { + sevenRequestedPage = JSON.parse(String(init?.body)).pageNo; + return json({ success: true, data: { SearchQueryResult: { Collection: [{ Documentset: { + totalCount: 40, + Document: Array.from({ length: 20 }, (_, i) => ({ field: { itemCd: `P${20 + i}`, itemOnm: `콜라${20 + i}` } })), + } }] } } }); + }; + const sevenPage = await searchSevenElevenProductsWithVariants('콜라', { page: 2, size: 20 }); + assert.equal(sevenRequestedPage, 1); + assert.equal(sevenPage.products.length, 20); + assert.equal(sevenPage.totalCount, 40); + + +}); +it('Emart24 absent quantity remains unknown', async () => { + globalThis.fetch = async input => { + const url = urlOf(input); + if (url.includes('/stock-search/store') || url.includes('/stock/store/')) return json({}); + return unexpected(url); + }; + const emartMissing = await lookupEmart24Inventory({ pluCd: 'P', bizNos: ['S'], includeRequestedStoresWithoutQty: true }); + assert.equal(emartMissing.inventory.stores[0].bizQty, null); + assert.equal(emartMissing.inventory.goodsInfo, null); + + +}); +it('CU metadata has unknown stock', async () => { + globalThis.fetch = async () => new Response(`fixture store
fixture address
`); + const cuWeb = await fetchCuStores({ searchWord: 'fixture' }); + assert.equal(cuWeb.stores.length, 1); + assert.equal(cuWeb.stores[0].stock, null); + + +}); +it('GS25 metadata has unknown stock', async () => { + const gs = normalizeStore({ storeCode: 'S', storeName: 'fixture' }); + assert.equal(gs.realStockQuantity, null); + + +}); +it('Emart24 enforces requested page size', async () => { + let requestedPageCnt = ''; + globalThis.fetch = async (_input, init) => { + requestedPageCnt = new URLSearchParams(String(init?.body)).get('pageCnt') || ''; + return json({ totalCnt: 10, productList: Array.from({ length: 10 }, (_, i) => ({ pluCd: `P${i}`, goodsNm: `fixture ${i}` })) }); + }; + const emartSize = await searchEmart24Products({ keyword: 'fixture', pageSize: 3 }); + assert.equal(requestedPageCnt, '3'); + assert.equal(emartSize.products.length, 3); + + +}); +it.each(['5bad', -2, 1.5])('Seven invalid stock %s remains unknown', async (stock) => { + clearSevenElevenReadCache(); + globalThis.fetch = async input => { + const url = urlOf(input); + if (url.includes('/search/goods')) return json({ success: true, data: { content: [{ itemCd: 'P', itemOnm: '콜라' }] } }); + if (url.includes('/search/store')) return json({ success: true, data: { SearchQueryResult: { Collection: [{ Documentset: { Document: [ + { storeCd: 'S', storeNm: '서울강남점', address: '서울 강남구', latitude: 37.5, longitude: 127 }, + ] } }] } } }); + if (url.includes('/real-stock/')) return json({ success: true, data: { storeList: [{ storeCd: 'S', stock }] } }); + if (url.includes('itemCd=P')) return json({ itemCd: 'P', smCd: 'M', stokMngCd: 'M' }); + return unexpected(url); + }; + const sevenLocation = await checkSevenElevenInventory({ productKeyword: '콜라', storeKeyword: '서울 강남' }); + + + expect(sevenLocation.stores[0].stockQuantity).toBe(-1); + expect(sevenLocation.stores[0].isSoldOut).toBe(false); + expect(sevenLocation.stockAvailable).toBe(false); +}); + +it('Daiso store search explicit failure is not empty inventory', async () => { + globalThis.fetch = async () => json({success:false}); + await expect(fetchStoreInventory('P',37,127)).rejects.toThrow('매장 검색'); +}); + +it('Daiso empty successful store search stays empty', async () => { + globalThis.fetch = async () => json({success:true}); + expect(await fetchStoreInventory('P',37,127)).toEqual({stores:[],totalCount:0}); +}); diff --git a/tests/services/inventory-quota.test.ts b/tests/services/inventory-quota.test.ts new file mode 100644 index 00000000..be72005a --- /dev/null +++ b/tests/services/inventory-quota.test.ts @@ -0,0 +1,18 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import * as cu from '../../src/services/cu/client.js'; +import * as seven from '../../src/services/seveneleven/productKeyword.js'; +import { ServiceError } from '../../src/core/errors.js'; +import { createCheckInventoryTool } from '../../src/services/cu/tools/checkInventory.js'; +import { createSearchProductsTool } from '../../src/services/seveneleven/tools/searchProducts.js'; +afterEach(() => vi.restoreAllMocks()); +it('CU store quota propagates to MCP diagnostics', async () => { + const error = new ServiceError('UPSTREAM_RATE_LIMITED', 'quota', 429, true); + vi.spyOn(cu, 'fetchCuStock').mockResolvedValue({ available:true, unavailableReason:null, totalCount:0, items:[], spellModifyYn:'' }); + vi.spyOn(cu, 'fetchCuStores').mockRejectedValue(error); + await expect(createCheckInventoryTool().handler({keyword:'콜라',latitude:37,longitude:127})).rejects.toBe(error); +}); +it('Seven search quota does not become a degraded empty search', async () => { + const error = new ServiceError('UPSTREAM_RATE_LIMITED', 'quota', 429, true); + vi.spyOn(seven, 'searchSevenElevenProductsWithVariants').mockRejectedValue(error); + await expect(createSearchProductsTool().handler({query:'콜라'})).rejects.toBe(error); +}); diff --git a/tests/services/inventory-summary.test.ts b/tests/services/inventory-summary.test.ts new file mode 100644 index 00000000..d31c8a5d --- /dev/null +++ b/tests/services/inventory-summary.test.ts @@ -0,0 +1,34 @@ +import * as daiso from '../../src/services/daiso/client.js'; +import * as emart from '../../src/services/emart24/client.js'; +import { createCheckInventoryTool as createDaisoTool } from '../../src/services/daiso/tools/checkInventory.js'; +import { lookupEmart24Inventory } from '../../src/services/emart24/inventoryLookup.js'; +import { afterEach, expect, it, vi } from 'vitest'; +import * as gs from '../../src/services/gs25/client.js'; +import { createCheckInventoryTool } from '../../src/services/gs25/tools/checkInventory.js'; +import { normalizeStore } from '../../src/services/gs25/storeUtils.js'; +afterEach(() => vi.restoreAllMocks()); +it('all unknown GS stock has a null total and explicit unknown count', async () => { + vi.spyOn(gs,'fetchGs25Stores').mockResolvedValue({totalCount:1,stores:[normalizeStore({storeCode:'S'})]}); + const result = await createCheckInventoryTool().handler({keyword:'콜라',itemCode:'P'}); + const payload = JSON.parse(result.content[0].text); + expect(payload.inventory.totalStockQuantity).toBeNull(); + expect(payload.inventory.unknownStockStoreCount).toBe(1); +}); + +it('Daiso unknown store stock is counted separately', async () => { + vi.spyOn(daiso,'fetchDaisoJson').mockImplementation(async (url) => { + if (String(url).includes('selOnlStck')) return {success:true,data:{}} as never; + if (String(url).includes('/ms/msg/selStr')) return {data:[{strCd:'S',strNm:'매장'}]} as never; + return {} as never; + }); + vi.spyOn(daiso,'fetchDaisoJsonWithAuth').mockResolvedValue({success:true,data:[]} as never); + const result = await createDaisoTool().handler({productId:'P'}); + const payload = JSON.parse(result.content[0].text); + expect(payload.storeInventory).toMatchObject({unknownStockCount:1,inStockCount:0,outOfStockCount:0}); +}); +it('Emart missing store detail does not turn an unknown quantity into zero', async () => { + vi.spyOn(emart,'searchEmart24StockByStores').mockResolvedValue({storeGoodsQty:[{BIZNO:'S'}]} as never); + vi.spyOn(emart,'fetchEmart24StoreDetail').mockRejectedValue(new Error('detail unavailable')); + const result = await lookupEmart24Inventory({pluCd:'P',bizNos:['S']}); + expect(result.inventory.stores[0]).toMatchObject({bizQty:null,storeName:''}); +}); diff --git a/tests/services/inventoryQuantity.test.ts b/tests/services/inventoryQuantity.test.ts new file mode 100644 index 00000000..57e29a73 --- /dev/null +++ b/tests/services/inventoryQuantity.test.ts @@ -0,0 +1,56 @@ +import { expect, it } from 'vitest'; +import { parseInventoryQuantity } from '../../src/services/inventoryQuantity.js'; +import { normalizeStore, sortGs25Stores, extractGs25ProductCandidates } from '../../src/services/gs25/storeUtils.js'; +import { selectCuStockItem, cuStockSelectionReason } from '../../src/services/cu/productSelection.js'; + +it.each([undefined, null, {}, false, '', ' ', '3bad', 'bad', -1, 1.5, NaN, Infinity])('unknown quantity %s does not become zero', (value) => { + expect(parseInventoryQuantity(value)).toBeNull(); +}); +it.each([0, '0', 7, '7'])('valid quantity %s is preserved', (value) => { + expect(parseInventoryQuantity(value)).toBe(Number(value)); +}); +it('missing GS stock does not assert sold out, even when a stale flag says Y', () => { + expect(normalizeStore({isSoldOutYn:'Y'})).toMatchObject({realStockQuantity:null,isSoldOut:false}); + expect(normalizeStore({realStockQuantity:0,isSoldOutYn:'Y'})).toMatchObject({realStockQuantity:0,isSoldOut:true}); +}); +it('CU skips invalid codes and supports absent or broad candidates', () => { + expect(selectCuStockItem([], '콜라')).toBeNull(); + const item = {itemCode:'C', itemName:' 콜 라 '} as never; + expect(selectCuStockItem([{itemCode:' ',itemName:'콜라'} as never,item], '콜라')).toBe(item); + expect(selectCuStockItem([item], '음료')).toBe(item); +}); + +it('CU selection reason distinguishes exact, broad and absent candidates', () => { + const item = {itemCode:'C',itemName:' 콜 라 '} as never; + expect(cuStockSelectionReason(item,'콜라')).toBe('exact_match'); + expect(cuStockSelectionReason(item,'음료')).toBe('first_candidate'); + expect(cuStockSelectionReason(null,'음료')).toBeNull(); +}); + +it.each([42.7, Infinity, false])('GS price normalization remains independent from stock %s', (price) => { + expect(normalizeStore({searchItemSellPrice:price as never}).searchItemSellPrice).toBe(price === 42.7 ? 42 : 0); +}); + +it('GS unknown quantities sort behind known stores and do not add confirmed stock', () => { + const unknown = normalizeStore({storeCode:'U',storeName:'미확인',searchItemName:'콜라'}); + const known = normalizeStore({storeCode:'K',storeName:'확인',realStockQuantity:2,searchItemName:'콜라'}); + expect(sortGs25Stores([unknown,known])[0].storeCode).toBe('K'); + expect(sortGs25Stores([known,unknown])[0].storeCode).toBe('K'); + const candidates = extractGs25ProductCandidates([unknown,unknown,known]); + expect(candidates[0]).toMatchObject({totalStockQuantity:2,inStockStoreCount:1}); +}); +it('GS product candidates distinguish all unknown from confirmed zero and partial sums', () => { + const unknown = normalizeStore({storeCode:'U',searchItemName:'콜라'}); + const zero = normalizeStore({storeCode:'Z',realStockQuantity:0,searchItemName:'콜라'}); + const known = normalizeStore({storeCode:'K',realStockQuantity:3,searchItemName:'콜라'}); + expect(extractGs25ProductCandidates([unknown,unknown])[0]).toMatchObject({totalStockQuantity:null,unknownStockStoreCount:2}); + expect(extractGs25ProductCandidates([zero])[0]).toMatchObject({totalStockQuantity:0,unknownStockStoreCount:0}); + expect(extractGs25ProductCandidates([unknown,known])[0]).toMatchObject({totalStockQuantity:3,unknownStockStoreCount:1}); + expect(extractGs25ProductCandidates([known,unknown])[0]).toMatchObject({totalStockQuantity:3,unknownStockStoreCount:1}); +}); +it('GS known zero candidates rank ahead of unknown totals in either input order', () => { + const unknown = normalizeStore({searchItemName:'미확인'}); + const zero = normalizeStore({searchItemName:'확인',realStockQuantity:0}); + expect(extractGs25ProductCandidates([unknown,zero])[0].name).toBe('확인'); + expect(extractGs25ProductCandidates([zero,unknown])[0].name).toBe('확인'); +}); diff --git a/tests/services/megabox/client.test.ts b/tests/services/megabox/client.test.ts index c9d067dd..4314c355 100644 --- a/tests/services/megabox/client.test.ts +++ b/tests/services/megabox/client.test.ts @@ -41,11 +41,15 @@ describe('fetchMegaboxBookingList', () => { totSeatCnt: '100', }, ], - }) - ) + }), + ), ); - const result = await fetchMegaboxBookingList({ playDate: '20260304', theaterId: '1372', movieId: 'M1' }); + const result = await fetchMegaboxBookingList({ + playDate: '20260304', + theaterId: '1372', + movieId: 'M1', + }); expect(result.theaters[0]).toEqual({ theaterId: '1372', theaterName: '강남' }); expect(result.movies[0].movieStatus).toBe('상영중'); @@ -68,16 +72,16 @@ describe('fetchMegaboxBookingList', () => { restSeatCnt: 'abc', }, ], - }) - ) + }), + ), ); const result = await fetchMegaboxBookingList({ playDate: '20260304' }); expect(result.showtimes[0].startTime).toBe('9'); expect(result.showtimes[0].endTime).toBe('10'); - expect(result.showtimes[0].totalSeats).toBe(0); - expect(result.showtimes[0].remainingSeats).toBe(0); + expect(result.showtimes[0].totalSeats).toBeNull(); + expect(result.showtimes[0].remainingSeats).toBeNull(); }); it('시간 포맷 분기(콜론 포함/기타)를 처리한다', async () => { @@ -93,8 +97,8 @@ describe('fetchMegaboxBookingList', () => { playEndTime: '123', }, ], - }) - ) + }), + ), ); const result = await fetchMegaboxBookingList({ playDate: '20260304' }); @@ -113,8 +117,8 @@ describe('fetchMegaboxBookingList', () => { brchNo: '1372', }, ], - }) - ) + }), + ), ); const result = await fetchMegaboxBookingList({ playDate: '20260304' }); @@ -126,7 +130,7 @@ describe('fetchMegaboxBookingList', () => { mockFetch.mockResolvedValue(new Response('fail', { status: 500 })); await expect(fetchMegaboxBookingList({ playDate: '20260304' })).rejects.toThrow( - '메가박스 상영 목록 조회 실패: 500' + '메가박스 상영 목록 조회 실패: 500', ); }); @@ -134,7 +138,7 @@ describe('fetchMegaboxBookingList', () => { mockFetch.mockRejectedValue(new DOMException('aborted', 'AbortError')); await expect(fetchMegaboxBookingList({ playDate: '20260304' })).rejects.toThrow( - '메가박스 상영 목록 조회 시간 초과' + '메가박스 상영 목록 조회 시간 초과', ); }); @@ -148,7 +152,7 @@ describe('fetchMegaboxBookingList', () => { mockFetch.mockRejectedValue(new DOMException('aborted', 'AbortError')); await expect(fetchMegaboxBookingList({ playDate: '20260304' })).rejects.toThrow( - '메가박스 상영 목록 조회 시간 초과' + '메가박스 상영 목록 조회 시간 초과', ); }); }); @@ -156,7 +160,9 @@ describe('fetchMegaboxBookingList', () => { describe('fetchMegaboxTheaterInfo', () => { it('도로명주소와 좌표를 파싱한다', async () => { mockFetch.mockResolvedValue( - new Response('
도로명주소
서울 강남구 강남대로
지도') + new Response( + '
도로명주소
서울 강남구 강남대로
지도', + ), ); const result = await fetchMegaboxTheaterInfo('1372'); @@ -167,7 +173,11 @@ describe('fetchMegaboxTheaterInfo', () => { }); it('일반 주소와 mapLat/mapLng 파싱을 지원한다', async () => { - mockFetch.mockResolvedValue(new Response("
주소
서울시 중구
")); + mockFetch.mockResolvedValue( + new Response( + "
주소
서울시 중구
", + ), + ); const result = await fetchMegaboxTheaterInfo('1000'); @@ -189,13 +199,17 @@ describe('fetchMegaboxTheaterInfo', () => { it('HTTP 에러를 처리한다', async () => { mockFetch.mockResolvedValue(new Response('fail', { status: 404 })); - await expect(fetchMegaboxTheaterInfo('1372')).rejects.toThrow('메가박스 지점 정보 조회 실패: 404'); + await expect(fetchMegaboxTheaterInfo('1372')).rejects.toThrow( + '메가박스 지점 정보 조회 실패: 404', + ); }); it('AbortError를 시간 초과 에러로 변환한다', async () => { mockFetch.mockRejectedValue(new DOMException('aborted', 'AbortError')); - await expect(fetchMegaboxTheaterInfo('1372')).rejects.toThrow('메가박스 지점 정보 조회 시간 초과'); + await expect(fetchMegaboxTheaterInfo('1372')).rejects.toThrow( + '메가박스 지점 정보 조회 시간 초과', + ); }); }); diff --git a/tests/services/oliveyoung/client.test.ts b/tests/services/oliveyoung/client.test.ts index b579d5a7..27fceb54 100644 --- a/tests/services/oliveyoung/client.test.ts +++ b/tests/services/oliveyoung/client.test.ts @@ -464,7 +464,7 @@ describe('fetchOliveyoungProducts', () => { const fallback = await fetchOliveyoungProducts(params, { apiKey: 'test-key' }); expect(first.products[0].goodsName).toBe('립밤'); - expect(fallback).toEqual(first); + expect(fallback.products[0]).toEqual(expect.objectContaining({ goodsNumber: first.products[0].goodsNumber, stockStatus: 'unknown', inStock: false })); expect(mockFetch).toHaveBeenCalledTimes(2); }); @@ -928,7 +928,7 @@ describe('enrichOliveyoungProductsWithNearbyStoreInventory', () => { expect(result.products).toEqual(products); }); - it('매장 재고 조회 실패 시 같은 조건의 직전 성공 결과를 재사용한다', async () => { + it('매장 재고 조회 실패 시 과거 수량을 재사용하지 않는다', async () => { const products = [ { goodsNumber: 'A1', @@ -984,8 +984,8 @@ describe('enrichOliveyoungProductsWithNearbyStoreInventory', () => { ); expect(first.checkedCount).toBe(1); - expect(fallback.checkedCount).toBe(1); - expect(fallback.products[0]).toEqual(first.products[0]); + expect(fallback.checkedCount).toBe(0); + expect(fallback.products[0]).toEqual(products[0]); expect(mockFetch).toHaveBeenCalledTimes(3); }); @@ -1067,7 +1067,7 @@ describe('enrichOliveyoungProductsWithNearbyStoreInventory', () => { ); }); - it('stock-stores 응답에 빈 데이터가 오면 주변 매장 재고를 품절로 처리한다', async () => { + it('stock-stores 응답에 빈 데이터가 오면 재고를 미확인으로 유지한다', async () => { mockFetch .mockResolvedValueOnce( createDirectResponse({ @@ -1106,17 +1106,9 @@ describe('enrichOliveyoungProductsWithNearbyStoreInventory', () => { { apiKey: 'test-key' } ); - expect(result.checkedCount).toBe(1); - expect(result.products[0].stockSource).toBe('nearby_store'); - expect(result.products[0].inStock).toBe(false); - expect(result.products[0].stockStatus).toBe('out_of_stock'); - expect(result.products[0].storeInventory).toEqual({ - totalCount: 0, - inStockCount: 0, - outOfStockCount: 0, - notSoldCount: 0, - stores: [], - }); + expect(result.checkedCount).toBe(0); + expect(result.products[0].stockSource).toBe('global_search'); + expect(result.products[0].storeInventory).toBeUndefined(); }); it('stock-stores 항목이 비어 있어도 기본값과 재고 라벨을 계산한다', async () => { diff --git a/tests/services/oliveyoung/quality-regressions.test.ts b/tests/services/oliveyoung/quality-regressions.test.ts new file mode 100644 index 00000000..39143713 --- /dev/null +++ b/tests/services/oliveyoung/quality-regressions.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { + __testOnlyClearOliveyoungCaches, + fetchOliveyoungProducts, + enrichOliveyoungProductsWithNearbyStoreInventory, +} from '../../../src/services/oliveyoung/client.js'; +import { createCheckInventoryTool } from '../../../src/services/oliveyoung/tools/checkInventory.js'; +const fetchMock = vi.fn(); +const product = { + goodsNumber: 'A1', + goodsName: '팩', + priceToPay: 1, + originalPrice: 1, + discountRate: 0, + o2oStockFlag: true, + o2oRemainQuantity: 1, + inStock: true, + stockStatus: 'in_stock' as const, + stockSource: 'global_search' as const, +}; +const params = { latitude: 37, longitude: 127, storeKeyword: '', maxProducts: 1 }; +const search = { keyword: '팩', page: 1, size: 1, sort: '01', includeSoldOut: false }; +const response = (data: unknown) => Response.json({ status: 'SUCCESS', data }); +beforeEach(() => { + __testOnlyClearOliveyoungCaches(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); +it('빈 재고 payload를 확인 완료나 품절로 집계하지 않는다', async () => { + fetchMock + .mockResolvedValueOnce(response({ goodsInfo: { masterGoodsNumber: '8801' } })) + .mockResolvedValueOnce(response({})); + const result = await enrichOliveyoungProductsWithNearbyStoreInventory([product], params); + expect(result.checkedCount).toBe(0); + expect(result.products[0]).toEqual(product); +}); +it('모든 주변 매장이 미판매이면 상품도 미판매로 표시한다', async () => { + fetchMock + .mockResolvedValueOnce(response({ goodsInfo: { masterGoodsNumber: '8801' } })) + .mockResolvedValueOnce( + response({ totalCount: 1, storeList: [{ storeCode: 'S1', salesStoreYn: false }] }), + ); + const result = await enrichOliveyoungProductsWithNearbyStoreInventory([product], params); + expect(result.products[0].stockStatus).toBe('not_sold'); +}); +it('로컬 재고 조회 실패에 29분 전 수량을 재사용하지 않는다', async () => { + vi.useFakeTimers(); + fetchMock + .mockResolvedValueOnce(response({ goodsInfo: { masterGoodsNumber: '8801' } })) + .mockResolvedValueOnce( + response({ + totalCount: 1, + storeList: [{ storeCode: 'S1', salesStoreYn: true, remainQuantity: 5 }], + }), + ); + await enrichOliveyoungProductsWithNearbyStoreInventory([product], params); + vi.setSystemTime(Date.now() + 29 * 60 * 1000); + fetchMock + .mockResolvedValueOnce(response({ goodsInfo: { masterGoodsNumber: '8801' } })) + .mockRejectedValue(new Error('offline')); + const result = await enrichOliveyoungProductsWithNearbyStoreInventory([product], params); + expect(result.checkedCount).toBe(0); + expect(result.products[0].storeInventory).toBeUndefined(); +}); +it('검색 캐시는 상품 정보만 복구하고 과거 재고는 미확인으로 표시한다', async () => { + fetchMock.mockResolvedValueOnce(response({ searchList: [product], totalCount: 1 })); + await fetchOliveyoungProducts(search); + fetchMock.mockRejectedValue(new Error('offline')); + const result = await fetchOliveyoungProducts(search); + expect(result.products[0].stockStatus).toBe('unknown'); + expect(result.products[0].inStock).toBe(false); +}); +it('전국 검색 재고는 로컬 MCP 재고 수량에 포함하지 않는다', async () => { + fetchMock + .mockResolvedValueOnce(response({ storeList: [], totalCount: 0 })) + .mockResolvedValueOnce(response({ searchList: [product], totalCount: 1 })) + .mockRejectedValue(new Error('offline')); + const result = await createCheckInventoryTool().handler({ keyword: '팩' }); + const inventory = JSON.parse(result.content[0].text).inventory; + expect(inventory.stockCheckedCount).toBe(0); + expect(inventory.stockUncheckedCount).toBe(1); + expect(inventory.inStockCount).toBe(0); + expect(inventory.outOfStockCount).toBe(0); +}); +it('릴레이 설정에서 기본 조회 시간을 유지하고 명시 설정도 우선한다', async () => { + fetchMock.mockImplementation(async () => + response({ storeList: [], searchList: [], totalCount: 0 }), + ); + const relay = { relayUrl: 'https://relay.invalid', relayToken: 'unit-test' }; + const tool = createCheckInventoryTool(undefined, relay); + await tool.handler({ keyword: '팩', stockCheckLimit: 0 }); + expect(tool.metadata.inputSchema.timeoutMs.parse(undefined)).toBe(60000); + expect( + createCheckInventoryTool(undefined, { + ...relay, + timeout: 1234, + }).metadata.inputSchema.timeoutMs.parse(undefined), + ).toBe(1234); +}); diff --git a/tests/services/opinet/client.test.ts b/tests/services/opinet/client.test.ts index c957c82b..59abb43b 100644 --- a/tests/services/opinet/client.test.ts +++ b/tests/services/opinet/client.test.ts @@ -119,7 +119,11 @@ describe('opinet client', () => { .mockResolvedValueOnce(new Response(JSON.stringify({ RESULT: { OIL: [] } }))) .mockResolvedValueOnce( new Response( - JSON.stringify({ documents: [{ place_name: '강남역', address_name: '서울 강남역', y: 37.4979, x: 127.0276 }] }), + JSON.stringify({ + documents: [ + { place_name: '강남역', address_name: '서울 강남역', y: 37.4979, x: 127.0276 }, + ], + }), ), ) .mockResolvedValueOnce(new Response(JSON.stringify({ RESULT: { OIL: [] } }))); @@ -191,7 +195,9 @@ describe('opinet client', () => { expect(normalizeOpinetSort('2')).toBe('distance'); expect(() => normalizeFuelCode('BAD')).toThrow('fuelCode'); await expect(fetchOpinetAveragePrices({ apiKey: '' })).rejects.toThrow('OPINET_API_KEY'); - await expect(fetchOpinetStationsAround({ x: Number.NaN, y: 1 }, { apiKey: 'key' })).rejects.toThrow('location'); + await expect( + fetchOpinetStationsAround({ x: Number.NaN, y: 1 }, { apiKey: 'key' }), + ).rejects.toThrow('location'); await expect(fetchOpinetStationDetail('', { apiKey: 'key' })).rejects.toThrow('주유소 ID'); }); @@ -200,7 +206,9 @@ describe('opinet client', () => { .fn() .mockResolvedValue(new Response('not-json', { status: 503 })); - await expect(fetchOpinetAveragePrices({ apiKey: 'key', fetchImpl })).rejects.toThrow('HTTP 503'); + await expect(fetchOpinetAveragePrices({ apiKey: 'key', fetchImpl })).rejects.toThrow( + 'HTTP 503', + ); }); it('빈 응답과 기본값 분기를 안정적으로 처리한다', async () => { @@ -226,16 +234,12 @@ describe('opinet client', () => { }), ), ) - .mockResolvedValueOnce(new Response(JSON.stringify({ RESULT: {} }))); + .mockResolvedValueOnce(new Response(JSON.stringify({ RESULT: { OIL: [] } }))); - await expect(fetchOpinetAveragePrices({ apiKey: 'key', fetchImpl })).resolves.toMatchObject({ - count: 0, - }); - await expect(fetchOpinetLowestStations({}, { apiKey: 'key', fetchImpl })).resolves.toMatchObject({ - fuelCode: 'B027', - areaCode: null, - count: 0, - }); + await expect(fetchOpinetAveragePrices({ apiKey: 'key', fetchImpl })).rejects.toThrow('JSON'); + await expect(fetchOpinetLowestStations({}, { apiKey: 'key', fetchImpl })).rejects.toThrow( + '응답', + ); const around = await fetchOpinetStationsAround( { x: 1, y: 2, radiusMeters: 1, sort: 'price' }, @@ -250,7 +254,9 @@ describe('opinet client', () => { distanceMeters: null, }); - await expect(fetchOpinetStationDetail('A0', { apiKey: 'key', fetchImpl })).resolves.toMatchObject({ + await expect( + fetchOpinetStationDetail('A0', { apiKey: 'key', fetchImpl }), + ).resolves.toMatchObject({ station: null, }); }); diff --git a/tests/services/places/client.test.ts b/tests/services/places/client.test.ts index 4efcc916..a5688fc8 100644 --- a/tests/services/places/client.test.ts +++ b/tests/services/places/client.test.ts @@ -212,10 +212,10 @@ describe('searchNaverLocalPlaces', () => { ); }); - it('items가 없는 성공 응답은 빈 장소 목록으로 처리한다', async () => { + it('빈 items 배열은 정상 빈 결과로 처리한다', async () => { const fetchImpl = vi .fn() - .mockResolvedValue(new Response(JSON.stringify({ total: 0 }))); + .mockResolvedValue(new Response(JSON.stringify({ total: 0, items: [] }))); const result = await searchNaverLocalPlaces({ naverClientId: 'client-id', diff --git a/tests/services/seveneleven/productKeyword.test.ts b/tests/services/seveneleven/productKeyword.test.ts index cf8b78c7..3b4225cd 100644 --- a/tests/services/seveneleven/productKeyword.test.ts +++ b/tests/services/seveneleven/productKeyword.test.ts @@ -218,7 +218,8 @@ describe('searchSevenElevenProductsWithVariants', () => { size: 10, }); - expect(result.totalCount).toBe(1); + expect(result.totalCount).toBe(3); + expect(result.products).toHaveLength(1); expect(result.products[0].itemCode).toBe('111'); });