diff --git a/docs/ADAPTIVE-ROUTING.md b/docs/ADAPTIVE-ROUTING.md new file mode 100644 index 00000000..b01082db --- /dev/null +++ b/docs/ADAPTIVE-ROUTING.md @@ -0,0 +1,44 @@ +# 직접 호출과 중계 경로 자동 선택 + +Cloudflare Worker가 5분마다 조회 종류별 직접 API와 맥 중계 상태를 확인합니다. 사용자 조회는 이 결과로 경로를 고릅니다. 매 조회 전에 연결 시험을 하거나 상태 저장소 응답을 기다리지 않습니다. + +## 경로를 고르는 기준 + +| 상황 | 조회 경로 | +| --- | --- | +| 최근 점검에서 직접 API가 정상 | 직접 호출 | +| 직접 API가 차단되거나 응답 형식이 비정상 | 기존 맥 중계 | +| 서버에 상태가 없거나 30초 상태 캐시가 만료 | 중계로 응답하면서 상태만 백그라운드 갱신 | +| 마지막 점검이 10분을 초과함 | 중계 | +| 직접 조회 중 401·403·429·5xx, 연결·응답 오류 발생 | 직접 경로를 5분 차단하고 남은 시간 안에서 중계로 복구 | +| 직접과 중계가 모두 최근 점검에서 실패 | 연결 장애 503 반환 | +| GS25 재고 조회 | 정상 로그인 세션을 사용하는 기존 맥 중계 유지 | + +상품 검색과 재고 조회의 판정은 따로 저장합니다. CU 3종, GS25 상품 검색, 세븐일레븐 8종, 올리브영 4종, 디트릭스 3종이 직접 점검 대상입니다. 다이소 등 원래 직접 호출하던 서비스에는 이 중계 선택을 추가하지 않습니다. + +올리브영 중계는 브라우저 없이 동작하는 기존 HTTP 방식입니다. 이 기능은 중계 프로그램을 재시작하거나 로그인 세션을 옮기지 않습니다. Google 지오코딩과 Zyte도 다시 활성화하지 않습니다. + +## 정기 점검의 비용과 제한 + +`wrangler.toml`의 Cron `*/5 * * * *`가 실행합니다. 직접 요청은 고정 대표 상품·매장으로 최대 3초, 본문 2 MiB, 재시도 없이 수행합니다. 최대 네 연결만 동시에 사용합니다. 실제 조회와 점검은 같은 URL·헤더·요청 구성·응답 검증을 사용합니다. HTTP 200만 보고 성공으로 판단하지 않습니다. + +하루 상한은 직접 API 5,472회(19종 × 288회)와 중계 헬스 864회(3곳 × 288회)입니다. 중계 헬스는 상품·재고 요청을 하지 않으므로 중계 원장의 일일 한도를 소비하지 않습니다. 사용자 요청에 적용되는 기존 인증과 한도는 그대로 유지합니다. + +## 저장과 오류 복구 + +공유 상태는 `UPSTREAM_ROUTE_HEALTH` 바인딩의 `UpstreamRouteHealth` Durable Object에 저장합니다. 처음 배포할 때 SQLite migration `v2`가 추가됩니다. 개별 Worker의 캐시는 판정의 짧은 사본이며, 공유 저장소를 대신하지 않습니다. + +실제 직접 조회 실패는 해당 Worker에서 즉시 차단하고 공유 저장소에 비동기로 기록합니다. 늦게 도착한 점검 성공이나 실패 기록이 더 최근 실패를 지우지 않도록 순서와 차단 시간을 검증합니다. 기록 전송이 한 번 실패하더라도 차단 시간이 지난 뒤 새 점검이 성공하면 직접 경로로 복귀할 수 있습니다. + +400·404·422와 사용자 취소는 연결 장애로 취급하지 않습니다. 중계 429도 중계 서버 장애로 취급하지 않습니다. 공식 API에는 중계 Bearer 토큰이나 Cloudflare Access 키를 보내지 않습니다. + +## 운영 확인 + +`HEALTH_CHECK_SECRET`과 기존 운영 헬스 인증 방식을 사용합니다. 시크릿을 명령 인수·로그·저장소에 넣지 마세요. + +- `GET /api/health/routes`: 저장된 조회별 판정과 점검 시각을 확인합니다. 직접 API를 시험하지 않습니다. +- `POST /api/health/routes`: 고정된 전체 점검을 실행하고 결과를 반환합니다. 임의 URL이나 상품 입력은 받지 않습니다. + +인증 헤더는 `Authorization: Bearer <운영 시크릿>` 또는 `x-health-check-key`입니다. 새 배포 직후에는 POST로 판정을 채우고, 이후 GET의 `checkedAt`이 Cron 실행으로 갱신되는지 확인합니다. 공유 저장소 바인딩이 없는 기존 설치·로컬 사용은 기존 중계 우선 동작을 유지합니다. + +장애 확인 시 직접 점검의 `reason`, 점검 시각, `failedAt`과 `blockedUntil`, 중계 그룹의 `healthy`를 먼저 확인합니다. 원문 응답·쿠키·인증 코드는 저장하지 않으며 고정된 진단 코드만 남깁니다. diff --git a/docs/superpowers/plans/2026-10-06-adaptive-routing.md b/docs/superpowers/plans/2026-10-06-adaptive-routing.md new file mode 100644 index 00000000..1ea82e35 --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-adaptive-routing.md @@ -0,0 +1,22 @@ +# 주기적 경로 선택 구현 계획 + +**Goal:** 사용자 조회의 시험 호출 지연을 제거하고 검증된 직접 경로만 사용한다. +**Architecture:** Cron probes → DurableObject snapshot → request-local cached decision → direct 또는 relay. 실패는 cooldown과 단일 fallback. +**Tech Stack:** Workers scheduled/waitUntil, SQLite DO, AsyncLocalStorage, 기존fetchJson/ServiceError/Zod/Vitest. + +- [ ] src/utils/routeHealth.ts 및 src/durableObjects/upstreamRouteHealth.ts를 테스트먼저 구현. cold/stale/namespace격리/nohotIO/후속실패/오래된probe/취소/시간예산/400보존. 계약은 아래와 같다. +- [ ] src/utils/directRoutes.ts, directRouteSpecs*.ts 및 src/utils/routeProbes.ts를 테스트먼저 구현. 고정20operation URL/method/payload/스키마/headers; GSstockpinned;19direct+3health 최악제한. 실제Workerprobe증거저장. +- [ ] 기존공통transport3곳에 routeOperation(key,timeout,direct,relay,signal?) 연결하고 모든클라이언트branch가 이 경로를 사용하는지 확인. 새binding없으면기존relay순서 유지. +- [ ] index.ts worker fetch context와scheduled, wrangler v2DO/Cron5분, AppBindings와인증된health route GET/POST 연결. index450줄 유지. +- [ ] npm run check; npm run test:coverage; npm run build; npm audit 및 독립요구사항/품질리뷰. 기능·schema실패가재고0으로반환되지않는회귀포함. +- [ ] PR/CI확인후병합·배포. 기존Macruntime·인증·원장은변경없음. 운영수동warm/probe와실제scheduled관측, REST/MCP/게시CLI 및state cache지연검증. +- [ ] 변경된Worker/CLIpackage를patch릴리스하고공식tarballgitHead/무결성검증. 운영기록/설계제약·실측·복구절차와완료알림. + +## 병렬 모듈 계약 + +routeHealth.ts exports ROUTE_KEYS(oy-find-store,oy-product-search,oy-goods-info,oy-stock-stores,cu-prime,cu-stock,cu-store,gs25-products,gs25-stock,seven-goods,seven-store,seven-popwords,seven-stock-meta,seven-stock,seven-pages,seven-issues,seven-exhibitions,dtryx-movies,dtryx-play-dates,dtryx-timetable), RouteKey, RelayGroup=oliveyoung/convenience/dtryx. +RouteCheck={key,direct:boolean,checkedAt:number,reason?:string}; RelayCheck={group,healthy:boolean,checkedAt,reason?}. RouteSnapshot={routes:Partial>,relays:Partial>}. +withRouteRouting(namespace:DurableObjectNamespace|undefined,waitUntil:((p:Promise)=>void)|undefined,work:()=>T):T. +routeOperation(key:RouteKey,budgetMs:number,direct:(ms:number)=>Promise,relay:(ms:number)=>Promise,signal?:AbortSignal):Promise. +readRouteSnapshot(namespace):Promise; writeRouteChecks(namespace,checks:RouteCheck[],relays:RelayCheck[]):Promise. +DO internal GET/snapshot; POST/checks {checks,relays}; POST/failure {key,at}. metadata-only,no tokens. Coreagent owns core/DO+theirtests; probeagent owns specs/directRoutes/routeProbes+theirtests; root owns existingtransports/index/bindings/wrangler/healthroute/docs/tests integration. No agent deploys/commits/restarts. diff --git a/docs/superpowers/specs/2026-10-06-adaptive-routing.md b/docs/superpowers/specs/2026-10-06-adaptive-routing.md new file mode 100644 index 00000000..64ca2e3a --- /dev/null +++ b/docs/superpowers/specs/2026-10-06-adaptive-routing.md @@ -0,0 +1,15 @@ +# 조회 경로의 주기적 판정 + +사용자가 직접 실패 시에만 중계를 쓰는 구조와 시간 단위 별도 점검을 승인했다. 매 사용자 요청에서 상태를 시험하지 않고, 저장된 판정으로 즉시 경로를 고른다. + +Cloudflare scheduled는5분마다20개 조회 종류의 직접 API와3개 중계health를 점검한다. GS25 stock은 맥의 정상 로그인 세션을 사용하므로 직접 후보에서 제외하고 중계 유지. 점검은 사용자 조회/캐시를 통해 실행하지 않는다. 직접 점검은 같은 URL·헤더·본문·검증으로 수행하고 상품과 상세재고를 별도 판정한다. 고정 대표 상품/매장만 사용하며 최대3초·2MiB·재시도0, 병렬4, 모든 필드 오류는 실패로 본다. HTTP200의 빈 오류 HTML이나 비정상JSON을 성공/재고0으로 취급하지 않는다. 점검의 상한은19직접*288/일과3health*288/일이며 중계health는원장을 차감하지 않는다. + +UpstreamRouteHealth SQLite DurableObject가 공유 판정을 영속화한다. 새 binding UPSTREAM_ROUTE_HEALTH와 migration v2. 상태는 key별 direct/checkedAt/failedAt/blockedUntil, 중계그룹별healthy/checkedAt로 구성한다. 실패의5분 cooldown은 나중에 도착한 오래된 probe 성공이 해제하지 못하게 한다. 스냅샷10분 초과는 직접 성공 근거로 사용하지 않는다. + +Worker request context는 AsyncLocalStorage로 분리. isolate에는 namespace별 짧은 immutable snapshot cache만 둔다. 상태가없음/30초지난경우 사용자요청은 기다리지 않고 relay를 선택하며 waitUntil에서 DO snapshot만 갱신한다. 실시간 upstream health probe는 scheduled/admin만 수행한다. namespace가없는 기존 설치/로컬은 기존 relay 우선 유지. 새context없는 transport도 기존동작. + +알려진 직접 경로를 선택한 뒤 예상치 못한403/401/429/5xx·network·shape 실패 시 직접을 즉시5분 차단하고 원래 총시간예산 안에서 중계로1회 복구한다. 정상적인400/404/422나 사용자취소는 경로장애로 판단하지 않는다. 동일 실패는비동기로DO에 전달한다. 직접/중계 모두 최근점검에서 불가하면503으로명확히실패. quota429는 중계가 죽었다는 의미로 사용하지 않는다. + +공통 transport단계에서만 routing을 연결해REST/MCP/CLI·비교조회에동일하게적용한다. 편의점 requestConvenienceRelay, Dtryx requestDtryxRelay, OY requestOliveyoung의 relaybranch를 감싼다. 직접요청은공통requestDirectRoute를 사용하며 relay헤더/토큰은공식API에전달하지않는다. 기존 인증·quota·캐시와 브라우저없는OY중계 유지, Google/Zyte 재활성 없음. + +API /api/health/routes GET은 기존운영secret으로snapshot조회, POST는명시적수동점검. frontend의 상품조회흐름에는운영진단정보를추가하지않는다. Cron5분 설정/실제Cloudflare직접probe·운영다른서비스·fallback주입·동시coldstart·stale·crossisolate race를검증하고PR/CI/병합/배포/CLI릴리스까지완료한다. diff --git a/package-lock.json b/package-lock.json index 87aa0f97..ab363fbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daiso", - "version": "1.2.15", + "version": "1.2.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daiso", - "version": "1.2.15", + "version": "1.2.16", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index f4befd0d..d9b05564 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "daiso", - "version": "1.2.15", + "version": "1.2.16", "description": "Korean retail and cinema MCP server, CLI, and Codex Skill for Daiso, convenience stores, marts, Olive Young, and theaters", "main": "dist/index.js", "bin": { diff --git a/src/api/response.ts b/src/api/response.ts index f6ca5b3b..1ffab1b4 100644 --- a/src/api/response.ts +++ b/src/api/response.ts @@ -7,6 +7,7 @@ import { ServiceError, toServiceErrorDiagnostics, toStandardErrorDiagnostics } f export interface AppBindings { DAILY_RATE_LIMITER?: DurableObjectNamespace; + UPSTREAM_ROUTE_HEALTH?: DurableObjectNamespace; ZYTE_API_KEY?: string; DTRYX_RELAY_URL?: string; DTRYX_RELAY_TOKEN?: string; diff --git a/src/api/routes/routeHealthRoutes.ts b/src/api/routes/routeHealthRoutes.ts new file mode 100644 index 00000000..6f24055c --- /dev/null +++ b/src/api/routes/routeHealthRoutes.ts @@ -0,0 +1,45 @@ +/** 인증된 운영자만 사전 경로 점검 상태를 읽거나 갱신할 수 있습니다. */ +import type { Hono } from 'hono'; +import { errorResponse, type AppBindings } from '../response.js'; +import { authorizeOperationalRequest } from '../operationalAuth.js'; +import { readRouteSnapshot } from '../../utils/routeHealth.js'; +import { refreshRouteChecks } from '../../utils/routeProbes.js'; + +export function registerRouteHealthRoutes(app: Hono<{ Bindings: AppBindings }>): void { + app.on(['GET', 'POST'], '/api/health/routes', async (c) => { + const authorization = await authorizeOperationalRequest( + c.req.raw.headers, + c.env?.HEALTH_CHECK_SECRET, + ); + if (authorization === 'not-configured') { + return errorResponse( + c, + 'HEALTH_CHECK_SECRET_NOT_CONFIGURED', + 'HEALTH_CHECK_SECRET이 설정되어 있지 않습니다.', + 503, + ); + } + if (authorization !== 'authorized') { + return errorResponse( + c, + 'UNAUTHORIZED_HEALTH_CHECK', + '유효한 헬스 체크 시크릿 키가 필요합니다.', + 401, + ); + } + const namespace = c.env.UPSTREAM_ROUTE_HEALTH; + if (!namespace) + return errorResponse( + c, + 'ROUTE_HEALTH_NOT_CONFIGURED', + '경로 점검 저장소가 설정되어 있지 않습니다.', + 503, + ); + try { + if (c.req.method === 'POST') await refreshRouteChecks(c.env); + return c.json({ success: true, data: await readRouteSnapshot(namespace) }); + } catch { + return errorResponse(c, 'ROUTE_HEALTH_FAILED', '경로 점검 상태를 확인하지 못했습니다.', 503); + } + }); +} diff --git a/src/durableObjects/upstreamRouteHealth.ts b/src/durableObjects/upstreamRouteHealth.ts new file mode 100644 index 00000000..699c2e91 --- /dev/null +++ b/src/durableObjects/upstreamRouteHealth.ts @@ -0,0 +1,92 @@ +/** 내부 바인딩을 통해서만 접근하는 경로별 상태 저장소입니다. */ +import { + ROUTE_COOLDOWN_MS, + isRouteKey, + validRelayCheck, + validRouteCheck, + validTime, + type RelayCheck, + type RouteCheck, + type RouteSnapshot, +} from '../utils/routeHealth.js'; +const STORAGE_KEY = 'snapshot'; +const empty = (): RouteSnapshot => ({ routes: {}, relays: {} }); +export class UpstreamRouteHealth { + constructor(private readonly state: DurableObjectState) {} + async fetch(request: Request): Promise { + const path = new URL(request.url).pathname; + if (request.method === 'GET' && path === '/snapshot') + return Response.json((await this.state.storage.get(STORAGE_KEY)) || empty()); + if (request.method !== 'POST' || !['/checks', '/failure'].includes(path)) + return new Response(null, { status: 404 }); + let input: Record; + try { + const text = await request.text(); + if (text.length > 16384) return new Response(null, { status: 400 }); + const value: unknown = JSON.parse(text); + if (!value || typeof value !== 'object' || Array.isArray(value)) + return new Response(null, { status: 400 }); + input = value as Record; + } catch { + return new Response(null, { status: 400 }); + } + if (path === '/checks') { + if ( + !Array.isArray(input.checks) || + input.checks.length > 20 || + !input.checks.every(validRouteCheck) || + !Array.isArray(input.relays) || + input.relays.length > 3 || + !input.relays.every(validRelayCheck) + ) + return new Response(null, { status: 400 }); + await this.state.storage.transaction(async (storage) => { + const snapshot = (await storage.get(STORAGE_KEY)) || empty(); + for (const row of input.checks as RouteCheck[]) { + const old = snapshot.routes[row.key]; + if (old && (row.checkedAt < old.checkedAt || row.checkedAt <= (old.failedAt ?? -1))) + continue; + const blocked = (old?.blockedUntil || 0) > Date.now(); + snapshot.routes[row.key] = { + key: row.key, + direct: row.key !== 'gs25-stock' && !blocked && row.direct, + checkedAt: row.checkedAt, + ...(row.reason === undefined ? {} : { reason: row.reason }), + ...(old?.failedAt === undefined ? {} : { failedAt: old.failedAt }), + ...(old?.blockedUntil === undefined ? {} : { blockedUntil: old.blockedUntil }), + }; + } + for (const row of input.relays as RelayCheck[]) { + if (row.checkedAt < (snapshot.relays[row.group]?.checkedAt || 0)) continue; + snapshot.relays[row.group] = { + group: row.group, + healthy: row.healthy, + checkedAt: row.checkedAt, + ...(row.reason === undefined ? {} : { reason: row.reason }), + }; + } + await storage.put(STORAGE_KEY, snapshot); + }); + } else { + if (!isRouteKey(input.key) || !validTime(input.at)) + return new Response(null, { status: 400 }); + const key = input.key; + const at = input.at; + await this.state.storage.transaction(async (storage) => { + const snapshot = (await storage.get(STORAGE_KEY)) || empty(); + const old = snapshot.routes[key]; + if (!old || (at >= (old.failedAt ?? 0) && old.checkedAt < at + ROUTE_COOLDOWN_MS)) + snapshot.routes[key] = { + key, + direct: false, + checkedAt: Math.max(old?.checkedAt ?? at, at), + reason: 'unavailable', + failedAt: at, + blockedUntil: at + ROUTE_COOLDOWN_MS, + }; + await storage.put(STORAGE_KEY, snapshot); + }); + } + return new Response(null, { status: 204 }); + } +} diff --git a/src/index.ts b/src/index.ts index 95b78d24..8e02efbc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,7 @@ +import { withRouteRouting } from './utils/routeHealth.js'; +import { refreshRouteChecks } from './utils/routeProbes.js'; +import { registerRouteHealthRoutes } from './api/routes/routeHealthRoutes.js'; +export { UpstreamRouteHealth } from './durableObjects/upstreamRouteHealth.js'; import { convenienceTransportFromBindings } from './utils/convenienceTransport.js'; import { handleLotteMartFindStores } from './api/lottemartHandlers.js'; import { geocodeBindings } from './utils/geocode.js'; @@ -347,6 +351,7 @@ app.on(['POST', 'DELETE'], '/', handleRootMcpRequest); // 헬스 체크 엔드포인트 app.get('/health', (c) => c.json({ status: 'ok', config: buildConfigStatus(c.env) })); registerHealthRoutes(app); +registerRouteHealthRoutes(app); registerRateLimitStatsRoutes(app); registerServiceRoutes(app); @@ -423,7 +428,11 @@ const worker = { return createRootInfoResponse(request.method); } - return app.fetch(request, env, executionCtx); + return withRouteRouting(env?.UPSTREAM_ROUTE_HEALTH, executionCtx?.waitUntil?.bind(executionCtx), + () => app.fetch(request, env, executionCtx)); + }, + scheduled(_controller: ScheduledController, env: AppBindings, ctx: ExecutionContext) { + ctx.waitUntil(refreshRouteChecks(env)); }, request: app.request.bind(app), }; diff --git a/src/services/dtryx/transport.ts b/src/services/dtryx/transport.ts index 43882937..afe21109 100644 --- a/src/services/dtryx/transport.ts +++ b/src/services/dtryx/transport.ts @@ -1,3 +1,5 @@ +import { requestDirectRoute } from '../../utils/directRoutes.js'; +import { routeOperation } from '../../utils/routeHealth.js'; import { diagnosticHeaders } from '../../utils/diagnostics.js'; /** 디트릭스 전용 중계 설정과 인증 전송입니다. */ import { ServiceError } from '../../core/errors.js'; @@ -52,6 +54,15 @@ export async function requestDtryxRelay( options: DtryxTransportOptions, timeout: number, ): Promise { + validateRelay(options); + return routeOperation( + `dtryx-${path}`, + timeout, + (ms) => requestDirectRoute(`dtryx-${path}`, body, ms), + (ms) => requestDtryxRelayRaw(path, body, options, ms), + ); +} +function validateRelay(options: DtryxTransportOptions): void { const { relayUrl, relayToken, accessClientId, accessClientSecret } = options; const accessPresent = accessClientId !== undefined || accessClientSecret !== undefined; if ( @@ -66,6 +77,16 @@ export async function requestDtryxRelay( false, ); } +} + +async function requestDtryxRelayRaw( + path: 'movies' | 'play-dates' | 'timetable', + body: { brandCode: string; cinemaCode: string; playDate?: string }, + options: DtryxTransportOptions, + timeout: number, +): Promise { + const { relayUrl, relayToken, accessClientId, accessClientSecret } = options; + const accessPresent = accessClientId !== undefined || accessClientSecret !== undefined; const headers: Record = { ...diagnosticHeaders(), 'Content-Type': 'application/json', diff --git a/src/services/oliveyoung/transport.ts b/src/services/oliveyoung/transport.ts index 4177140f..088d1975 100644 --- a/src/services/oliveyoung/transport.ts +++ b/src/services/oliveyoung/transport.ts @@ -1,3 +1,5 @@ +import { requestDirectRoute } from '../../utils/directRoutes.js'; +import { routeOperation, type RouteKey } from '../../utils/routeHealth.js'; import { diagnosticHeaders, relayConsumer } from '../../utils/diagnostics.js'; /** 올리브영 무료 직접 요청 및 운영자가 설정한 브라우저 릴레이 전송. */ import { createRelayCooldown, relayCredentialScope } from '../../utils/relayQuota.js'; @@ -56,10 +58,44 @@ export function isValidOliveyoungRelayUrl(value: string | undefined): boolean { ); } +const operationKeys: Record = { + [OLIVEYOUNG_API.STORE_FINDER_PATH]: 'oy-find-store', + [OLIVEYOUNG_API.PRODUCT_SEARCH_PATH]: 'oy-product-search', + [OLIVEYOUNG_API.STOCK_GOODS_INFO_PATH]: 'oy-goods-info', + [OLIVEYOUNG_API.STOCK_STORES_PATH]: 'oy-stock-stores', +}; export async function requestOliveyoung( path: string, body: Record, options: OliveyoungRequestOptions = {}, +): Promise { + const key = operationKeys[path]; + if (!options.relayUrl || !key) return requestOliveyoungRaw(path, body, options); + validateRelay(options); + return routeOperation( + key, + options.timeout ?? 60000, + (ms) => requestDirectRoute(key, body, ms, options.signal), + (ms) => requestOliveyoungRaw(path, body, { ...options, timeout: ms }), + options.signal, + ); +} +function validateRelay(options: OliveyoungRequestOptions): void { + if (!isValidOliveyoungRelayUrl(options.relayUrl)) { + throw new Error('올리브영 릴레이 URL은 HTTPS 또는 로컬 HTTP 주소여야 합니다.'); + } + if (!options.relayToken?.trim()) throw new Error('OY_RELAY_TOKEN이 필요합니다.'); + if (options.accessClientId !== undefined || options.accessClientSecret !== undefined) { + if (!options.accessClientId?.trim() || !options.accessClientSecret?.trim()) { + throw new Error('올리브영 Access 서비스 토큰 설정이 필요합니다.'); + } + } +} + +async function requestOliveyoungRaw( + path: string, + body: Record, + options: OliveyoungRequestOptions = {}, ): Promise { const { relayUrl, relayToken, timeout = relayUrl ? 60000 : 15000 } = options; const deadline = Date.now() + timeout; @@ -72,22 +108,16 @@ export async function requestOliveyoung( let consumer = 'legacy'; let url = `${OLIVEYOUNG_API.BASE_URL}${path}`; if (relayUrl) { - if (!isValidOliveyoungRelayUrl(relayUrl)) { - throw new Error('올리브영 릴레이 URL은 HTTPS 또는 로컬 HTTP 주소여야 합니다.'); - } - if (!relayToken?.trim()) throw new Error('OY_RELAY_TOKEN이 필요합니다.'); + validateRelay(options); url = `${relayUrl.replace(/\/$/, '')}/v1/oliveyoung/${path.split('/').pop()}`; Object.assign(headers, diagnosticHeaders()); headers.Authorization = `Bearer ${relayToken}`; - consumer = (await relayConsumer(relayToken)) || 'legacy'; + consumer = (await relayConsumer(relayToken!)) || 'legacy'; if (consumer !== 'legacy') headers['x-relay-consumer'] = consumer; const { accessClientId, accessClientSecret } = options; if (accessClientId !== undefined || accessClientSecret !== undefined) { - if (!accessClientId?.trim() || !accessClientSecret?.trim()) { - throw new Error('올리브영 Access 서비스 토큰 설정이 필요합니다.'); - } - headers['CF-Access-Client-Id'] = accessClientId; - headers['CF-Access-Client-Secret'] = accessClientSecret; + headers['CF-Access-Client-Id'] = accessClientId!; + headers['CF-Access-Client-Secret'] = accessClientSecret!; } } else { // 공식 사이트 직접 조회용 헤더는 운영자 릴레이에 전달하지 않습니다. @@ -98,7 +128,12 @@ export async function requestOliveyoung( headers['Accept-Language'] = 'ko-KR,ko;q=0.9'; } if (relayUrl) { - scope = await relayCredentialScope([relayUrl.replace(/\/$/, ''), relayToken, options.accessClientId, options.accessClientSecret]); + scope = await relayCredentialScope([ + relayUrl.replace(/\/$/, ''), + relayToken, + options.accessClientId, + options.accessClientSecret, + ]); } let busyRetries = 0; while (true) { @@ -108,10 +143,16 @@ export async function requestOliveyoung( const remaining = deadline - Date.now(); if (options.signal?.aborted) throw new DOMException('Request cancelled', 'AbortError'); if (remaining <= 0) throw new DOMException('Request deadline exceeded', 'AbortError'); - if (cachedQuota) throw new HttpError(429, '', '', new Headers({ - 'x-relay-quota-reason': cachedQuota.quotaReason, - 'retry-after': String(cachedQuota.retryAfter), - })); + if (cachedQuota) + throw new HttpError( + 429, + '', + '', + new Headers({ + 'x-relay-quota-reason': cachedQuota.quotaReason, + 'retry-after': String(cachedQuota.retryAfter), + }), + ); result = await fetchJson(url, { method: 'POST', // Workers는 error 모드를 지원하지 않으므로 따라가지 않고 아래 200 검사로 거절합니다. diff --git a/src/utils/convenienceTransport.ts b/src/utils/convenienceTransport.ts index 8ae489eb..6ba83239 100644 --- a/src/utils/convenienceTransport.ts +++ b/src/utils/convenienceTransport.ts @@ -1,4 +1,6 @@ /** 편의점 공통 릴레이 설정과 인증 전송입니다. */ +import { requestDirectRoute } from './directRoutes.js'; +import { isRouteKey, routeOperation } from './routeHealth.js'; import { ServiceError } from '../core/errors.js'; import { diagnosticHeaders } from './diagnostics.js'; import { isValidDtryxRelayUrl } from '../services/dtryx/transport.js'; @@ -37,6 +39,16 @@ export async function requestConvenienceRelay( options: ConvenienceTransportOptions, timeout = 15000, ): Promise { + validateRelay(options); + if (!isRouteKey(operation)) return requestConvenienceRelayRaw(operation, body, options, timeout); + return routeOperation( + operation, + timeout, + (ms) => requestDirectRoute(operation, body, ms), + (ms) => requestConvenienceRelayRaw(operation, body, options, ms), + ); +} +function validateRelay(options: ConvenienceTransportOptions): void { const { convenienceRelayUrl: url, convenienceRelayToken: token, @@ -56,6 +68,21 @@ export async function requestConvenienceRelay( false, ); } +} + +async function requestConvenienceRelayRaw( + operation: string, + body: unknown, + options: ConvenienceTransportOptions, + timeout = 15000, +): Promise { + const { + convenienceRelayUrl: url, + convenienceRelayToken: token, + convenienceAccessClientId: id, + convenienceAccessClientSecret: secret, + } = options; + const access = id !== undefined || secret !== undefined; const headers: Record = { ...diagnosticHeaders(), 'Content-Type': 'application/json', diff --git a/src/utils/directRouteSpecs.ts b/src/utils/directRouteSpecs.ts new file mode 100644 index 00000000..f765715e --- /dev/null +++ b/src/utils/directRouteSpecs.ts @@ -0,0 +1,243 @@ +/** 직접 조회와 주기 검사에서 같은 고정 경로·검증기를 사용합니다. */ +import * as z from 'zod'; +import type { RouteKey } from './routeHealth.js'; +import { OLIVEYOUNG_API as OY } from '../services/oliveyoung/api.js'; +import { DTRYX_API as DT } from '../services/dtryx/api.js'; +import { + convenienceInputs, + conveniencePaths, + successSchema, +} from './directRouteSpecsConvenience.js'; + +export interface DirectRouteSpec { + build(body: unknown): { url: URL; init: RequestInit }; + response: z.ZodType; + probe: () => unknown; +} +const obj = z.object({}).passthrough(); +import { + oyStoreRow, + oyStockRow, + oyProductRow, + dtMovieRow, + dtDateRow, + dtTimetableRow, +} from './directRouteSpecsRows.js'; +const jsonHeaders = { Accept: 'application/json', 'Content-Type': 'application/json' }; +const oyHeaders = { + ...jsonHeaders, + 'X-Requested-With': 'XMLHttpRequest', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36', + Origin: OY.BASE_URL, + Referer: `${OY.BASE_URL}/`, + 'Accept-Language': 'ko-KR,ko;q=0.9', +}; +const coordinates = { + lat: 37.5635, + lon: 126.9839, + mapLat: 37.5635, + mapLon: 126.9839, + pageIdx: 1, + searchWords: 'DF23', +}; +const oyPayloads = { + 'oy-find-store': { ...coordinates, pogKeys: '', serviceKeys: '' }, + 'oy-product-search': { keyword: '립밤', page: 1, size: 10, sort: '01', includeSoldOut: true }, + 'oy-goods-info': { goodsNo: 'A000000165760' }, + 'oy-stock-stores': { ...coordinates, productId: '8800362322138' }, +}; +const oySchemas = { + 'oy-find-store': z.object({ storeList: z.array(oyStoreRow) }).passthrough(), + 'oy-product-search': z.union([ + z.object({ searchList: z.array(oyProductRow) }).passthrough(), + z.object({ serachList: z.array(oyProductRow) }).passthrough(), + ]), + 'oy-goods-info': z + .object({ goodsInfo: z.object({ masterGoodsNumber: z.string().min(1) }).passthrough() }) + .passthrough(), + 'oy-stock-stores': z.object({ storeList: z.array(oyStockRow) }).passthrough(), +}; +const oyPaths = [ + OY.STORE_FINDER_PATH, + OY.PRODUCT_SEARCH_PATH, + OY.STOCK_GOODS_INFO_PATH, + OY.STOCK_STORES_PATH, +]; +const convenienceFixtures: Record = { + 'cu-prime': {}, + 'cu-stock': { + searchWord: '레쓰비', + prevSearchWord: '', + spellModifyUseYn: 'Y', + offset: 0, + limit: 10, + searchSort: 'recom', + }, + 'cu-store': { + latVal: '37.5635', + longVal: '126.9839', + baseLatVal: '37.5635', + baseLongVal: '126.9839', + tabId: '2', + filterSvcList: [], + filterAdtList: [], + stockCdcYn: 'N', + searchStock: false, + pickupType: 'change', + getRoute: 'IOS', + areaTplNo: '0', + childMealPickUpYn: 'N', + isCurrentSearch: 'N', + pageType: 'search_improve', + searchWord: '강남', + isRecommend: '', + recommendId: '', + jipCd: '', + itemCd: '', + item_cd: '', + onItemNo: '', + }, + 'seven-goods': { + collection: 'goods', + query: '코카콜라', + sort: 'quantity/desc,itemOnm/asc', + startCount: 0, + listCount: 10, + }, + 'seven-store': { collection: 'store', query: '강남', sort: 'Date/desc', listCount: 10 }, + 'seven-popwords': { label: 'home' }, + 'seven-stock-meta': { itemCd: '8801056018979' }, + 'seven-stock': { + smCd: '161260', + stokMngCd: '161260', + stokMngQty: 1, + stockApplicationRate: '100', + storeList: ['64676'], + }, + 'gs25-products': { query: '코카콜라' }, +}; +// 클라이언트 모듈을 가져오면 전송 모듈과 순환하므로 고정 공개 헤더만 둡니다. +const cuHeaders = { + ...jsonHeaders, + Accept: 'application/json, text/javascript, */*; q=0.01', + 'X-Requested-With': 'XMLHttpRequest', + 'User-Agent': + 'Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.0.0 Mobile Safari/537.36;BGFCU', +}; +const gsHeaders = { + ...jsonHeaders, + Accept: 'application/json, text/plain, */*', + 'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7', + 'User-Agent': + 'Mozilla/5.0 (Linux; Android 15; SM-S928N) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0 Mobile Safari/537.36', + Origin: 'https://woodongs.com', + Referer: 'https://woodongs.com/', +}; +const sevenHeaders = { + ...jsonHeaders, + Accept: 'application/json, text/plain, */*', + 'User-Agent': 'Mozilla/5.0 (Linux; Android 15)', +}; +const specs: Partial> = {}; +Object.entries(oyPayloads).forEach(([key, fixture], i) => { + specs[key as RouteKey] = { + probe: () => fixture, + response: z + .object({ status: z.literal('SUCCESS'), data: oySchemas[key as keyof typeof oySchemas] }) + .passthrough(), + build(body) { + return { + url: new URL(oyPaths[i], OY.BASE_URL), + init: { method: 'POST', headers: oyHeaders, body: JSON.stringify(obj.parse(body)) }, + }; + }, + }; +}); +Object.entries(conveniencePaths).forEach(([key, [base, path, method, schema]]) => { + specs[key as RouteKey] = { + probe: () => convenienceFixtures[key] || {}, + response: successSchema.and(schema), + build(input) { + const body = convenienceInputs[key as keyof typeof convenienceInputs].parse(input); + const url = new URL(path, base); + if (method === 'GET' || key === 'seven-popwords') + url.search = new URLSearchParams(body as Record).toString(); + return { + url, + init: { + method, + headers: key.startsWith('cu-') + ? cuHeaders + : key === 'gs25-products' + ? gsHeaders + : sevenHeaders, + body: + method === 'POST' ? JSON.stringify(key === 'seven-popwords' ? {} : body) : undefined, + }, + }; + }, + }; +}); +const dtryxInputs = z + .object({ + brandCode: z.string().regex(/^[a-z][a-z0-9_-]{0,31}$/), + cinemaCode: z.string().regex(/^\d{6}$/), + playDate: z + .string() + .regex(/^\d{8}$/) + .optional(), + }) + .strict(); +const dtOperations = { + 'dtryx-movies': 'MOVIE_NOW', + 'dtryx-play-dates': 'PLAY_DATE_LIST', + 'dtryx-timetable': 'TIMETABLE_LIST', +} as const; +Object.entries(dtOperations).forEach(([key, operation]) => { + specs[key as RouteKey] = { + probe: () => ({ + brandCode: 'indieart', + cinemaCode: '000067', + ...(operation === 'TIMETABLE_LIST' + ? { playDate: new Date().toISOString().slice(0, 10).replaceAll('-', '') } + : {}), + }), + response: z + .object({ + RetCode: z.literal('success'), + Recordset: z.array( + operation === 'MOVIE_NOW' + ? dtMovieRow + : operation === 'PLAY_DATE_LIST' + ? dtDateRow + : dtTimetableRow, + ), + }) + .passthrough(), + build(input) { + const body = dtryxInputs.parse(input); + const url = new URL(`${DT.THIRDPARTY_PATH}/${DT.ENDPOINTS[operation]}`, DT.BASE_URL); + url.search = new URLSearchParams({ + ChannelCd: DT.CHANNEL_CODE, + EngVerYn: 'N', + WorkGuID: DT.WORK_GUIDS[operation], + BrandCd: body.brandCode, + CinemaCd: body.cinemaCode, + }).toString(); + if (operation === 'TIMETABLE_LIST') { + if (!body.playDate) throw new Error('invalid-input'); + url.searchParams.set( + 'PlaySDT', + `${body.playDate.slice(0, 4)}-${body.playDate.slice(4, 6)}-${body.playDate.slice(6, 8)}`, + ); + } + url.searchParams.set( + operation === 'PLAY_DATE_LIST' ? 'MovieCd' : 'ImgSize', + operation === 'PLAY_DATE_LIST' ? '' : 'small', + ); + return { url, init: { method: 'GET', headers: { Accept: 'application/json' } } }; + }, + }; +}); +export const directRouteSpecs = specs as Record; diff --git a/src/utils/directRouteSpecsConvenience.ts b/src/utils/directRouteSpecsConvenience.ts new file mode 100644 index 00000000..564ecd68 --- /dev/null +++ b/src/utils/directRouteSpecsConvenience.ts @@ -0,0 +1,286 @@ +/** 편의점 고정 경로와 요청·응답 구조를 검증합니다. */ +import * as z from 'zod'; +import { + cuStockRow, + cuStoreRow, + sevenProductRow, + sevenStoreRow, + gsProductRow, +} from './directRouteSpecsRows.js'; +import { CU_API } from '../services/cu/api.js'; +import { GS25_API } from '../services/gs25/api.js'; +import { SEVENELEVEN_API } from '../services/seveneleven/api.js'; +const text = z + .string() + .max(200) + .regex(/^[^\p{Cc}]*$/u); +const code = z + .string() + .max(64) + .regex(/^[A-Za-z0-9_-]*$/); +const count = z.number().int().min(0).max(9999); +const quantity = z.union([z.number().int().min(-1), z.string().regex(/^(?:-1|\d{1,9})$/)]); +export const successSchema = z + .object({ success: z.literal(true).optional(), error: z.never().optional() }) + .passthrough(); +const rows = z.array(z.object({}).passthrough()); +const envelope = z + .object({ data: z.unknown(), success: z.boolean().optional(), code: z.number().optional() }) + .passthrough() + .refine( + (v) => + Object.hasOwn(v, 'data') && + v.success !== false && + (v.code === undefined || (v.code >= 200 && v.code < 300)), + ); +export const convenienceInputs = { + 'cu-prime': z.object({}).strict(), + 'cu-stock': z + .object({ + searchWord: text.min(1), + prevSearchWord: z.literal(''), + spellModifyUseYn: z.literal('Y'), + offset: count, + limit: count.min(1), + searchSort: code.min(1), + }) + .strict(), + 'cu-store': z + .object({ + latVal: z.string().regex(/^-?\d+(\.\d+)?$/), + longVal: z.string().regex(/^-?\d+(\.\d+)?$/), + baseLatVal: z.string().regex(/^-?\d+(\.\d+)?$/), + baseLongVal: z.string().regex(/^-?\d+(\.\d+)?$/), + tabId: z.literal('2'), + filterSvcList: z.array(z.never()).length(0), + filterAdtList: z.array(z.never()).length(0), + stockCdcYn: z.literal('N'), + searchStock: z.literal(false), + pickupType: z.literal('change'), + getRoute: z.literal('IOS'), + areaTplNo: z.literal('0'), + childMealPickUpYn: z.literal('N'), + isCurrentSearch: z.literal('N'), + pageType: text, + searchWord: text, + isRecommend: z.enum(['', 'Y', 'N']), + recommendId: code, + jipCd: code, + itemCd: code, + item_cd: code, + onItemNo: code, + }) + .strict() + .refine( + (v) => + [v.latVal, v.baseLatVal].every((n) => Math.abs(Number(n)) <= 90) && + [v.longVal, v.baseLongVal].every((n) => Math.abs(Number(n)) <= 180), + ), + 'seven-goods': z.union([ + z + .object({ + collection: z.literal('goods'), + query: text.min(1), + sort: z.literal('quantity/desc,itemOnm/asc'), + startCount: count, + listCount: count.min(1), + }) + .strict(), + z.object({ query: text.min(1), pageNo: count, pageSize: count.min(1) }).strict(), + ]), + 'seven-store': z + .object({ + collection: z.literal('store'), + query: text.min(1), + sort: z.literal('Date/desc'), + listCount: count.min(1), + }) + .strict(), + 'seven-popwords': z.object({ label: code.min(1) }).strict(), + 'seven-stock-meta': z.object({ itemCd: code.min(1) }).strict(), + 'seven-stock': z + .object({ + smCd: code.min(1), + stokMngCd: code.min(1), + stokMngQty: count, + stockApplicationRate: z + .string() + .max(32) + .regex(/^\d*(\.\d+)?$/), + storeList: z.array(code.min(1)).min(1).max(200), + }) + .strict(), + 'seven-pages': z.object({}).strict(), + 'seven-issues': z.object({}).strict(), + 'seven-exhibitions': z.object({}).strict(), + 'gs25-products': z.object({ query: text.min(1) }).strict(), + 'gs25-stock': z.object({}).strict(), +}; +type Operation = keyof typeof convenienceInputs; +const searchData = (row: z.ZodType) => + z.union([ + z + .object({ + SearchQueryResult: z + .object({ + Collection: z.array( + z + .object({ Documentset: z.object({ Document: z.array(row) }).passthrough() }) + .passthrough(), + ), + }) + .passthrough(), + }) + .passthrough(), + z.object({ content: z.array(row) }).passthrough(), + ]); +const catalogData = (row: z.ZodType) => + z.union([ + z.array(row), + z.object({ content: z.array(row) }).passthrough(), + z.object({ list: z.array(row) }).passthrough(), + ]); +const popwordData = z.union([ + z.array(z.union([z.string(), z.object({}).passthrough()])), + z.object({ keywords: z.array(z.unknown()) }).passthrough(), + z.object({ popwords: z.array(z.unknown()) }).passthrough(), + z.object({ wordList: z.array(z.unknown()) }).passthrough(), + z.object({ list: z.array(z.unknown()) }).passthrough(), +]); +const withData = (schema: z.ZodType) => envelope.and(z.object({ data: schema })); +export const conveniencePaths: Record = { + 'cu-prime': [ + CU_API.BASE_URL, + CU_API.STOCK_DISPLAY_PATH, + 'POST', + z + .object({ + resp_cd: z.literal('0000').optional(), + areaCateList: z.array( + z.object({ menuCd: z.string().min(1), menuNm: z.string().min(1) }).passthrough(), + ), + }) + .passthrough(), + ], + 'cu-stock': [ + CU_API.BASE_URL, + CU_API.STOCK_MAIN_PATH, + 'POST', + z + .object({ + data: z + .object({ + stockResult: z + .object({ result: z.object({ rows: z.array(cuStockRow) }).passthrough() }) + .passthrough(), + }) + .passthrough(), + }) + .passthrough() + .refine((v) => v.resp_cd === undefined || v.resp_cd === '0000'), + ], + 'cu-store': [ + CU_API.BASE_URL, + CU_API.STORE_PATH, + 'POST', + z + .object({ storeList: z.array(cuStoreRow) }) + .passthrough() + .refine((v) => v.resp_cd === undefined || v.resp_cd === '0000'), + ], + 'seven-goods': [ + SEVENELEVEN_API.BASE_URL, + SEVENELEVEN_API.SEARCH_GOODS_PATH, + 'POST', + withData(searchData(sevenProductRow)), + ], + 'seven-store': [ + SEVENELEVEN_API.BASE_URL, + SEVENELEVEN_API.SEARCH_STORE_PATH, + 'POST', + withData(searchData(sevenStoreRow)), + ], + 'seven-popwords': [ + SEVENELEVEN_API.BASE_URL, + SEVENELEVEN_API.SEARCH_POPWORD_PATH, + 'POST', + z.union([ + withData(popwordData), + z + .object({ success: z.literal(true), code: z.literal(200), data: z.object({}).strict() }) + .passthrough(), + ]), + ], + 'seven-stock-meta': [ + SEVENELEVEN_API.BASE_URL, + SEVENELEVEN_API.PRODUCT_SEARCH_STOCK_PATH, + 'GET', + z + .object({ + itemCd: code.min(1), + smCd: code.min(1), + stokMngCd: code.min(1), + stokMngQty: quantity, + stockApplicationRate: z.union([z.string(), z.number()]), + }) + .passthrough(), + ], + 'seven-stock': [ + SEVENELEVEN_API.BASE_URL, + `${SEVENELEVEN_API.REAL_STOCK_MULTI_PATH}/01/stocks`, + 'POST', + envelope.and( + z.object({ + data: z + .object({ + storeList: z.array(z.object({ storeCd: code.min(1), stock: quantity }).passthrough()), + }) + .passthrough(), + }), + ), + ], + 'seven-pages': [ + SEVENELEVEN_API.BASE_URL, + SEVENELEVEN_API.PRODUCT_PAGES_PATH, + 'GET', + withData(catalogData(sevenProductRow)), + ], + 'seven-issues': [ + SEVENELEVEN_API.BASE_URL, + SEVENELEVEN_API.PRODUCT_ISSUES_PATH, + 'GET', + withData(catalogData(sevenProductRow)), + ], + 'seven-exhibitions': [ + SEVENELEVEN_API.BASE_URL, + SEVENELEVEN_API.EXHIBITION_MAIN_PATH, + 'GET', + withData(catalogData(z.object({}).passthrough())), + ], + 'gs25-products': [ + GS25_API.APIGW_BASE_URL, + GS25_API.TOTAL_SEARCH_PATH, + 'POST', + z + .object({ + SearchQueryResult: z + .object({ + Collection: z.array( + z + .object({ + Documentset: z.object({ Document: z.array(gsProductRow) }).passthrough(), + }) + .passthrough(), + ), + }) + .passthrough(), + }) + .passthrough(), + ], + 'gs25-stock': [ + GS25_API.BFF_BASE_URL, + GS25_API.STORE_STOCK_PATH, + 'GET', + z.object({ stores: rows }).passthrough(), + ], +}; diff --git a/src/utils/directRouteSpecsRows.ts b/src/utils/directRouteSpecsRows.ts new file mode 100644 index 00000000..63239c96 --- /dev/null +++ b/src/utils/directRouteSpecsRows.ts @@ -0,0 +1,129 @@ +/** 원본 행의 식별자와 수치 형식을 검사해 누락값을 실제 0으로 해석하지 않습니다. */ +import * as z from 'zod'; +const id = z.string().min(1); +const count = z.number().int().min(0); +export const numeric = z.union([z.number().min(0), z.string().regex(/^\d+(\.\d+)?$/)]); +const stock = z.union([z.number().int().min(-1), z.string().regex(/^(?:-1|\d+)$/)]).nullable(); +const yesNo = z.enum(['Y', 'N']); +const reviewNumber = z.union([numeric, z.literal('')]); +export const oyStoreRow = z + .object({ + storeCode: id, + storeName: id, + address: z.string().optional(), + latitude: z.number().min(-90).max(90).optional(), + longitude: z.number().min(-180).max(180).optional(), + distance: z.number().min(0).optional(), + pickupYn: z.boolean().optional(), + openYn: z.boolean().optional(), + salesStoreYn: z.boolean().optional(), + remainQuantity: count.nullable().optional(), + o2oRemainQuantity: count.nullable().optional(), + }) + .passthrough(); +export const oyStockRow = oyStoreRow.extend({ salesStoreYn: z.boolean() }); +export const oyProductRow = z + .object({ + goodsNumber: id, + goodsName: id, + priceToPay: z.number().min(0), + originalPrice: z.number().min(0), + discountRate: z.number().min(0).max(100), + o2oStockFlag: z.boolean(), + o2oRemainQuantity: count, + imagePath: z.string().nullable().optional(), + }) + .passthrough(); +export const cuStockRow = z + .object({ + fields: z + .object({ + item_cd: id, + item_nm: id, + hyun_maega: numeric, + on_item_no: z.string().optional(), + pickup_yn: yesNo.optional(), + deliv_yn: yesNo.optional(), + reserv_yn: yesNo.optional(), + }) + .passthrough(), + }) + .passthrough(); +export const cuStoreRow = z + .object({ + storeCd: id, + storeNm: id, + latVal: numeric.optional(), + longVal: numeric.optional(), + distance: numeric.optional(), + stock: stock.optional(), + deliveryYn: yesNo.optional(), + deliveryPickYn: yesNo.optional(), + jumpoPickYn: yesNo.optional(), + reserveYn: yesNo.optional(), + }) + .passthrough(); +const sevenProduct = z + .object({ + itemCd: id, + itemOnm: id, + onlinePrice: numeric.optional(), + onlineCost: numeric.optional(), + avgEvalScore: reviewNumber.optional(), + productReviewCnt: reviewNumber.optional(), + }) + .passthrough(); +export const sevenProductRow = z.union([ + z.object({ field: sevenProduct }).passthrough(), + sevenProduct, +]); +const sevenStore = z.union([ + z.object({ storeCode: id, storeName: id }).passthrough(), + z.object({ storeCd: id, storeNm: id }).passthrough(), + z.object({ strCd: id, strNm: id }).passthrough(), + z.object({ storCd: id, storNm: id }).passthrough(), + z.object({ shopCd: id, shopNm: id }).passthrough(), +]); +export const sevenStoreRow = z.union([z.object({ field: sevenStore }).passthrough(), sevenStore]); +export const gsProductRow = z + .object({ + field: z + .object({ + itemCode: id, + itemName: id, + shortItemName: z.string().optional(), + starPoint: reviewNumber.optional(), + stockCheckYn: yesNo.optional(), + }) + .passthrough(), + }) + .passthrough(); +export const dtMovieRow = z + .object({ MovieCd: id, MovieNm: id, RunningTime: numeric.optional() }) + .passthrough(); +export const dtDateRow = z + .object({ + PlaySDT: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + HiddenYn: yesNo.optional(), + RestYn: yesNo.optional(), + }) + .passthrough(); +export const dtTimetableRow = dtMovieRow.extend({ + CinemaCd: id, + ScreenCd: id, + PlaySDT: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + ShowSeq: z.union([id, count]).optional(), + StartTime: z + .string() + .regex(/^\d{2}:?\d{2}$/) + .optional(), + EndTime: z + .string() + .regex(/^\d{2}:?\d{2}$/) + .optional(), + TotalSeatCnt: stock.optional(), + RemainSeatCnt: stock.optional(), +}); diff --git a/src/utils/directRoutes.ts b/src/utils/directRoutes.ts new file mode 100644 index 00000000..696e1952 --- /dev/null +++ b/src/utils/directRoutes.ts @@ -0,0 +1,120 @@ +/** 공식 API 직접 조회는 본문 읽기까지 같은 짧은 제한을 적용합니다. */ +import { HttpError } from './http.js'; +import { directRouteSpecs } from './directRouteSpecs.js'; +import type { RouteKey, RouteCheck } from './routeHealth.js'; + +export class DirectRouteError extends Error { + constructor(readonly reason: string) { + super(reason); + } +} +const MAX_BYTES = 2 * 1024 * 1024; + +/** 중단을 무시하는 fetch나 스트림도 전체 제한 시간을 넘기지 못합니다. */ +export async function boundedRouteJson( + url: URL, + init: RequestInit, + timeoutMs: number, + callerSignal?: AbortSignal, +): Promise { + const controller = new AbortController(); + let rejectAbort!: (reason: unknown) => void; + const aborted = new Promise((_, reject) => { + rejectAbort = reject; + }); + const stop = (reason: string) => { + rejectAbort( + reason === 'cancelled' + ? new DOMException('cancelled', 'AbortError') + : new DirectRouteError(reason), + ); + controller.abort(); + }; + const onAbort = () => stop('cancelled'); + const timer = setTimeout(() => stop('timeout'), Math.max(1, Math.min(3000, timeoutMs))); + callerSignal?.addEventListener('abort', onAbort, { once: true }); + if (callerSignal?.aborted) onAbort(); + let reader: ReadableStreamDefaultReader | undefined; + try { + if (controller.signal.aborted) return await aborted; + const options: RequestInit & { credentials: string } = { + ...init, + redirect: 'manual', + credentials: 'omit', + signal: controller.signal, + }; + const response = await Promise.race([fetch(url, options), aborted]); + if (response.status !== 200) { + void response.body?.cancel().catch(() => undefined); + throw new HttpError(response.status, '', ''); + } + reader = response.body?.getReader(); + if (!reader) throw new DirectRouteError('invalid-response'); + const decoder = new TextDecoder(); + let text = ''; + let bytes = 0; + while (true) { + const chunk = await Promise.race([reader.read(), aborted]); + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > MAX_BYTES) throw new DirectRouteError('response-too-large'); + text += decoder.decode(chunk.value, { stream: true }); + } + text += decoder.decode(); + try { + return JSON.parse(text); + } catch { + throw new DirectRouteError('invalid-response'); + } + } catch (error) { + if ( + error instanceof DirectRouteError || + error instanceof HttpError || + (error instanceof Error && error.name === 'AbortError') + ) + throw error; + throw new DirectRouteError('network-error'); + } finally { + clearTimeout(timer); + callerSignal?.removeEventListener('abort', onAbort); + controller.abort(); + void reader?.cancel().catch(() => undefined); + } +} + +export async function requestDirectRoute( + key: RouteKey, + body: unknown, + timeoutMs: number, + signal?: AbortSignal, +): Promise { + if (key === 'gs25-stock') throw new DirectRouteError('pinned-relay'); + const spec = directRouteSpecs[key]; + const request = spec.build(body); + const result = await boundedRouteJson(request.url, request.init, timeoutMs, signal); + if (!spec.response.safeParse(result).success) throw new DirectRouteError('invalid-response'); + return result as T; +} + +export async function checkDirectRoute(key: RouteKey, timeoutMs = 3000): Promise { + const checkedAt = Date.now(); + try { + const body = directRouteSpecs[key].probe() as Record; + const remaining = Math.min(3000, timeoutMs) - (Date.now() - checkedAt); + if (remaining <= 0) throw new DirectRouteError('timeout'); + await requestDirectRoute(key, body, remaining); + return { key, direct: true, checkedAt }; + } catch (error) { + return { + key, + direct: false, + checkedAt, + reason: + error instanceof DirectRouteError + ? error.reason + : error instanceof HttpError + ? 'http-error' + : 'invalid-input', + }; + } +} diff --git a/src/utils/routeHealth.ts b/src/utils/routeHealth.ts new file mode 100644 index 00000000..02234650 --- /dev/null +++ b/src/utils/routeHealth.ts @@ -0,0 +1,320 @@ +/** 사전 점검 결과를 이용하며 요청 경로에서는 상태 저장소를 기다리지 않습니다. */ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { ServiceError } from '../core/errors.js'; +import { HttpError } from './http.js'; + +export const ROUTE_KEYS = [ + 'oy-find-store', + 'oy-product-search', + 'oy-goods-info', + 'oy-stock-stores', + 'cu-prime', + 'cu-stock', + 'cu-store', + 'gs25-products', + 'gs25-stock', + 'seven-goods', + 'seven-store', + 'seven-popwords', + 'seven-stock-meta', + 'seven-stock', + 'seven-pages', + 'seven-issues', + 'seven-exhibitions', + 'dtryx-movies', + 'dtryx-play-dates', + 'dtryx-timetable', +] as const; +export type RouteKey = (typeof ROUTE_KEYS)[number]; +export const ROUTE_GROUPS = ['oliveyoung', 'convenience', 'dtryx'] as const; +export type RelayGroup = (typeof ROUTE_GROUPS)[number]; +export interface RouteCheck { + key: RouteKey; + direct: boolean; + checkedAt: number; + reason?: string; +} +export interface RelayCheck { + group: RelayGroup; + healthy: boolean; + checkedAt: number; + reason?: string; +} +export interface RouteSnapshot { + routes: Partial>; + relays: Partial>; +} +export const ROUTE_MAX_AGE_MS = 600000; +export const ROUTE_COOLDOWN_MS = 300000; +const SNAPSHOT_REFRESH_MS = 30000; +const STORAGE_NAME = 'upstream-route-health-v1'; +type Context = { namespace: DurableObjectNamespace; waitUntil: (p: Promise) => void }; +type Cached = { snapshot: RouteSnapshot; refreshAt: number; snapshotAt: number }; +const context = new AsyncLocalStorage(); +const cache = new Map(); +const empty = (): RouteSnapshot => ({ routes: {}, relays: {} }); +export function isRouteKey(value: unknown): value is RouteKey { + return typeof value === 'string' && (ROUTE_KEYS as readonly string[]).includes(value); +} +export function isRelayGroup(value: unknown): value is RelayGroup { + return typeof value === 'string' && (ROUTE_GROUPS as readonly string[]).includes(value); +} +/** 원문이나 토큰 대신 사전에 정한 진단 코드만 보관합니다. */ +export function validReason(value: unknown): boolean { + return ( + value === undefined || + (typeof value === 'string' && + [ + 'ok', + 'http', + 'network', + 'shape', + 'timeout', + 'unavailable', + 'quota', + 'configuration', + 'pinned', + 'pinned-relay', + 'http-error', + 'network-error', + 'invalid-response', + 'response-too-large', + 'cancelled', + 'invalid-input', + ].includes(value)) + ); +} +export function validTime(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isSafeInteger(value) && + value >= 0 && + value <= Date.now() + 60000 + ); +} +export function validRouteCheck(value: unknown): value is RouteCheck { + if (!value || typeof value !== 'object') return false; + const row = value as RouteCheck; + return ( + isRouteKey(row.key) && + typeof row.direct === 'boolean' && + validTime(row.checkedAt) && + validReason(row.reason) + ); +} +export function validRelayCheck(value: unknown): value is RelayCheck { + if (!value || typeof value !== 'object') return false; + const row = value as RelayCheck; + return ( + isRelayGroup(row.group) && + typeof row.healthy === 'boolean' && + validTime(row.checkedAt) && + validReason(row.reason) + ); +} +export function withRouteRouting( + namespace: DurableObjectNamespace | undefined, + waitUntil: ((p: Promise) => void) | undefined, + work: () => T, +): T { + return namespace && waitUntil ? context.run({ namespace, waitUntil }, work) : context.exit(work); +} +function objectId(namespace: DurableObjectNamespace) { + return namespace.idFromName(STORAGE_NAME); +} +async function storageRequest( + namespace: DurableObjectNamespace, + path: string, + data?: unknown, + consume: (response: Response) => Promise = async (response) => response as T, +): Promise { + const controller = new AbortController(); + let timer: ReturnType; + // fetch가 중단 신호를 무시해도 상태 호출의 대기 시간을 제한합니다. + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new Error('timeout')); + }, 2000); + }); + try { + return await Promise.race([ + namespace + .get(objectId(namespace)) + .fetch(`https://route-health.internal${path}`, { + method: data === undefined ? 'GET' : 'POST', + ...(data === undefined + ? {} + : { body: JSON.stringify(data), headers: { 'Content-Type': 'application/json' } }), + signal: controller.signal, + }) + .then(consume), + timeout, + ]); + } finally { + clearTimeout(timer!); + } +} +export async function readRouteSnapshot(namespace: DurableObjectNamespace): Promise { + try { + const raw = await storageRequest(namespace, '/snapshot', undefined, async (response) => + response.ok ? ((await response.json()) as RouteSnapshot) : null, + ); + if ( + !raw || + typeof raw.routes !== 'object' || + !raw.routes || + typeof raw.relays !== 'object' || + !raw.relays + ) + return empty(); + const snapshot = empty(); + for (const key of ROUTE_KEYS) { + const row = raw.routes[key]; + if (!validRouteCheck(row) || row.key !== key) continue; + snapshot.routes[key] = { + key, + direct: key !== 'gs25-stock' && row.direct, + checkedAt: row.checkedAt, + ...(row.reason === undefined ? {} : { reason: row.reason }), + ...(validTime(row.failedAt) ? { failedAt: row.failedAt } : {}), + ...(typeof row.blockedUntil === 'number' && + Number.isSafeInteger(row.blockedUntil) && + row.blockedUntil >= 0 && + row.blockedUntil <= Date.now() + ROUTE_COOLDOWN_MS + 60000 + ? { blockedUntil: row.blockedUntil } + : {}), + }; + } + for (const group of ROUTE_GROUPS) { + const row = raw.relays[group]; + if (validRelayCheck(row) && row.group === group) + snapshot.relays[group] = { + group, + healthy: row.healthy, + checkedAt: row.checkedAt, + ...(row.reason === undefined ? {} : { reason: row.reason }), + }; + } + return snapshot; + } catch { + return empty(); + } +} +export async function writeRouteChecks( + namespace: DurableObjectNamespace, + checks: RouteCheck[], + relays: RelayCheck[], +): Promise { + try { + await storageRequest(namespace, '/checks', { checks, relays }); + } catch { + /* 상태 쓰기 장애는 서비스 요청에 전파하지 않습니다. */ + } +} +function background(ctx: Context, task: Promise) { + ctx.waitUntil(task.catch(() => undefined)); +} +function groupFor(key: RouteKey): RelayGroup { + return key.startsWith('oy-') ? 'oliveyoung' : key.startsWith('dtryx-') ? 'dtryx' : 'convenience'; +} +function fresh(at: number, now: number) { + return at <= now && now - at <= ROUTE_MAX_AGE_MS; +} +export async function routeOperation( + key: RouteKey, + budgetMs: number, + direct: (ms: number) => Promise, + relay: (ms: number) => Promise, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const ctx = context.getStore(); + if (!ctx) return relay(budgetMs); + let cacheKey: string; + try { + cacheKey = objectId(ctx.namespace).toString(); + } catch { + return relay(budgetMs); + } + const now = Date.now(); + let cached = cache.get(cacheKey); + if (!cached) { + cached = { snapshot: empty(), refreshAt: 0, snapshotAt: 0 }; + cache.set(cacheKey, cached); + } + if (now - cached.refreshAt >= SNAPSHOT_REFRESH_MS) { + cached.refreshAt = now; + const target = cached; + background( + ctx, + readRouteSnapshot(ctx.namespace).then((snapshot) => { + // 비동기 읽기 중 발생한 실제 실패를 이전 저장소 응답으로 덮어쓰지 않습니다. + for (const route of ROUTE_KEYS) { + const local = target.snapshot.routes[route]; + if ( + local?.failedAt && + local.failedAt > (snapshot.routes[route]?.failedAt || 0) && + (snapshot.routes[route]?.checkedAt || 0) < local.failedAt + ROUTE_COOLDOWN_MS + ) + snapshot.routes[route] = local; + } + target.snapshot = snapshot; + target.snapshotAt = Date.now(); + }), + ); + } + const state = cached.snapshot.routes[key]; + const relayState = cached.snapshot.relays[groupFor(key)]; + const cacheFresh = now - cached.snapshotAt < SNAPSHOT_REFRESH_MS; + const directUsable = + cacheFresh && + key !== 'gs25-stock' && + state && + fresh(state.checkedAt, now) && + state.direct && + (state.blockedUntil || 0) <= now; + if (!directUsable) { + if ( + cacheFresh && + state && + fresh(state.checkedAt, now) && + !state.direct && + relayState && + fresh(relayState.checkedAt, now) && + !relayState.healthy + ) + throw new ServiceError( + 'UPSTREAM_ROUTE_UNAVAILABLE', + '외부 서비스 연결을 사용할 수 없습니다.', + 503, + true, + ); + return relay(budgetMs); + } + try { + return await direct(Math.min(3000, budgetMs)); + } catch (error) { + if (signal?.aborted || (error instanceof HttpError && [400, 404, 422].includes(error.status))) + throw error; + const at = Date.now(); + cached.snapshot.routes[key] = { + ...state, + direct: false, + failedAt: at, + blockedUntil: at + ROUTE_COOLDOWN_MS, + }; + background(ctx, storageRequest(ctx.namespace, '/failure', { key, at })); + const remaining = budgetMs - (at - now); + if (remaining <= 0) throw error; + signal?.throwIfAborted(); + if (relayState && fresh(relayState.checkedAt, at) && !relayState.healthy) + throw new ServiceError( + 'UPSTREAM_ROUTE_UNAVAILABLE', + '외부 서비스 연결을 사용할 수 없습니다.', + 503, + true, + ); + return relay(remaining); + } +} diff --git a/src/utils/routeProbes.ts b/src/utils/routeProbes.ts new file mode 100644 index 00000000..3ed7decf --- /dev/null +++ b/src/utils/routeProbes.ts @@ -0,0 +1,116 @@ +/** 주기 검사는 재고 중계를 소비하지 않고 직접 경로와 인증 상태만 기록합니다. */ +import { ROUTE_KEYS, writeRouteChecks, type RouteCheck, type RelayCheck } from './routeHealth.js'; +import { boundedRouteJson, checkDirectRoute } from './directRoutes.js'; + +export interface RouteProbeBindings { + UPSTREAM_ROUTE_HEALTH?: DurableObjectNamespace; + OY_RELAY_URL?: string; + OY_RELAY_TOKEN?: string; + OY_ACCESS_CLIENT_ID?: string; + OY_ACCESS_CLIENT_SECRET?: string; + CONVENIENCE_RELAY_URL?: string; + CONVENIENCE_RELAY_TOKEN?: string; + CONVENIENCE_ACCESS_CLIENT_ID?: string; + CONVENIENCE_ACCESS_CLIENT_SECRET?: string; + DTRYX_RELAY_URL?: string; + DTRYX_RELAY_TOKEN?: string; + DTRYX_ACCESS_CLIENT_ID?: string; + DTRYX_ACCESS_CLIENT_SECRET?: string; +} + +async function checkRelay( + group: RelayCheck['group'], + rawUrl: string | undefined, + token: string | undefined, + id: string | undefined, + secret: string | undefined, +): Promise { + const checkedAt = Date.now(); + const failure = (reason: string): RelayCheck => ({ group, healthy: false, checkedAt, reason }); + if ( + !rawUrl?.trim() || + !token?.trim() || + ((id !== undefined || secret !== undefined) && (!id?.trim() || !secret?.trim())) + ) + return failure('configuration'); + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return failure('configuration'); + } + if ( + url.username || + url.password || + url.search || + url.hash || + (url.protocol !== 'https:' && + !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) + ) + return failure('configuration'); + url.pathname = `${url.pathname.replace(/\/$/, '')}${group === 'oliveyoung' ? '/health' : `/v1/${group}/health`}`; + const headers: Record = { + Accept: 'application/json', + Authorization: `Bearer ${token}`, + }; + if (id && secret) { + headers['CF-Access-Client-Id'] = id; + headers['CF-Access-Client-Secret'] = secret; + } + try { + const result = await boundedRouteJson(url, { method: 'GET', headers }, 3000); + if (!result || typeof result !== 'object' || Array.isArray(result)) + return failure('invalid-response'); + const data = result as Record; + const healthy = group === 'oliveyoung' ? data.state === 'ready' : data.status === 'ok'; + return healthy ? { group, healthy, checkedAt } : failure('unavailable'); + } catch { + return failure('network-error'); + } +} + +export async function refreshRouteChecks(env: RouteProbeBindings): Promise { + if (!env.UPSTREAM_ROUTE_HEALTH) return; + const checks: RouteCheck[] = []; + const relays: RelayCheck[] = []; + const tasks: Array<() => Promise> = ROUTE_KEYS.map((key) => async () => { + const checkedAt = Date.now(); + try { + checks.push(await checkDirectRoute(key)); + } catch { + checks.push({ key, direct: false, checkedAt, reason: 'network-error' }); + } + }); + const relayTasks = [ + [ + 'oliveyoung', + env.OY_RELAY_URL, + env.OY_RELAY_TOKEN, + env.OY_ACCESS_CLIENT_ID, + env.OY_ACCESS_CLIENT_SECRET, + ], + [ + 'convenience', + env.CONVENIENCE_RELAY_URL, + env.CONVENIENCE_RELAY_TOKEN, + env.CONVENIENCE_ACCESS_CLIENT_ID, + env.CONVENIENCE_ACCESS_CLIENT_SECRET, + ], + [ + 'dtryx', + env.DTRYX_RELAY_URL, + env.DTRYX_RELAY_TOKEN, + env.DTRYX_ACCESS_CLIENT_ID, + env.DTRYX_ACCESS_CLIENT_SECRET, + ], + ] as const; + for (const [group, url, token, id, secret] of relayTasks) + tasks.push(async () => { + relays.push(await checkRelay(group, url, token, id, secret)); + }); + // 직접 검사와 헬스 검사를 합쳐 최대 네 연결만 사용합니다. + for (let offset = 0; offset < tasks.length; offset += 4) + await Promise.all(tasks.slice(offset, offset + 4).map((task) => task())); + checks.sort((a, b) => ROUTE_KEYS.indexOf(a.key) - ROUTE_KEYS.indexOf(b.key)); + await writeRouteChecks(env.UPSTREAM_ROUTE_HEALTH, checks, relays); +} diff --git a/tests/app/app-route-health.test.ts b/tests/app/app-route-health.test.ts new file mode 100644 index 00000000..c8c3ee16 --- /dev/null +++ b/tests/app/app-route-health.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +const mocks = vi.hoisted(() => ({ read: vi.fn(), refresh: vi.fn(), routing: vi.fn() })); +vi.mock('../../src/utils/routeHealth.js', async (importOriginal) => ({ + ...(await importOriginal()), + readRouteSnapshot: mocks.read, + withRouteRouting: mocks.routing, +})); +vi.mock('../../src/utils/routeProbes.js', () => ({ refreshRouteChecks: mocks.refresh })); +import worker from '../../src/index.js'; +const env = { HEALTH_CHECK_SECRET: 'secret', UPSTREAM_ROUTE_HEALTH: {} as DurableObjectNamespace }; +beforeEach(() => { + vi.resetAllMocks(); + mocks.read.mockResolvedValue({ routes: {}, relays: {} }); + mocks.refresh.mockResolvedValue(undefined); + mocks.routing.mockImplementation((_namespace, _waitUntil, work) => work()); +}); +describe('operational route health', () => { + it.each(['GET', 'POST'])('%s requires configured secret and authorization', async (method) => { + expect((await worker.request('/api/health/routes', { method })).status).toBe(503); + expect((await worker.request('/api/health/routes', { method }, env)).status).toBe(401); + expect(mocks.refresh).not.toHaveBeenCalled(); + expect(mocks.read).not.toHaveBeenCalled(); + }); + it('rejects missing shared storage after authorization', async () => { + expect( + ( + await worker.request( + '/api/health/routes', + { headers: { Authorization: 'Bearer secret' } }, + { HEALTH_CHECK_SECRET: 'secret' }, + ) + ).status, + ).toBe(503); + }); + it('GET reads the snapshot without probing', async () => { + const result = await worker.request( + '/api/health/routes', + { headers: { 'x-health-check-key': 'secret' } }, + env, + ); + expect(result.status).toBe(200); + expect(await result.json()).toMatchObject({ success: true, data: { routes: {}, relays: {} } }); + expect(mocks.read).toHaveBeenCalledWith(env.UPSTREAM_ROUTE_HEALTH); + expect(mocks.refresh).not.toHaveBeenCalled(); + }); + it('POST performs fixed checks and returns the resulting snapshot', async () => { + const result = await worker.request( + '/api/health/routes', + { method: 'POST', headers: { Authorization: 'Bearer secret' } }, + env, + ); + expect(result.status).toBe(200); + expect(mocks.refresh).toHaveBeenCalledWith(env); + expect(mocks.read).toHaveBeenCalledOnce(); + }); + it.each(['read', 'refresh'])('reports %s failure without exposing details', async (failure) => { + mocks[failure].mockRejectedValue(new Error('private token raw body')); + const result = await worker.request( + '/api/health/routes', + { + method: failure === 'refresh' ? 'POST' : 'GET', + headers: { Authorization: 'Bearer secret' }, + }, + env, + ); + expect(result.status).toBe(503); + expect(await result.text()).not.toContain('private token'); + }); +}); + +describe('worker background routing integration', () => { + it('scheduled checks are registered with waitUntil', async () => { + const pending: Promise[] = []; + worker.scheduled({} as ScheduledController, env, { + waitUntil: (p: Promise) => pending.push(p), + } as unknown as ExecutionContext); + await Promise.all(pending); + expect(mocks.refresh).toHaveBeenCalledWith(env); + expect(pending).toHaveLength(1); + }); + it('fetch installs binding and a bound background callback', async () => { + const ctx = { + waitUntil: vi.fn(function (this: unknown) { + expect(this).toBe(ctx); + }), + }; + const response = await worker.fetch( + new Request('https://mcp.test/health'), + env, + ctx as unknown as ExecutionContext, + ); + expect(response.status).toBe(200); + const args = mocks.routing.mock.calls[0]!; + expect(args[0]).toBe(env.UPSTREAM_ROUTE_HEALTH); + args[1](Promise.resolve()); + expect(ctx.waitUntil).toHaveBeenCalledOnce(); + }); + it('fetch remains compatible without an execution context or bindings', async () => { + expect( + ( + await worker.fetch( + new Request('https://mcp.test/health'), + undefined as unknown as typeof env, + undefined as unknown as ExecutionContext, + ) + ).status, + ).toBe(200); + expect(mocks.routing.mock.calls[0]?.slice(0, 2)).toEqual([undefined, undefined]); + }); +}); diff --git a/tests/durableObjects/upstreamRouteHealth.test.ts b/tests/durableObjects/upstreamRouteHealth.test.ts new file mode 100644 index 00000000..75e716e7 --- /dev/null +++ b/tests/durableObjects/upstreamRouteHealth.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; +import { UpstreamRouteHealth } from '../../src/durableObjects/upstreamRouteHealth.js'; +function store() { + let value: unknown; + const storage = { + get: async () => structuredClone(value), + put: async (_key: string, data: unknown) => { + value = structuredClone(data); + }, + transaction: async (work: (s: unknown) => unknown) => work(storage), + }; + const obj = new UpstreamRouteHealth({ storage } as unknown as DurableObjectState); + const call = (path: string, data?: unknown) => + obj.fetch( + new Request( + `https://internal${path}`, + data === undefined ? undefined : { method: 'POST', body: JSON.stringify(data) }, + ), + ); + return { call }; +} +describe('upstream route durable object', () => { + it('merges partial checks and pins GS stock', async () => { + const s = store(); + const checkedAt = Date.now(); + expect((await s.call('/snapshot')).status).toBe(200); + expect( + ( + await s.call('/checks', { + checks: [{ key: 'cu-stock', direct: true, checkedAt }], + relays: [{ group: 'convenience', healthy: true, checkedAt }], + }) + ).status, + ).toBe(204); + await s.call('/checks', { + checks: [{ key: 'gs25-stock', direct: true, checkedAt }], + relays: [], + }); + expect(await (await s.call('/snapshot')).json()).toMatchObject({ + routes: { 'cu-stock': { direct: true }, 'gs25-stock': { direct: false } }, + relays: { convenience: { healthy: true } }, + }); + }); + it('protects cooldown and newer failures from delayed probes', async () => { + const s = store(); + const at = Date.now(); + await s.call('/failure', { key: 'cu-stock', at }); + await s.call('/checks', { + checks: [{ key: 'cu-stock', direct: true, checkedAt: at - 1 }], + relays: [], + }); + await s.call('/checks', { + checks: [{ key: 'cu-stock', direct: true, checkedAt: at + 1 }], + relays: [], + }); + expect(await (await s.call('/snapshot')).json()).toMatchObject({ + routes: { 'cu-stock': { direct: false, failedAt: at, blockedUntil: at + 300000 } }, + }); + }); + it.each([ + null, + {}, + { checks: [], relays: 'x' }, + { checks: [{ key: 'unknown', direct: true, checkedAt: 1 }], relays: [] }, + { + checks: [{ key: 'cu-stock', direct: true, checkedAt: 1, reason: 'secret token' }], + relays: [], + }, + ])('rejects invalid payload atomically %j', async (data) => { + const s = store(); + expect((await s.call('/checks', data)).status).toBe(400); + expect(await (await s.call('/snapshot')).json()).toEqual({ routes: {}, relays: {} }); + }); + it('rejects invalid failure, malformed JSON and unknown paths', async () => { + const s = store(); + expect((await s.call('/failure', { key: 'wrong', at: 0 })).status).toBe(400); + expect((await s.call('/other')).status).toBe(404); + }); +}); + +describe('durable state edge cases', () => { + it('rejects oversized and malformed JSON and non-object payloads', async () => { + const obj = new UpstreamRouteHealth({ storage: {} } as DurableObjectState); + for (const body of ['x', '[]', JSON.stringify('x'.repeat(17000))]) + expect( + (await obj.fetch(new Request('https://internal/checks', { method: 'POST', body }))).status, + ).toBe(400); + }); + it('omits secrets and ignores older checks, relays and failures', async () => { + const s = store(); + const at = Date.now(); + await s.call('/checks', { + checks: [{ key: 'cu-stock', direct: true, checkedAt: at, reason: 'ok', token: 'secret' }], + relays: [{ group: 'convenience', healthy: true, checkedAt: at, reason: 'ok' }], + }); + await s.call('/checks', { + checks: [{ key: 'cu-stock', direct: false, checkedAt: at - 1 }], + relays: [{ group: 'convenience', healthy: false, checkedAt: at - 1 }], + }); + await s.call('/failure', { key: 'cu-stock', at: at - 1 }); + const result = await (await s.call('/snapshot')).json(); + expect(result).toMatchObject({ + routes: { 'cu-stock': { direct: false, reason: 'unavailable' } }, + relays: { convenience: { healthy: true, reason: 'ok' } }, + }); + expect(JSON.stringify(result)).not.toContain('secret'); + await s.call('/failure', { key: 'cu-stock', at }); + await s.call('/failure', { key: 'cu-stock', at }); + }); + it('allows healthy probe only after the failure cooldown expires', async () => { + const s = store(); + const at = Date.now() - 300001; + await s.call('/failure', { key: 'cu-stock', at }); + await s.call('/checks', { + checks: [{ key: 'cu-stock', direct: true, checkedAt: Date.now(), reason: 'ok' }], + relays: [], + }); + expect(await (await s.call('/snapshot')).json()).toMatchObject({ + routes: { 'cu-stock': { direct: true, failedAt: at } }, + }); + }); + it.each([ + { checks: [null], relays: [] }, + { checks: [], relays: [null] }, + { checks: [{ key: 'cu-stock', direct: 'yes', checkedAt: 1 }], relays: [] }, + { checks: [], relays: [{ group: 'bad', healthy: true, checkedAt: 1 }] }, + { checks: [], relays: [{ group: 'dtryx', healthy: 'yes', checkedAt: 1 }] }, + { checks: [{ key: 'cu-stock', direct: true, checkedAt: -1 }], relays: [] }, + { checks: [{ key: 'cu-stock', direct: true, checkedAt: Date.now() + 90000 }], relays: [] }, + { checks: Array(21).fill({ key: 'cu-stock', direct: true, checkedAt: 1 }), relays: [] }, + { checks: [], relays: Array(4).fill({ group: 'dtryx', healthy: true, checkedAt: 1 }) }, + ])('rejects invalid values %j', async (data) => { + expect((await store().call('/checks', data)).status).toBe(400); + }); +}); + +it.each([ + 'pinned-relay', + 'http-error', + 'network-error', + 'invalid-response', + 'response-too-large', + 'cancelled', + 'invalid-input', +])('accepts fixed probe diagnostic %s', async (reason) => { + expect( + ( + await store().call('/checks', { + checks: [{ key: 'cu-stock', direct: false, checkedAt: 1, reason }], + relays: [], + }) + ).status, + ).toBe(204); +}); + +it('keeps cooldown when a newer probe arrives before a delayed actual failure', async () => { + const s = store(); + const at = Date.now(); + await s.call('/checks', { + checks: [{ key: 'cu-stock', direct: true, checkedAt: at + 1 }], + relays: [], + }); + await s.call('/failure', { key: 'cu-stock', at }); + expect(await (await s.call('/snapshot')).json()).toMatchObject({ + routes: { 'cu-stock': { direct: false, failedAt: at, blockedUntil: at + 300000 } }, + }); +}); +it('ignores a delayed failure superseded by a success after its full cooldown', async () => { + const s = store(); + const checkedAt = Date.now(); + await s.call('/checks', { checks: [{ key: 'cu-stock', direct: true, checkedAt }], relays: [] }); + await s.call('/failure', { key: 'cu-stock', at: checkedAt - 300001 }); + expect(await (await s.call('/snapshot')).json()).toMatchObject({ + routes: { 'cu-stock': { direct: true } }, + }); +}); diff --git a/tests/utils/adaptiveTransports.test.ts b/tests/utils/adaptiveTransports.test.ts new file mode 100644 index 00000000..5736f896 --- /dev/null +++ b/tests/utils/adaptiveTransports.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ direct: vi.fn(), route: vi.fn() })); +vi.mock('../../src/utils/directRoutes.js', () => ({ requestDirectRoute: mocks.direct })); +vi.mock('../../src/utils/routeHealth.js', async (importOriginal) => ({ + ...(await importOriginal()), + routeOperation: mocks.route, +})); +import { requestConvenienceRelay } from '../../src/utils/convenienceTransport.js'; +import { requestDtryxRelay } from '../../src/services/dtryx/transport.js'; +import { requestOliveyoung } from '../../src/services/oliveyoung/transport.js'; + +const cases = [ + [ + 'cu-stock', + () => + requestConvenienceRelay( + 'cu-stock', + { query: '우유' }, + { convenienceRelayUrl: 'https://relay.example', convenienceRelayToken: 'token' }, + 10000, + ), + ], + [ + 'dtryx-movies', + () => + requestDtryxRelay( + 'movies', + { brandCode: 'K', cinemaCode: '000067' }, + { relayUrl: 'https://relay.example', relayToken: 'token' }, + 10000, + ), + ], + [ + 'oy-product-search', + () => + requestOliveyoung( + '/oystore/api/stock/product-search-v3', + { keyword: '크림' }, + { relayUrl: 'https://relay.example', relayToken: 'token', timeout: 10000 }, + ), + ], +] as const; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetAllMocks(); +}); + +describe('shared transports use saved route decisions', () => { + it.each(cases)('%s invokes only selected direct path', async (key, call) => { + mocks.route.mockImplementation((_key, _budget, direct) => direct(2500)); + mocks.direct.mockResolvedValue({ selected: 'direct' }); + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + expect(await call()).toEqual({ selected: 'direct' }); + expect(mocks.route.mock.calls[0]?.slice(0, 2)).toEqual([key, 10000]); + expect(mocks.direct.mock.calls[0]?.[0]).toBe(key); + expect(mocks.direct.mock.calls[0]?.[2]).toBe(2500); + expect(fetch).not.toHaveBeenCalled(); + }); + it.each(cases)('%s retains authenticated relay fallback', async (_key, call) => { + mocks.route.mockImplementation((_key, _budget, _direct, relay) => relay(7500)); + const result = { + status: 'SUCCESS', + success: true, + RetCode: 'success', + Recordset: [], + data: [], + }; + const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(result))); + vi.stubGlobal('fetch', fetch); + await call(); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch.mock.calls[0]?.[1]?.headers.Authorization).toBe('Bearer token'); + expect(mocks.direct).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/utils/directRouteRows.test.ts b/tests/utils/directRouteRows.test.ts new file mode 100644 index 00000000..0c05a59f --- /dev/null +++ b/tests/utils/directRouteRows.test.ts @@ -0,0 +1,305 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { checkDirectRoute } from '../../src/utils/directRoutes.js'; +afterEach(() => vi.unstubAllGlobals()); + +it('rejects malformed product identities and stock quantities before normalization can invent zero', async () => { + const valid = { + goodsNumber: 'A000000165760', + goodsName: '핸드크림', + priceToPay: 10800, + originalPrice: 12000, + discountRate: 10, + o2oStockFlag: true, + o2oRemainQuantity: 0, + }; + for (const row of [ + {}, + { ...valid, goodsNumber: '' }, + { ...valid, priceToPay: 'oops' }, + { ...valid, o2oRemainQuantity: 'oops' }, + { ...valid, o2oStockFlag: 'N' }, + { ...valid, o2oRemainQuantity: undefined }, + ]) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ status: 'SUCCESS', data: { searchList: [row] } })), + ); + expect(await checkDirectRoute('oy-product-search')).toMatchObject({ + direct: false, + reason: 'invalid-response', + }); + } + for (const [key, data] of [ + ['oy-find-store', { storeList: [{}] }], + [ + 'oy-stock-stores', + { + storeList: [ + { storeCode: 'DF23', storeName: '점포', salesStoreYn: true, remainQuantity: 'oops' }, + ], + }, + ], + [ + 'cu-stock', + { + data: { + stockResult: { + result: { rows: [{ fields: { item_cd: '123', item_nm: '커피', hyun_maega: 'oops' } }] }, + }, + }, + }, + ], + ['cu-store', { storeList: [{}] }], + [ + 'gs25-products', + { SearchQueryResult: { Collection: [{ Documentset: { Document: [{ field: {} }] } }] } }, + ], + ['seven-goods', { data: { content: [{}] } }], + [ + 'seven-store', + { data: { SearchQueryResult: { Collection: [{ Documentset: { Document: [{}] } }] } } }, + ], + ['dtryx-movies', { RetCode: 'success', Recordset: [{}] }], + ['dtryx-play-dates', { RetCode: 'success', Recordset: [{}] }], + [ + 'dtryx-timetable', + { + RetCode: 'success', + Recordset: [{ CinemaCd: '000067', MovieCd: '1', ScreenCd: '1', TotalSeatCnt: 'oops' }], + }, + ], + ] as const) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json(key.startsWith('oy-') ? { status: 'SUCCESS', data } : data)), + ); + expect(await checkDirectRoute(key)).toMatchObject({ + direct: false, + reason: 'invalid-response', + }); + } +}); + +it('accepts observed Olive Young rows and typed nonempty rows for the other public APIs', async () => { + const product = { + goodsNumber: 'A000000165760', + goodsName: '타입넘버 핸드크림', + priceToPay: 10800, + originalPrice: 12000, + discountRate: 10, + o2oStockFlag: true, + o2oRemainQuantity: 0, + }; + const store = { + storeCode: 'DF23', + storeName: '명동2가점', + address: '서울특별시 중구 남대문로 68-1', + latitude: 37.5632323502622, + longitude: 126.982275208021, + distance: 0.15, + pickupYn: true, + salesStoreYn: true, + remainQuantity: 9, + o2oRemainQuantity: 7, + openYn: false, + }; + const cases = [ + ['oy-product-search', { status: 'SUCCESS', data: { serachList: [product] } }], + ['oy-find-store', { status: 'SUCCESS', data: { storeList: [store] } }], + [ + 'oy-stock-stores', + { + status: 'SUCCESS', + data: { storeList: [store, { ...store, remainQuantity: null, o2oRemainQuantity: null }] }, + }, + ], + [ + 'cu-stock', + { + data: { + stockResult: { + result: { rows: [{ fields: { item_cd: '123', item_nm: '커피', hyun_maega: '1500' } }] }, + }, + }, + }, + ], + ['cu-store', { storeList: [{ storeCd: '123', storeNm: 'CU강남', stock: null }] }], + [ + 'gs25-products', + { + SearchQueryResult: { + Collection: [ + { + Documentset: { + Document: [ + { + field: { + itemCode: '123', + itemName: '콜라', + starPoint: '4.0', + stockCheckYn: 'Y', + }, + }, + ], + }, + }, + ], + }, + }, + ], + [ + 'seven-goods', + { data: { content: [{ itemCd: '201051', itemOnm: '콜라', onlinePrice: 1500 }] } }, + ], + [ + 'seven-store', + { + data: { + SearchQueryResult: { + Collection: [ + { Documentset: { Document: [{ field: { storeCd: '54928', storeNm: '강남점' } }] } }, + ], + }, + }, + }, + ], + [ + 'dtryx-movies', + { RetCode: 'success', Recordset: [{ MovieCd: '1', MovieNm: '영화', RunningTime: '120' }] }, + ], + [ + 'dtryx-play-dates', + { RetCode: 'success', Recordset: [{ PlaySDT: '2026-10-06', HiddenYn: 'N' }] }, + ], + [ + 'dtryx-timetable', + { + RetCode: 'success', + Recordset: [ + { + CinemaCd: '000067', + MovieCd: '1', + MovieNm: '영화', + ScreenCd: '1', + TotalSeatCnt: '100', + RemainSeatCnt: '10', + }, + ], + }, + ], + ] as const; + for (const [key, body] of cases) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json(body)), + ); + expect(await checkDirectRoute(key), key).toMatchObject({ direct: true }); + } +}); + +it('rejects malformed catalog product prices in pages and issues before zero normalization', async () => { + for (const key of ['seven-pages', 'seven-issues'] as const) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ data: [{ itemCd: '201051', itemOnm: '콜라', onlinePrice: 'oops' }] }), + ), + ); + expect(await checkDirectRoute(key)).toMatchObject({ + direct: false, + reason: 'invalid-response', + }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ + data: { content: [{ itemCd: '201051', itemOnm: '콜라', onlinePrice: 1500 }] }, + }), + ), + ); + expect(await checkDirectRoute(key)).toMatchObject({ direct: true }); + } +}); + +it('accepts observed blank unrated values while retaining strict product prices', async () => { + for (const [key, result] of [ + [ + 'gs25-products', + { + SearchQueryResult: { + Collection: [ + { + Documentset: { + Document: [{ field: { itemCode: '123', itemName: '콜라', starPoint: '' } }], + }, + }, + ], + }, + }, + ], + [ + 'seven-goods', + { + data: { + content: [ + { + itemCd: '123', + itemOnm: '콜라', + onlinePrice: 1500, + avgEvalScore: '', + productReviewCnt: '', + }, + ], + }, + }, + ], + ] as const) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json(result)), + ); + expect(await checkDirectRoute(key)).toMatchObject({ direct: true }); + } + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ data: [{ itemCd: '123', itemOnm: '콜라', onlinePrice: '' }] }), + ), + ); + expect(await checkDirectRoute('seven-pages')).toMatchObject({ direct: false }); +}); +it('accepts typed CU display metadata without a response code and rejects malformed metadata', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ areaCateList: [{ menuCd: '1', menuNm: '상품 탭1' }] })), + ); + expect(await checkDirectRoute('cu-prime')).toMatchObject({ direct: true }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ areaCateList: [{}] })), + ); + expect(await checkDirectRoute('cu-prime')).toMatchObject({ direct: false }); +}); + +it('accepts exact empty popword data only with observed explicit success markers', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ success: true, data: {}, code: 200 })), + ); + expect(await checkDirectRoute('seven-popwords')).toMatchObject({ direct: true }); + for (const result of [ + { data: {} }, + { success: true, data: {} }, + { data: {}, code: 200 }, + { success: true, data: { error: 'wrong' }, code: 200 }, + ]) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json(result)), + ); + expect(await checkDirectRoute('seven-popwords')).toMatchObject({ + direct: false, + reason: 'invalid-response', + }); + } +}); diff --git a/tests/utils/directRoutes.test.ts b/tests/utils/directRoutes.test.ts new file mode 100644 index 00000000..594aabdd --- /dev/null +++ b/tests/utils/directRoutes.test.ts @@ -0,0 +1,314 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { requestDirectRoute, checkDirectRoute } from '../../src/utils/directRoutes.js'; +import { directRouteSpecs } from '../../src/utils/directRouteSpecs.js'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); +describe('direct routes', () => { + it('defines only fixed official routes and pins GS inventory without network access', async () => { + expect(Object.keys(directRouteSpecs)).toHaveLength(20); + const fetcher = vi.fn(); + vi.stubGlobal('fetch', fetcher); + await expect(requestDirectRoute('gs25-stock', {}, 3000)).rejects.toThrow('pinned-relay'); + expect(await checkDirectRoute('gs25-stock')).toMatchObject({ + direct: false, + reason: 'pinned-relay', + }); + expect(fetcher).not.toHaveBeenCalled(); + }); + it('shares strict meaningful response validation with real traffic', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ status: 'SUCCESS', data: {} })), + ); + await expect(requestDirectRoute('oy-find-store', {}, 3000)).rejects.toThrow('invalid-response'); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ status: 'SUCCESS', data: { storeList: [] } })), + ); + expect(await requestDirectRoute('oy-find-store', {}, 3000)).toMatchObject({ + status: 'SUCCESS', + }); + }); + it('builds exact stock metadata GET and popword POST query without forwarding credentials', async () => { + const f = vi.fn().mockResolvedValue( + Response.json({ + itemCd: '201051', + smCd: '201051', + stokMngCd: '201051', + stokMngQty: 1, + stockApplicationRate: '100', + }), + ); + vi.stubGlobal('fetch', f); + await requestDirectRoute('seven-stock-meta', { itemCd: '201051' }, 3000); + expect(String(f.mock.calls[0][0])).toBe( + 'https://new.7-elevenapp.co.kr/api/v1/open/product/search/stock?itemCd=201051', + ); + expect(f.mock.calls[0][1]).toMatchObject({ + method: 'GET', + redirect: 'manual', + credentials: 'omit', + }); + f.mockResolvedValue(Response.json({ data: [] })); + await requestDirectRoute('seven-popwords', { label: 'goods' }, 3000); + expect(String(f.mock.calls[1][0])).toContain('popword?label=goods'); + expect(f.mock.calls[1][1].body).toBe('{}'); + }); + it('fails closed with fixed reasons on HTTP, malformed JSON, and transport failure', async () => { + for (const response of [ + new Response('denied', { status: 403 }), + new Response('{'), + Response.json({}), + ]) { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)); + expect(await checkDirectRoute('cu-stock')).toMatchObject({ direct: false }); + } + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('private details'))); + expect(await checkDirectRoute('cu-stock')).toMatchObject({ reason: 'network-error' }); + }); + it('bounds stalled fetch and body reads, oversize responses and cancellation', async () => { + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {}))); + const p = requestDirectRoute('cu-prime', {}, 100); + const rejected = expect(p).rejects.toThrow('timeout'); + await vi.advanceTimersByTimeAsync(100); + await rejected; + vi.useRealTimers(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('x'.repeat(2 * 1024 * 1024 + 1))), + ); + await expect(requestDirectRoute('cu-prime', {}, 3000)).rejects.toThrow('response-too-large'); + const c = new AbortController(); + c.abort(); + await expect(requestDirectRoute('cu-prime', {}, 3000, c.signal)).rejects.toThrow('cancelled'); + }); +}); + +it('rejects success envelopes missing endpoint data structures', async () => { + for (const key of [ + 'cu-prime', + 'seven-goods', + 'seven-store', + 'seven-popwords', + 'seven-pages', + 'seven-issues', + 'seven-exhibitions', + 'gs25-products', + ] as const) { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + Response.json( + key === 'cu-prime' + ? {} + : key === 'gs25-products' + ? { SearchQueryResult: {} } + : { data: {} }, + ), + ), + ); + await expect(requestDirectRoute(key, directRouteSpecs[key].probe(), 3000)).rejects.toThrow( + 'invalid-response', + ); + } +}); + +it('preserves invalid request HTTP statuses and caller cancellation for route policy', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 422 }))); + await expect(requestDirectRoute('cu-prime', {}, 3000)).rejects.toMatchObject({ status: 422 }); + const caller = new AbortController(); + caller.abort(); + await expect(requestDirectRoute('cu-prime', {}, 3000, caller.signal)).rejects.toMatchObject({ + name: 'AbortError', + }); +}); + +const validResponses: Record = { + 'oy-find-store': { status: 'SUCCESS', data: { storeList: [] } }, + 'oy-product-search': { status: 'SUCCESS', data: { searchList: [] } }, + 'oy-goods-info': { + status: 'SUCCESS', + data: { goodsInfo: { masterGoodsNumber: '8800362322138' } }, + }, + 'oy-stock-stores': { status: 'SUCCESS', data: { storeList: [] } }, + 'cu-prime': { resp_cd: '0000', areaCateList: [] }, + 'cu-stock': { resp_cd: '0000', data: { stockResult: { result: { rows: [] } } } }, + 'cu-store': { resp_cd: '0000', storeList: [] }, + 'seven-goods': { + data: { SearchQueryResult: { Collection: [{ Documentset: { Document: [] } }] } }, + }, + 'seven-store': { data: { SearchQueryResult: { Collection: [] } } }, + 'seven-popwords': { data: [] }, + 'seven-stock-meta': { + itemCd: '201051', + smCd: '201051', + stokMngCd: '201051', + stokMngQty: 1, + stockApplicationRate: '100', + }, + 'seven-stock': { data: { storeList: [{ storeCd: '54928', stock: 2 }] } }, + 'seven-pages': { data: [] }, + 'seven-issues': { data: { content: [] } }, + 'seven-exhibitions': { data: { list: [] } }, + 'gs25-products': { SearchQueryResult: { Collection: [{ Documentset: { Document: [] } }] } }, + 'dtryx-movies': { RetCode: 'success', Recordset: [] }, + 'dtryx-play-dates': { RetCode: 'success', Recordset: [] }, + 'dtryx-timetable': { RetCode: 'success', Recordset: [] }, +}; +it('validates every official route fixture and uses the same descriptor for checks', async () => { + for (const [key, value] of Object.entries(validResponses)) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json(value)), + ); + const check = await checkDirectRoute(key as keyof typeof directRouteSpecs); + expect(check, key).toMatchObject({ direct: true }); + const request = vi.mocked(fetch).mock.calls[0]; + expect(request[1]).toMatchObject({ redirect: 'manual', credentials: 'omit' }); + expect(new Headers(request[1]?.headers).has('Authorization')).toBe(false); + } +}); +it('rejects endpoint business failure and invalid coordinates instead of accepting empty defaults', async () => { + for (const key of ['cu-stock', 'cu-store'] as const) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ ...(validResponses[key] as object), resp_cd: '3000' })), + ); + expect(await checkDirectRoute(key)).toMatchObject({ + direct: false, + reason: 'invalid-response', + }); + } + for (const [latVal, longVal] of [ + ['91', '0'], + ['0', '181'], + ]) { + await expect( + requestDirectRoute( + 'cu-store', + { ...(directRouteSpecs['cu-store'].probe() as object), latVal, longVal }, + 3000, + ), + ).rejects.toThrow(); + } + for (const data of [ + {}, + { success: false, data: [] }, + { data: [], code: 500 }, + { data: [], code: 199 }, + { data: [], code: 300 }, + ]) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json(data)), + ); + expect(await checkDirectRoute('seven-pages')).toMatchObject({ direct: false }); + } + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ data: [], success: true, code: 200 })), + ); + expect(await checkDirectRoute('seven-pages')).toMatchObject({ direct: true }); + await expect( + requestDirectRoute('dtryx-timetable', { brandCode: 'indieart', cinemaCode: '000067' }, 3000), + ).rejects.toThrow('invalid-input'); + expect(await checkDirectRoute('cu-store', 0)).toMatchObject({ reason: 'timeout' }); +}); +it('bounds streaming bodies and supports cancellation after fetch starts', async () => { + vi.useFakeTimers(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(new ReadableStream({ start() {} }))), + ); + const pending = requestDirectRoute('cu-prime', {}, 20); + const assertion = expect(pending).rejects.toThrow('timeout'); + await vi.advanceTimersByTimeAsync(20); + await assertion; + vi.useRealTimers(); + const caller = new AbortController(); + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise(() => {})), + ); + const cancelled = requestDirectRoute('cu-prime', {}, 3000, caller.signal); + caller.abort(); + await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null)), + ); + await expect(requestDirectRoute('cu-prime', {}, 3000)).rejects.toThrow('invalid-response'); +}); + +it('absorbs cancellation cleanup failures and classifies invalid probe input', async () => { + const probe = vi.spyOn(directRouteSpecs['cu-store'], 'probe').mockReturnValue({}); + expect(await checkDirectRoute('cu-store')).toMatchObject({ reason: 'invalid-input' }); + probe.mockRestore(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + status: 403, + body: { + cancel: async () => { + throw Error('cancel'); + }, + }, + })), + ); + expect(await checkDirectRoute('cu-stock')).toMatchObject({ reason: 'http-error' }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + status: 200, + body: { + getReader: () => ({ + read: async () => ({ done: true }), + cancel: async () => { + throw Error('cancel'); + }, + }), + }, + })), + ); + expect(await checkDirectRoute('cu-stock')).toMatchObject({ reason: 'invalid-response' }); +}); + +it('distinguishes its deadline from caller cancellation when fetch respects abort', async () => { + vi.useFakeTimers(); + vi.stubGlobal( + 'fetch', + vi.fn( + (_url, init) => + new Promise((_resolve, reject) => { + init.signal.addEventListener('abort', () => + reject(new DOMException('native abort', 'AbortError')), + ); + }), + ), + ); + const deadline = requestDirectRoute('cu-prime', {}, 10); + const assertion = expect(deadline).rejects.toMatchObject({ reason: 'timeout' }); + await vi.advanceTimersByTimeAsync(10); + await assertion; +}); + +it('records probe start time before a slow response completes', async () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return Response.json({ resp_cd: '0000', areaCateList: [] }); + }), + ); + const probe = checkDirectRoute('cu-prime'); + await vi.advanceTimersByTimeAsync(100); + expect(await probe).toMatchObject({ direct: true, checkedAt: 1000 }); +}); diff --git a/tests/utils/routeHealth.test.ts b/tests/utils/routeHealth.test.ts new file mode 100644 index 00000000..47a68420 --- /dev/null +++ b/tests/utils/routeHealth.test.ts @@ -0,0 +1,488 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { HttpError } from '../../src/utils/http.js'; +import { + routeOperation, + readRouteSnapshot, + writeRouteChecks, + withRouteRouting, + type RouteSnapshot, +} from '../../src/utils/routeHealth.js'; + +let sequence = 0; +function namespace(snapshot: RouteSnapshot = { routes: {}, relays: {} }) { + const pending: Promise[] = []; + const fetch = vi.fn(async () => Response.json(snapshot)); + const id = `namespace-${sequence++}`; + const ns = { + idFromName: () => ({ toString: () => id }), + get: () => ({ fetch }), + } as unknown as DurableObjectNamespace; + const run = ( + key = 'cu-stock' as const, + direct = vi.fn(async () => 'direct'), + relay = vi.fn(async () => 'relay'), + signal?: AbortSignal, + ) => + withRouteRouting( + ns, + (p) => pending.push(p), + () => routeOperation(key, 10000, direct, relay, signal), + ); + return { ns, fetch, pending, run }; +} +async function warm(snapshot: RouteSnapshot) { + const n = namespace(snapshot); + await n.run(); + await Promise.all(n.pending); + n.fetch.mockClear(); + n.pending.length = 0; + return n; +} +beforeEach(() => { + vi.useRealTimers(); +}); +describe('route health', () => { + it('preserves old relay routing without complete context', async () => { + const direct = vi.fn(async () => 'direct'); + const relay = vi.fn(async (ms) => ms); + expect(await routeOperation('cu-stock', 7000, direct, relay)).toBe(7000); + expect( + await withRouteRouting(namespace().ns, undefined, () => + routeOperation('cu-stock', 7000, direct, relay), + ), + ).toBe(7000); + expect(direct).not.toHaveBeenCalled(); + }); + it('cold decisions never await snapshot and warm decisions use direct', async () => { + const n = await warm({ + routes: { 'cu-stock': { key: 'cu-stock', direct: true, checkedAt: Date.now() } }, + relays: {}, + }); + const direct = vi.fn(async (ms) => `direct-${ms}`); + expect(await n.run(undefined, direct)).toBe('direct-3000'); + expect(n.fetch).not.toHaveBeenCalled(); + }); + it('cold pending snapshot cannot delay relay', async () => { + const n = namespace(); + n.fetch.mockImplementation(() => new Promise(() => {})); + expect(await n.run()).toBe('relay'); + }); + it('isolates namespaces and pins GS stock', async () => { + const n = await warm({ + routes: { 'gs25-stock': { key: 'gs25-stock', direct: true, checkedAt: Date.now() } }, + relays: {}, + }); + expect( + await withRouteRouting( + n.ns, + (p) => n.pending.push(p), + () => + routeOperation( + 'gs25-stock', + 1000, + async () => 'direct', + async () => 'relay', + ), + ), + ).toBe('relay'); + expect(await namespace().run()).toBe('relay'); + }); + it('fails over and immediately blocks subsequent direct calls', async () => { + const n = await warm({ + routes: { 'cu-stock': { key: 'cu-stock', direct: true, checkedAt: Date.now() } }, + relays: {}, + }); + const direct = vi.fn(async () => { + throw new HttpError(403, 'Forbidden', 'secret'); + }); + expect(await n.run(undefined, direct)).toBe('relay'); + expect(await n.run(undefined, direct)).toBe('relay'); + expect(direct).toHaveBeenCalledTimes(1); + await Promise.all(n.pending); + expect(n.fetch.mock.calls.length).toBe(1); + }); + it.each([400, 404, 422])('preserves caller HTTP %s errors', async (status) => { + const n = await warm({ + routes: { 'cu-stock': { key: 'cu-stock', direct: true, checkedAt: Date.now() } }, + relays: {}, + }); + const error = new HttpError(status, '', ''); + const relay = vi.fn(async () => 'relay'); + await expect( + n.run( + undefined, + async () => { + throw error; + }, + relay, + ), + ).rejects.toBe(error); + expect(relay).not.toHaveBeenCalled(); + }); + it('fails fast only on fresh evidence that both paths are unavailable', async () => { + const n = await warm({ + routes: { 'cu-stock': { key: 'cu-stock', direct: false, checkedAt: Date.now() } }, + relays: { convenience: { group: 'convenience', healthy: false, checkedAt: Date.now() } }, + }); + await expect(n.run()).rejects.toMatchObject({ status: 503 }); + }); + it('does not fail over when caller aborts', async () => { + const n = await warm({ + routes: { 'cu-stock': { key: 'cu-stock', direct: true, checkedAt: Date.now() } }, + relays: {}, + }); + const c = new AbortController(); + const relay = vi.fn(async () => 'relay'); + await expect( + n.run( + undefined, + async () => { + c.abort(); + throw c.signal.reason; + }, + relay, + c.signal, + ), + ).rejects.toBe(c.signal.reason); + expect(relay).not.toHaveBeenCalled(); + }); + it('returns safe snapshot on read errors and hides write errors', async () => { + const n = namespace(); + n.fetch.mockRejectedValue(new Error('secret')); + expect(await readRouteSnapshot(n.ns)).toEqual({ routes: {}, relays: {} }); + await expect(writeRouteChecks(n.ns, [], [])).resolves.toBeUndefined(); + }); +}); + +describe('routing edge cases', () => { + it('preserves stale and blocked routing while refreshing without awaiting', async () => { + const n = await warm({ + routes: { 'cu-stock': { key: 'cu-stock', direct: true, checkedAt: Date.now() - 600001 } }, + relays: { convenience: { group: 'convenience', healthy: false, checkedAt: 0 } }, + }); + expect(await n.run()).toBe('relay'); + const blocked = await warm({ + routes: { + 'cu-stock': { + key: 'cu-stock', + direct: true, + checkedAt: Date.now(), + failedAt: Date.now(), + blockedUntil: Date.now() + 300000, + }, + }, + relays: {}, + }); + expect(await blocked.run()).toBe('relay'); + }); + it('keeps actual failure when pending older snapshot arrives', async () => { + vi.useFakeTimers(); + const now = Date.now(); + const n = await warm({ + routes: { 'cu-stock': { key: 'cu-stock', direct: true, checkedAt: now } }, + relays: {}, + }); + let fail!: (error: Error) => void; + const active = n.run( + undefined, + () => + new Promise((_resolve, reject) => { + fail = reject; + }), + ); + vi.advanceTimersByTime(30001); + let release!: (r: Response) => void; + n.fetch.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + expect(await n.run()).toBe('relay'); + fail(new Error('network')); + await expect(active).rejects.toThrow('network'); + release( + Response.json({ + routes: { 'cu-stock': { key: 'cu-stock', direct: true, checkedAt: now } }, + relays: {}, + }), + ); + await Promise.all(n.pending); + expect(await n.run()).toBe('relay'); + vi.useRealTimers(); + }); + it('does not call fallback when original budget is exhausted', async () => { + vi.useFakeTimers(); + const n = await warm({ + routes: { 'cu-stock': { key: 'cu-stock', direct: true, checkedAt: Date.now() } }, + relays: {}, + }); + const error = new Error('failure'); + const relay = vi.fn(async () => 'relay'); + await expect( + n.run( + undefined, + async () => { + vi.advanceTimersByTime(10001); + throw error; + }, + relay, + ), + ).rejects.toBe(error); + expect(relay).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + it('reduces fallback budget by elapsed direct time', async () => { + vi.useFakeTimers(); + const n = await warm({ + routes: { 'cu-stock': { key: 'cu-stock', direct: true, checkedAt: Date.now() } }, + relays: {}, + }); + const relay = vi.fn(async () => 'relay'); + await n.run( + undefined, + async () => { + vi.advanceTimersByTime(350); + throw new Error('shape'); + }, + relay, + ); + expect(relay).toHaveBeenCalledWith(9650); + vi.useRealTimers(); + }); + it('rejects an already aborted caller before any callback', async () => { + const c = new AbortController(); + c.abort(); + const relay = vi.fn(async () => 'relay'); + await expect(namespace().run(undefined, undefined, relay, c.signal)).rejects.toBe( + c.signal.reason, + ); + expect(relay).not.toHaveBeenCalled(); + }); + it('safely ignores unavailable namespace identifiers', async () => { + const ns = { + idFromName: () => { + throw new Error('binding'); + }, + } as unknown as DurableObjectNamespace; + expect( + await withRouteRouting( + ns, + () => {}, + () => + routeOperation( + 'cu-stock', + 1000, + async () => 'direct', + async () => 'relay', + ), + ), + ).toBe('relay'); + }); + it('bounds reads, JSON bodies, and writes even when fetch ignores abort', async () => { + vi.useFakeTimers(); + const n = namespace(); + n.fetch.mockImplementation(() => new Promise(() => {})); + const read = readRouteSnapshot(n.ns); + const write = writeRouteChecks(n.ns, [], []); + await vi.advanceTimersByTimeAsync(2000); + expect(await read).toEqual({ routes: {}, relays: {} }); + await write; + const body = new ReadableStream(); + n.fetch.mockResolvedValue(new Response(body)); + const readBody = readRouteSnapshot(n.ns); + await vi.advanceTimersByTimeAsync(2000); + expect(await readBody).toEqual({ routes: {}, relays: {} }); + vi.useRealTimers(); + }); + it.each([null, {}, { routes: null, relays: {} }, { routes: {}, relays: null }])( + 'sanitizes malformed snapshot %j', + async (raw) => { + const n = namespace(); + n.fetch.mockResolvedValue(Response.json(raw)); + expect(await readRouteSnapshot(n.ns)).toEqual({ routes: {}, relays: {} }); + }, + ); + it('ignores non-success snapshot response', async () => { + const n = namespace(); + n.fetch.mockResolvedValue(new Response('', { status: 500 })); + expect(await readRouteSnapshot(n.ns)).toEqual({ routes: {}, relays: {} }); + }); + it('rejects malformed entries while copying only safe metadata', async () => { + const n = namespace(); + n.fetch.mockResolvedValue( + Response.json({ + routes: { + 'cu-stock': { key: 'cu-store', direct: true, checkedAt: 1 }, + 'cu-prime': { + key: 'cu-prime', + direct: true, + checkedAt: Date.now(), + failedAt: 1, + blockedUntil: 'invalid', + reason: 'ok', + token: 'secret', + }, + }, + relays: { + convenience: { group: 'dtryx', healthy: true, checkedAt: 1 }, + dtryx: { + group: 'dtryx', + healthy: true, + checkedAt: Date.now(), + reason: 'ok', + token: 'secret', + }, + }, + }), + ); + const result = await readRouteSnapshot(n.ns); + expect(result.routes['cu-stock']).toBeUndefined(); + expect(result.routes['cu-prime']).toEqual({ + key: 'cu-prime', + direct: true, + checkedAt: expect.any(Number), + failedAt: 1, + reason: 'ok', + }); + expect(result.relays.dtryx).not.toHaveProperty('token'); + expect(result.relays.convenience).toBeUndefined(); + }); + it.each(['oy-find-store', 'dtryx-movies'] as const)('maps %s relay group', async (key) => { + const n = await warm({ + routes: { [key]: { key, direct: false, checkedAt: Date.now() } }, + relays: { + oliveyoung: { group: 'oliveyoung', healthy: false, checkedAt: Date.now() }, + dtryx: { group: 'dtryx', healthy: false, checkedAt: Date.now() }, + }, + }); + await expect( + withRouteRouting( + n.ns, + () => {}, + () => + routeOperation( + key, + 1000, + async () => 'direct', + async () => 'relay', + ), + ), + ).rejects.toMatchObject({ status: 503 }); + }); +}); + +it('handles rejected background failure writes', async () => { + const n = await warm({ + routes: { 'cu-stock': { key: 'cu-stock', direct: true, checkedAt: Date.now() } }, + relays: {}, + }); + n.fetch.mockRejectedValue(new Error('storage unavailable')); + expect( + await n.run(undefined, async () => { + throw new Error('network'); + }), + ).toBe('relay'); + await expect(Promise.all(n.pending)).resolves.toBeDefined(); +}); + +it('recovers after an unpersisted failure when a probe after cooldown succeeds', async () => { + vi.useFakeTimers(); + const at = Date.now(); + const n = await warm({ + routes: { 'cu-stock': { key: 'cu-stock', direct: true, checkedAt: at } }, + relays: {}, + }); + n.fetch.mockRejectedValueOnce(new Error('storage write failed')); + await n.run(undefined, async () => { + throw new HttpError(403, '', ''); + }); + await Promise.all(n.pending); + vi.advanceTimersByTime(300001); + n.fetch.mockResolvedValue( + Response.json({ + routes: { 'cu-stock': { key: 'cu-stock', direct: true, checkedAt: Date.now() } }, + relays: {}, + }), + ); + await n.run(); + await Promise.all(n.pending); + expect(await n.run()).toBe('direct'); +}); +it('does not call a known unhealthy relay after an unexpected direct failure', async () => { + const at = Date.now(); + const n = await warm({ + routes: { 'cu-stock': { key: 'cu-stock', direct: true, checkedAt: at } }, + relays: { convenience: { group: 'convenience', healthy: false, checkedAt: at } }, + }); + const relay = vi.fn(async () => 'relay'); + await expect( + n.run( + undefined, + async () => { + throw new HttpError(403, '', ''); + }, + relay, + ), + ).rejects.toMatchObject({ status: 503 }); + expect(relay).not.toHaveBeenCalled(); +}); + +it('uses relay while an expired snapshot is being refreshed', async () => { + vi.useFakeTimers(); + const at = Date.now(); + const n = await warm({ + routes: { 'cu-stock': { key: 'cu-stock', direct: true, checkedAt: at } }, + relays: {}, + }); + vi.advanceTimersByTime(30001); + n.fetch.mockImplementation(() => new Promise(() => {})); + expect(await n.run()).toBe('relay'); + expect(await n.run()).toBe('relay'); +}); + +it('keeps a local failure when storage returns an empty snapshot', async () => { + vi.useFakeTimers(); + const at = Date.now(); + const n = await warm({ + routes: { 'cu-stock': { key: 'cu-stock', direct: true, checkedAt: at } }, + relays: {}, + }); + n.fetch.mockRejectedValueOnce(new Error('write failed')); + await n.run(undefined, async () => { + throw new HttpError(403, '', ''); + }); + await Promise.all(n.pending); + vi.advanceTimersByTime(30001); + n.fetch.mockResolvedValue(Response.json({ routes: {}, relays: {} })); + await n.run(); + await Promise.all(n.pending); + expect(await n.run()).toBe('relay'); +}); + +it.each([204, 302, 405, 408])( + 'falls back on unexpected HTTP %s rather than treating it as caller input', + async (status) => { + const n = await warm({ + routes: { 'cu-stock': { key: 'cu-stock', direct: true, checkedAt: Date.now() } }, + relays: {}, + }); + expect( + await n.run(undefined, async () => { + throw new HttpError(status, '', ''); + }), + ).toBe('relay'); + }, +); + +it('uses relay during snapshot refresh even if the expired cache had both paths down', async () => { + vi.useFakeTimers(); + const at = Date.now(); + const n = await warm({ + routes: { 'cu-stock': { key: 'cu-stock', direct: false, checkedAt: at } }, + relays: { convenience: { group: 'convenience', healthy: false, checkedAt: at } }, + }); + vi.advanceTimersByTime(30001); + n.fetch.mockImplementation(() => new Promise(() => {})); + expect(await n.run()).toBe('relay'); +}); diff --git a/tests/utils/routeProbes.test.ts b/tests/utils/routeProbes.test.ts new file mode 100644 index 00000000..e451b49e --- /dev/null +++ b/tests/utils/routeProbes.test.ts @@ -0,0 +1,126 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { refreshRouteChecks } from '../../src/utils/routeProbes.js'; +import { ROUTE_KEYS } from '../../src/utils/routeHealth.js'; +vi.mock('../../src/utils/directRoutes.js', () => ({ + checkDirectRoute: vi.fn(async (key) => ({ key, direct: key !== 'gs25-stock', checkedAt: 100 })), + boundedRouteJson: vi.fn(async () => ({ status: 'ok' })), +})); +import { checkDirectRoute, boundedRouteJson } from '../../src/utils/directRoutes.js'; +const fetcher = vi.fn(async () => Response.json({})); +const namespace = { + idFromName: vi.fn(() => ({})), + get: vi.fn(() => ({ fetch: fetcher })), +} as unknown as DurableObjectNamespace; +afterEach(() => vi.clearAllMocks()); +it('skips scheduling work when namespace is absent', async () => { + await refreshRouteChecks({}); + expect(checkDirectRoute).not.toHaveBeenCalled(); +}); +it('writes all twenty checks and authenticated health probes with fixed paths', async () => { + await refreshRouteChecks({ + UPSTREAM_ROUTE_HEALTH: namespace, + OY_RELAY_URL: 'https://oy.example', + OY_RELAY_TOKEN: 'oy', + CONVENIENCE_RELAY_URL: 'https://cv.example', + CONVENIENCE_RELAY_TOKEN: 'cv', + CONVENIENCE_ACCESS_CLIENT_ID: 'id', + CONVENIENCE_ACCESS_CLIENT_SECRET: 'secret', + DTRYX_RELAY_URL: 'https://dt.example', + DTRYX_RELAY_TOKEN: 'dt', + }); + expect(checkDirectRoute).toHaveBeenCalledTimes(20); + expect(vi.mocked(boundedRouteJson).mock.calls.map(([url]) => String(url))).toEqual([ + 'https://oy.example/health', + 'https://cv.example/v1/convenience/health', + 'https://dt.example/v1/dtryx/health', + ]); + expect(vi.mocked(boundedRouteJson).mock.calls[1][1].headers).toMatchObject({ + Authorization: 'Bearer cv', + 'CF-Access-Client-Id': 'id', + 'CF-Access-Client-Secret': 'secret', + }); + expect(fetcher).toHaveBeenCalledTimes(1); + const payload = JSON.parse( + (fetcher.mock.calls[0] as unknown as [string, RequestInit])[1].body as string, + ); + expect(payload.checks.map((c: { key: string }) => c.key)).toEqual(ROUTE_KEYS); + expect(payload.relays).toHaveLength(3); +}); +it('writes partial failures independently without exposing exception details', async () => { + vi.mocked(checkDirectRoute).mockRejectedValueOnce(new Error('private')); + await refreshRouteChecks({ UPSTREAM_ROUTE_HEALTH: namespace }); + const payload = JSON.parse( + (fetcher.mock.calls[0] as unknown as [string, RequestInit])[1].body as string, + ); + expect(payload.checks[0]).toMatchObject({ direct: false, reason: 'network-error' }); + expect(payload.relays.every((r: { reason: string }) => r.reason === 'configuration')).toBe(true); +}); + +it('marks ready Olive Young healthy even with an exhausted quota', async () => { + vi.mocked(boundedRouteJson).mockResolvedValueOnce({ state: 'ready', quota: { remaining: 0 } }); + await refreshRouteChecks({ + UPSTREAM_ROUTE_HEALTH: namespace, + OY_RELAY_URL: 'http://localhost:3000/', + OY_RELAY_TOKEN: 'token', + }); + const payload = JSON.parse( + (fetcher.mock.calls[0] as unknown as [string, RequestInit])[1].body as string, + ); + expect( + payload.relays.find((relay: { group: string }) => relay.group === 'oliveyoung'), + ).toMatchObject({ healthy: true }); +}); +it('rejects incomplete Access credentials, unsafe URL and invalid health bodies', async () => { + for (const url of [ + 'oops', + 'http://evil.example', + 'https://user:pw@relay.example', + 'https://relay.example?x=1', + 'https://relay.example#x', + ]) { + await refreshRouteChecks({ + UPSTREAM_ROUTE_HEALTH: namespace, + OY_RELAY_URL: url, + OY_RELAY_TOKEN: 'token', + }); + } + await refreshRouteChecks({ + UPSTREAM_ROUTE_HEALTH: namespace, + OY_RELAY_URL: 'https://relay.example', + OY_RELAY_TOKEN: 'token', + OY_ACCESS_CLIENT_ID: 'id', + }); + for (const result of [null, [], 3, { state: 'warming' }]) { + vi.mocked(boundedRouteJson).mockResolvedValueOnce(result); + await refreshRouteChecks({ + UPSTREAM_ROUTE_HEALTH: namespace, + OY_RELAY_URL: 'https://relay.example/', + OY_RELAY_TOKEN: 'token', + }); + } + vi.mocked(boundedRouteJson).mockRejectedValueOnce(new Error('private')); + await refreshRouteChecks({ + UPSTREAM_ROUTE_HEALTH: namespace, + DTRYX_RELAY_URL: 'https://relay.example', + DTRYX_RELAY_TOKEN: 'token', + }); + const payload = JSON.parse( + (fetcher.mock.calls.at(-1) as unknown as [string, RequestInit])[1].body as string, + ); + expect(payload.relays.at(-1)).toMatchObject({ healthy: false, reason: 'network-error' }); +}); + +it('limits outstanding probes to four and retains all independently completed checks', async () => { + let active = 0; + let peak = 0; + vi.mocked(checkDirectRoute).mockImplementation(async (key) => { + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setTimeout(resolve, 1)); + active -= 1; + return { key, direct: key !== 'gs25-stock', checkedAt: 100 }; + }); + await refreshRouteChecks({ UPSTREAM_ROUTE_HEALTH: namespace }); + expect(peak).toBe(4); + expect(active).toBe(0); +}); diff --git a/wrangler.toml b/wrangler.toml index 51b61f0b..9862745a 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -22,3 +22,14 @@ class_name = "DailyRateLimiter" [[migrations]] tag = "v1" new_sqlite_classes = ["DailyRateLimiter"] + +[[durable_objects.bindings]] +name = "UPSTREAM_ROUTE_HEALTH" +class_name = "UpstreamRouteHealth" + +[[migrations]] +tag = "v2" +new_sqlite_classes = ["UpstreamRouteHealth"] + +[triggers] +crons = ["*/5 * * * *"]