diff --git a/.env.example b/.env.example index 676a7e64..446d1e63 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,6 @@ OY_ACCESS_CLIENT_SECRET= # CGV_RELAY_TOKEN=your_dtryx_relay_token # CGV_ACCESS_CLIENT_ID=your_access_client_id # CGV_ACCESS_CLIENT_SECRET=your_access_client_secret + +# 일본 상품 상세만 기존 DTRYX 인증 HTTP 중계로 조회할 때 true +DAISO_JP_PRODUCT_RELAY_ENABLED=false diff --git a/docs/daiso-jp-network-analysis.md b/docs/daiso-jp-network-analysis.md index 41b0f5dc..828575c6 100644 --- a/docs/daiso-jp-network-analysis.md +++ b/docs/daiso-jp-network-analysis.md @@ -58,3 +58,14 @@ npx daiso jp-stores 東京都渋谷区 --json 정기 deep/full의 CLI 계약 검사에 일본 상세 `/api/daiso-jp/products/4550746075917`을 추가했습니다. 일본 REST 상품·상세·매장에도 다른 서비스와 같은 인증된 `x-health-check-force-fresh: true` 캐시 우회를 적용했습니다. 인증 없는 요청은 기존 캐시 정책을 유지하며, 검사 실패를 성공 캐시로 숨기지 않습니다. 같은 날 운영 반영 후 인증된 캐시 우회 상세503 및 CLI/MCP 실패가 다시 재현됐고, 별도 공식 코드 검색은 정확한 상품으로200을 반환했습니다. 후속 수정에서는 상품 코드 검색을 먼저 확보한 뒤 상세 HTML로 온라인 판매 여부를 보강합니다. 상세403이면 이미 확보한 정확한ID만 제한 안내와 함께 반환하며, 검색이 실패해도 상세 성공은 사용합니다. 상세404·429·500이나 파싱 오류는 검색 성공으로 감추지 않습니다. 조회는 검색1회·상세1회로 제한하며 재시도를 추가하지 않습니다. 직접 상세가 성공하던 환경에는 검색1회가 추가되는 비용이 있습니다. 이 순서 변경의 실제 운영 검증과 원본403의 영구 해소 여부는 구분합니다. + + +### 최종 상세 전송: 명시적 Mac HTTP 중계 + +앞의 검색 선조회 순서 변경은 실제 운영403을 해소하지 못해 철회했습니다. 로컬과 자체 배포에서 별도 설정이 없으면 기존 상세→403시 정확한코드검색 흐름을 유지합니다. 운영 Worker는 `DAISO_JP_PRODUCT_RELAY_ENABLED="true"`를 명시하고 기존 DTRYX_RELAY_URL/TOKEN/ACCESS_CLIENT_ID/ACCESS_CLIENT_SECRET 설정으로 일본 상세만 HTTP 중계에 연결합니다. 이 플래그가 없으면 DTRYX 설정만으로 일본 전송이 바뀌지 않습니다. + +`POST /v1/daiso-jp/product`는 `{ "id": "4550746075917" }`만 받으며 임의 URL을 허용하지 않습니다. Mac에서 공식 `https://jp.daisonet.com/products/{id}.js`만 읽고 요청ID와 대조합니다. 리다이렉트는 거절하고 본문2MiB·전체15초 제한을 적용합니다. Worker도 같은 파서로 응답ID를 다시 검증합니다. 재시작할 때 공유 파서의 `src/services/daisoJp/parser.ts`와 types.ts도 runtime에 배치해야 합니다. + +상품 검색·매장 검색은 기존 직접 경로를 유지하며 상세에는 브라우저·가상기기·새 로그인·유료 프록시가 필요하지 않습니다. 상세는 연결시험 후 재요청하는 대신 명시적 중계 경로를 사용합니다. 공통 DTRYX 중계의 건강 상태는 기존5분 Cron으로 확인되며 일본 상세는 deep/full CLI 계약에서 실제 조회합니다. 별도 직접상세 probe를 늘리지 않습니다. + +일본 상세는 DTRYX/CGV와 동일한 호출 예산 원장을 공유하지만 서비스별 접수·동시성 큐는 별도입니다. 상품정보 캐시는 기존 REST와 동일하게30분이며 인증된 force-fresh는 Worker와 중계 양쪽을 우회합니다. 자체 할당량429·중계인증·원본차단·미확인판매 여부를 성공이나 재고0으로 바꾸지 않습니다. 원본 HTTP상태는 비공개 중계로그에서 확인하고 Worker 공개진단의 upstreamStatus는 중계응답 상태입니다. 일본 오프라인 매장 재고 미지원 범위는 그대로입니다. diff --git a/scripts/relay/daiso-jp.ts b/scripts/relay/daiso-jp.ts new file mode 100644 index 00000000..e6b762b3 --- /dev/null +++ b/scripts/relay/daiso-jp.ts @@ -0,0 +1,22 @@ +/** 고정 일본 다이소 상품 JSON만 제공하며 임의 URL을 받지 않습니다. */ +import { parseProductDetail } from '../../src/services/daisoJp/parser.js'; +import { createHttpRelay, RelayError, abortable, readJson, type HttpRelayOptions } from './http-relay.js'; +export function createJapanProductRelay(token: string, options: Pick) { + return createHttpRelay(token, { + ...options, prefix: '/v1/daiso-jp/', operations: ['product'], cacheTtl: () => 1800000, + validate(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new RelayError(400); + const p = value as Record; + if (Object.keys(p).length !== 1 || typeof p.id !== 'string' || !/^\d{13}$/.test(p.id)) throw new RelayError(400); + return { id: p.id }; + }, + async upstream(_operation, value, signal, fetcher) { + const { id } = value as { id: string }; + const response = await abortable(fetcher(new URL(`https://jp.daisonet.com/products/${id}.js`), { redirect: 'manual', signal }), signal); + if (response.status !== 200) { void response.body?.cancel().catch(() => undefined); throw new RelayError(502, undefined, undefined, response.status); } + const data = await readJson(response.body, 2 * 1024 * 1024, signal, 502); + parseProductDetail(data, id); + return data; + }, + }); +} diff --git a/scripts/relay/dtryx-start.ts b/scripts/relay/dtryx-start.ts index 5ec69e19..0dadc554 100644 --- a/scripts/relay/dtryx-start.ts +++ b/scripts/relay/dtryx-start.ts @@ -9,6 +9,7 @@ import { pathToFileURL } from 'node:url'; import { createFileQuota, assertSeparateQuotaDirectories, CONVENIENCE_QUOTA_LIMITS } from './quota.js'; import { createConvenienceRelay } from './convenience.js'; import { createGs25SessionTransport } from './gs25-session.js'; +import { createJapanProductRelay } from './daiso-jp.js'; import { createCgvRelay } from './cgv.js'; import { createDtryxRelay } from './dtryx.js'; @@ -72,8 +73,11 @@ async function main() { } return response; }, log.append); + const japanRelay = createJapanProductRelay(token, { takeQuota, onEvent: log.append }); + const japanHandler = observeRelay(japanRelay, log.append); const handler = (request: Request) => new URL(request.url).pathname.startsWith('/v1/convenience/') ? convenience ? convenience(request) : Promise.resolve(Response.json({ error: 'Not found' }, { status: 404 })) + : new URL(request.url).pathname.startsWith('/v1/daiso-jp/') ? japanHandler(request) : new URL(request.url).pathname.startsWith('/v1/cgv/') ? cgvHandler(request) : dtryxHandler(request); const server = createServer(async (req, res) => { const controller = new AbortController(); diff --git a/src/api/response.ts b/src/api/response.ts index 96167ecd..42074f10 100644 --- a/src/api/response.ts +++ b/src/api/response.ts @@ -6,6 +6,7 @@ import type { Context } from 'hono'; import { ServiceError, toServiceErrorDiagnostics, toStandardErrorDiagnostics } from '../core/errors.js'; export interface AppBindings { + DAISO_JP_PRODUCT_RELAY_ENABLED?: string; DAILY_RATE_LIMITER?: DurableObjectNamespace; UPSTREAM_ROUTE_HEALTH?: DurableObjectNamespace; GEOCODE_GATEWAY?: DurableObjectNamespace; diff --git a/src/api/routes/daisoJpRoutes.ts b/src/api/routes/daisoJpRoutes.ts index 8f973cd6..9d946bf6 100644 --- a/src/api/routes/daisoJpRoutes.ts +++ b/src/api/routes/daisoJpRoutes.ts @@ -5,6 +5,7 @@ import { successResponse, errorResponse, serviceErrorResponse } from '../respons import { withEdgeCache } from '../../utils/cache.js'; import { ServiceError } from '../../core/errors.js'; import { searchProducts, getProduct, findStores } from '../../services/daisoJp/client.js'; +import { japanProductTransportFromBindings } from '../../services/daisoJp/productRelay.js'; import { jpQuery, jpPage, jpProductId } from '../../services/daisoJp/index.js'; import { isFreshOperationalRequest } from '../operationalFreshness.js'; @@ -34,8 +35,9 @@ export function registerDaisoJpRoutes(app: Hono<{ Bindings: AppBindings }>): voi 'INVALID_INPUT', '상품 ID는 일본 다이소 검색 결과의 13자리 코드를 사용하세요.', ); - return withEdgeCache(c.req.url, { ttlSeconds: 1800, keyPrefix: 'daiso-jp-product-v1', bypassCache: await isFreshOperationalRequest(c) }, () => - execute(c, () => getProduct(id.data)), + const bypassCache = await isFreshOperationalRequest(c); + return withEdgeCache(c.req.url, { ttlSeconds: 1800, keyPrefix: 'daiso-jp-product-v1', bypassCache }, () => + execute(c, () => getProduct(id.data, japanProductTransportFromBindings(c.env, bypassCache))), ); }); app.get('/api/daiso-jp/products', async (c) => { diff --git a/src/index.ts b/src/index.ts index 9b404d4d..1a9e5509 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import { createDaisoService } from './services/daiso/index.js'; import { createOliveyoungService } from './services/oliveyoung/index.js'; import { createDtryxService } from './services/dtryx/index.js'; import { createMegaboxService } from './services/megabox/index.js'; +import { japanProductTransportFromBindings } from './services/daisoJp/productRelay.js'; import { cgvTransportFromBindings } from './services/cgv/relayTransport.js'; import { createCgvService } from './services/cgv/index.js'; import { createLotteCinemaService } from './services/lottecinema/index.js'; @@ -58,7 +59,6 @@ export { DailyRateLimiter } from './durableObjects/dailyRateLimiter.js'; const SERVER_NAME = 'multi-service-mcp'; const SERVER_VERSION = '1.0.0'; const SESSION_HEADER = 'mcp-session-id'; - // 세션별 MCP 전송 계층/서버를 유지해 GET/POST/DELETE를 일관 처리합니다. const mcpSessions = new Map< string, @@ -76,7 +76,7 @@ const createRegistry = (bindings?: AppBindings) => { registry.registerAll([ createDaisoService, - createDaisoJpService, + () => createDaisoJpService(japanProductTransportFromBindings(bindings)), () => createGs25Service({ ...convenienceTransportFromBindings(bindings), ...geocodeBindings(bindings), diff --git a/src/services/daisoJp/client.ts b/src/services/daisoJp/client.ts index 17ee8095..44f93500 100644 --- a/src/services/daisoJp/client.ts +++ b/src/services/daisoJp/client.ts @@ -1,6 +1,7 @@ import { ServiceError } from '../../core/errors.js'; import { fetchText, HttpError } from '../../utils/http.js'; import { parseProductPage, parseProductSearch, parseStoreSearch } from './parser.js'; +import { requestJapanProductRelay, type JapanTransportOptions } from './productRelay.js'; import type { JapanProduct, JapanProductSearchResult, JapanStoreSearchResult } from './types.js'; const OPTIONS = { timeout: 20000, retries: 0 }; @@ -44,19 +45,12 @@ export async function searchProducts(query: string, page = 1): Promise { +export async function getProduct(id: string, options: JapanTransportOptions = {}): Promise { if (typeof id !== 'string' || !/^\d{13}$/.test(id)) { throw new Error('상품 ID는 13자리 숫자여야 합니다.'); } + if (options.productRelay) return requestJapanProductRelay(id, options.productRelay); return upstream(async () => { - // 차단 응답 뒤 검색까지 거절될 수 있으므로 공식 코드 검색을 먼저 확보합니다. - let result: JapanProductSearchResult | undefined; - let searchError: unknown; - try { - result = await searchProducts(id, 1); - } catch (error) { - searchError = error; - } try { return parseProductPage( await fetchText(`https://jp.daisonet.com/products/${id}`, OPTIONS), @@ -64,8 +58,9 @@ export async function getProduct(id: string): Promise { ); } catch (error) { if (!(error instanceof HttpError) || error.status !== 403) throw error; - const match = result?.products.find((item) => item.id === id); - if (!match) throw searchError ?? error; + const result = await searchProducts(id, 1); + const match = result.products.find((item) => item.id === id); + if (!match) throw error; return { ...match, onlineAvailable: null, diff --git a/src/services/daisoJp/index.ts b/src/services/daisoJp/index.ts index b756823e..29de4d8e 100644 --- a/src/services/daisoJp/index.ts +++ b/src/services/daisoJp/index.ts @@ -2,6 +2,7 @@ import * as z from 'zod'; import type { ServiceProvider } from '../../core/interfaces.js'; import type { ToolRegistration } from '../../core/types.js'; +import type { JapanTransportOptions } from './productRelay.js'; import { searchProducts, getProduct, findStores } from './client.js'; export const jpQuery = z.string().trim().min(1).max(100); @@ -42,7 +43,7 @@ const storeOutput = z.object({ brand: z.string(), }); -export function createDaisoJpService(): ServiceProvider { +export function createDaisoJpService(options: JapanTransportOptions = {}): ServiceProvider { const tools: ToolRegistration[] = [ { name: 'daiso_jp_search_products', @@ -78,7 +79,7 @@ export function createDaisoJpService(): ServiceProvider { }, handler: async (args) => { const { id } = schemas.product.parse(args); - const product = await getProduct(id); + const product = await getProduct(id, options); const result = { ...product }; return { content: [{ type: 'text', text: JSON.stringify(result) }], diff --git a/src/services/daisoJp/productRelay.ts b/src/services/daisoJp/productRelay.ts new file mode 100644 index 00000000..32cf959c --- /dev/null +++ b/src/services/daisoJp/productRelay.ts @@ -0,0 +1,54 @@ +/** 일본 상세만 기존 인증 HTTP 중계에 명시적으로 연결합니다. */ +import { ServiceError } from '../../core/errors.js'; +import { diagnosticHeaders } from '../../utils/diagnostics.js'; +import { parseRelayQuota } from '../../utils/relayQuota.js'; +import { dtryxTransportFromBindings, isValidDtryxRelayUrl, type DtryxTransportOptions } from '../dtryx/transport.js'; +import { parseProductDetail } from './parser.js'; +import type { JapanProduct } from './types.js'; +export interface JapanProductRelayOptions extends DtryxTransportOptions { bypassCache?: boolean } +export interface JapanTransportOptions { productRelay?: JapanProductRelayOptions } +export function japanProductTransportFromBindings(bindings?: { + DAISO_JP_PRODUCT_RELAY_ENABLED?: string; + DTRYX_RELAY_URL?: string; DTRYX_RELAY_TOKEN?: string; + DTRYX_ACCESS_CLIENT_ID?: string; DTRYX_ACCESS_CLIENT_SECRET?: string; +}, bypassCache = false): JapanTransportOptions { + return bindings?.DAISO_JP_PRODUCT_RELAY_ENABLED === 'true' + ? { productRelay: { ...dtryxTransportFromBindings(bindings), bypassCache } } : {}; +} +export async function requestJapanProductRelay(id: string, options: JapanProductRelayOptions): Promise { + const { relayUrl, relayToken, accessClientId, accessClientSecret } = options; + if (!isValidDtryxRelayUrl(relayUrl) || !relayToken?.trim() || + ((accessClientId !== undefined || accessClientSecret !== undefined) && (!accessClientId?.trim() || !accessClientSecret?.trim()))) { + throw new ServiceError('DAISO_JP_RELAY_CONFIG_ERROR', '일본 다이소 중계 설정을 확인하세요.', 503, false); + } + const headers: Record = { ...diagnosticHeaders(), Authorization: `Bearer ${relayToken}`, 'Content-Type': 'application/json' }; + if (options.bypassCache) headers['x-relay-cache-bypass'] = 'true'; + if (accessClientId && accessClientSecret) { + headers['CF-Access-Client-Id'] = accessClientId; + headers['CF-Access-Client-Secret'] = accessClientSecret; + } + const controller = new AbortController(); + let timer: ReturnType; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { controller.abort(); reject(new ServiceError('DAISO_JP_RELAY_TIMEOUT', '일본 다이소 중계 요청 시간이 초과되었습니다.', 504, true)); }, 15000); + }); + try { + const work = (async () => { + const response = await fetch(`${relayUrl!.replace(/\/$/, '')}/v1/daiso-jp/product`, { + method: 'POST', redirect: 'manual', headers, body: JSON.stringify({ id }), signal: controller.signal, + }); + if (response.status !== 200) { + void response.body?.cancel().catch(() => undefined); + const status = response.status; + throw new ServiceError('DAISO_JP_RELAY_FAILED', '일본 다이소 중계 요청에 실패했습니다.', + status === 429 || status === 503 || status === 504 ? status : 502, + status === 429 || status >= 500, status, parseRelayQuota(status, response.headers)); + } + return parseProductDetail(await response.json(), id); + })(); + return await Promise.race([work, timeout]); + } catch (error) { + if (error instanceof ServiceError) throw error; + throw new ServiceError('DAISO_JP_RELAY_FAILED', '일본 다이소 중계 응답을 확인할 수 없습니다.', 502, true); + } finally { clearTimeout(timer!); } +} diff --git a/tests/relay/daiso-jp.test.ts b/tests/relay/daiso-jp.test.ts new file mode 100644 index 00000000..5b42ecd5 --- /dev/null +++ b/tests/relay/daiso-jp.test.ts @@ -0,0 +1,27 @@ +import { expect, it, vi } from 'vitest'; +import { createJapanProductRelay } from '../../scripts/relay/daiso-jp.js'; +const raw = { handle: '4550746075917', title: 'ボウル', price: 22000, available: true }; +const req = (body: unknown, token = 'test-token') => new Request('http://localhost/v1/daiso-jp/product', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify(body) }); +it('인증된 고정 상품 JSON만 읽고 실제 상품 코드와 대조한다', async () => { + const fetcher = vi.fn(async () => Response.json(raw)); + const takeQuota = vi.fn(async () => true); + const handler = createJapanProductRelay('test-token', { fetcher, takeQuota }); + expect((await handler(req({ id: raw.handle }, 'wrong'))).status).toBe(401); + for (const body of [null, [], {}, { id: 1 }, { id: '../x' }, { id: raw.handle, url: 'https://other' }]) expect((await handler(req(body))).status).toBe(400); + const response = await handler(req({ id: raw.handle })); + expect(response.status).toBe(200); expect(await response.json()).toEqual(raw); + expect(fetcher.mock.calls[0][0].toString()).toBe('https://jp.daisonet.com/products/4550746075917.js'); + expect(takeQuota).toHaveBeenCalledTimes(1); +}); +it('원본 차단과 잘못된 상품을 실패로 유지한다', async () => { + for (const response of [new Response('denied', { status: 403 }), new Response(null, { status: 403 }), Response.json({ ...raw, handle: '4550746075918' })]) { + const handler = createJapanProductRelay('test-token', { takeQuota: async () => true, fetcher: vi.fn(async () => response) }); + expect((await handler(req({ id: raw.handle }))).status).toBe(502); + } +}); + +it('원본 실패 본문의 회수 오류를 처리한다', async () => { + const fetcher = vi.fn(async () => ({ status: 403, body: { cancel: async () => { throw Error('cancel failed'); } } }) as unknown as Response); + const handler = createJapanProductRelay('test-token', { takeQuota: async () => true, fetcher }); + expect((await handler(req({ id: raw.handle }))).status).toBe(502); +}); diff --git a/tests/relay/dtryx-start-cgv.test.ts b/tests/relay/dtryx-start-cgv.test.ts index 170d21f7..559bd23e 100644 --- a/tests/relay/dtryx-start-cgv.test.ts +++ b/tests/relay/dtryx-start-cgv.test.ts @@ -34,7 +34,7 @@ it('CGV와 디트릭스 조회가 같은 쿼터를 소비하고 CGV 건강 상 vi.stubEnv('CONVENIENCE_RELAY_TOKEN', undefined); vi.stubEnv('CONVENIENCE_RELAY_STATE_DIR', undefined); vi.stubGlobal('fetch', vi.fn().mockImplementation((url: URL | string) => Promise.resolve( - String(url).includes('cgv.co.kr') ? Response.json({ statusCode: 0, data: [] }) : Response.json({ RetCode: 'success', Recordset: [] }), + new URL(String(url)).hostname === 'jp.daisonet.com' ? Response.json({ handle: '4550746075917', title: 'ボウル', price: 22000, available: true }) : ['api.cgv.co.kr', 'cgv.co.kr'].includes(new URL(String(url)).hostname) ? Response.json({ statusCode: 0, data: [] }) : Response.json({ RetCode: 'success', Recordset: [] }), ))); vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.spyOn(process, 'once').mockImplementation(((signal: string, listener: () => void) => { @@ -52,6 +52,10 @@ it('CGV와 디트릭스 조회가 같은 쿼터를 소비하고 CGV 건강 상 expect((await invoke('/v1/cgv/theaters', {}, 'wrong')).status).toBe(401); expect((await invoke('/v1/cgv/theaters', {})).status).toBe(200); expect((await invoke('/v1/dtryx/movies', { brandCode: 'dtryx', cinemaCode: '000067' })).status).toBe(200); + expect((await invoke('/v1/daiso-jp/product', { id: '4550746075917' }, 'wrong')).status).toBe(401); + const japan = await invoke('/v1/daiso-jp/product', { id: '4550746075917' }); + expect(japan.status).toBe(200); + expect(japan.body.handle).toBe('4550746075917'); const after = await invoke('/v1/cgv/health'); - expect(after.body.quota.dailyRemaining).toBe(before.body.quota.dailyRemaining - 2); + expect(after.body.quota.dailyRemaining).toBe(before.body.quota.dailyRemaining - 3); }); diff --git a/tests/services/daisoJp/client.test.ts b/tests/services/daisoJp/client.test.ts index 779b1c6d..5e35683b 100644 --- a/tests/services/daisoJp/client.test.ts +++ b/tests/services/daisoJp/client.test.ts @@ -11,6 +11,13 @@ const respond = (body: string, status = 200) => afterEach(() => vi.unstubAllGlobals()); describe('일본 공식 클라이언트', () => { + it('명시적 상세 중계는 원본 차단 요청 없이 동일 상품을 반환한다', async () => { + const fetcher = vi.fn(async () => Response.json({ handle: '4550746075917', title: 'ボウル', price: 22000, available: true })); + vi.stubGlobal('fetch', fetcher); + expect(await getProduct('4550746075917', { productRelay: { relayUrl: 'https://relay.example', relayToken: 'test-token' } })).toMatchObject({ id: '4550746075917', onlineAvailable: true }); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(fetcher.mock.calls[0][0]).toBe('https://relay.example/v1/daiso-jp/product'); + }); it('검색어를 인코딩하고 페이지와 출처 제한을 돌려준다', async () => { respond(fixture('products')); expect(await searchProducts(' ボウル ', 2)).toMatchObject({ @@ -99,30 +106,15 @@ describe('일본 공식 클라이언트', () => { }); describe('상세 페이지 차단 시 공식 상품 코드 검색', () => { - it('상세 차단 이후 검색도 거절되는 경우 먼저 확보한 동일 상품을 반환한다', async () => { - let denied = false; - vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { - if (new URL(String(input)).pathname.startsWith('/products/')) denied = true; - return denied ? new Response('denied', { status: 403 }) : new Response(fixture('products')); - })); - expect(await getProduct('4550746075917')).toMatchObject({ id: '4550746075917', onlineAvailable: null }); - expect(fetch).toHaveBeenCalledTimes(2); - }); - it('코드 검색과 상세가 모두 성공하면 상세의 온라인 판매 여부를 유지한다', async () => { - vi.stubGlobal('fetch', vi.fn() - .mockResolvedValueOnce(new Response(fixture('products'))) - .mockResolvedValueOnce(new Response(fixture('product-detail')))); - expect(await getProduct('4550746075917')).toMatchObject({ onlineAvailable: false, price: 220 }); - }); const deniedThen = (body: string, status = 200) => { const mock = vi .fn() - .mockResolvedValueOnce(new Response(body, { status })) - .mockResolvedValueOnce(new Response('secret denied', { status: 403 })); + .mockResolvedValueOnce(new Response('secret denied', { status: 403 })) + .mockResolvedValueOnce(new Response(body, { status })); vi.stubGlobal('fetch', mock); return mock; }; - it('상세403일 때 먼저 확보한 코드 검색의 정확한 ID와 제한 안내를 반환한다', async () => { + it('403일 때만 코드 검색으로 정확한 ID를 골라 제한 안내를 반환한다', async () => { const mock = deniedThen(fixture('products')); expect(await getProduct('4550746075917')).toMatchObject({ id: '4550746075917', @@ -131,7 +123,7 @@ describe('상세 페이지 차단 시 공식 상품 코드 검색', () => { note: '상세 페이지 접근 제한으로 공식 상품 코드 검색 결과를 반환합니다. 온라인 판매 가능 여부는 확인하지 못했습니다.', }); expect(mock).toHaveBeenCalledTimes(2); - const url = new URL(mock.mock.calls[0][0] as string); + const url = new URL(mock.mock.calls[1][0] as string); expect(url.pathname).toBe('/search'); expect(url.searchParams.get('q')).toBe('4550746075917'); expect(url.searchParams.get('page')).toBe('1'); @@ -158,16 +150,14 @@ describe('상세 페이지 차단 시 공식 상품 코드 검색', () => { status: 502, }); }); - it.each([404, 429, 500])('상세 페이지 %i는 검색 결과로 감추거나 재시도하지 않는다', async (status) => { - vi.stubGlobal('fetch', vi.fn() - .mockResolvedValueOnce(new Response(fixture('products'))) - .mockResolvedValueOnce(new Response('secret upstream error', { status }))); + it.each([404, 429, 500])('상세 페이지 %i는 후속 검색을 하지 않는다', async (status) => { + respond('secret upstream error', status); await expect(getProduct('4550746075917')).rejects.toMatchObject({ upstreamStatus: status }); - expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledTimes(1); }); it('직접 상세 조회 성공 시 확인된 온라인 판매 여부를 유지한다', async () => { respond(fixture('product-detail').replace('"available":false', '"available":true')); expect(await getProduct('4550746075917')).toMatchObject({ onlineAvailable: true, price: 220 }); - expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/services/daisoJp/integration.test.ts b/tests/services/daisoJp/integration.test.ts index d7bbab21..32a9dcbd 100644 --- a/tests/services/daisoJp/integration.test.ts +++ b/tests/services/daisoJp/integration.test.ts @@ -47,6 +47,16 @@ describe('일본 다이소 연결', () => { expect((await response.json()).data.cached).toBe(true); expect(match).toHaveBeenCalledTimes(1); }); + it('REST와 MCP 상세가 명시적 일본 중계 설정을 전달한다', async () => { + vi.mocked(getProduct).mockResolvedValue(product as never); + const options = { productRelay: { relayUrl: 'https://relay.example', relayToken: 'test-token' } }; + await createDaisoJpService(options).getTools()[1].handler({ id: product.id }); + expect(getProduct).toHaveBeenLastCalledWith(product.id, options); + await app().request('/api/daiso-jp/products/' + product.id, {}, { + DAISO_JP_PRODUCT_RELAY_ENABLED: 'true', DTRYX_RELAY_URL: 'https://relay.example', DTRYX_RELAY_TOKEN: 'test-token', + }); + expect(getProduct).toHaveBeenLastCalledWith(product.id, { productRelay: expect.objectContaining({ relayUrl: 'https://relay.example', relayToken: 'test-token', bypassCache: false }) }); + }); it('MCP 도구 세 가지와 입력 제약을 노출한다', async () => { const service = createDaisoJpService(); expect(service.metadata.id).toBe('daiso_jp'); diff --git a/tests/services/daisoJp/product-relay.test.ts b/tests/services/daisoJp/product-relay.test.ts new file mode 100644 index 00000000..d2132bb9 --- /dev/null +++ b/tests/services/daisoJp/product-relay.test.ts @@ -0,0 +1,48 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { requestJapanProductRelay, japanProductTransportFromBindings } from '../../../src/services/daisoJp/productRelay.js'; +const raw = { handle: '4550746075917', title: 'ボウル', price: 22000, available: true }; +const options = { relayUrl: 'https://relay.example', relayToken: 'test-token' }; +afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); +it('명시한 중계에서 같은 상품만 인증된 고정 경로로 읽는다', async () => { + expect(japanProductTransportFromBindings()).toEqual({}); + expect(japanProductTransportFromBindings({ DAISO_JP_PRODUCT_RELAY_ENABLED: 'true', DTRYX_RELAY_URL: options.relayUrl, DTRYX_RELAY_TOKEN: options.relayToken })).toEqual({ productRelay: { ...options, accessClientId: undefined, accessClientSecret: undefined, bypassCache: false } }); + const fetcher = vi.fn(async () => Response.json(raw)); vi.stubGlobal('fetch', fetcher); + expect(await requestJapanProductRelay(raw.handle, { ...options, bypassCache: true, accessClientId: 'test-id', accessClientSecret: 'test-secret' })).toMatchObject({ id: raw.handle, onlineAvailable: true, price: 220 }); + const [url, init] = fetcher.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe('https://relay.example/v1/daiso-jp/product'); + expect(init.redirect).toBe('manual'); + expect(init.headers).toMatchObject({ Authorization: 'Bearer test-token', 'x-relay-cache-bypass': 'true', 'CF-Access-Client-Id': 'test-id' }); +}); +it.each([{}, { relayUrl: options.relayUrl }, { ...options, relayToken: ' ' }, { ...options, accessClientId: ' ', accessClientSecret: 'x' }, { ...options, accessClientId: 'x', accessClientSecret: ' ' }, { relayUrl: 'http://remote.example', relayToken: 'x' }, { ...options, accessClientId: 'x' }, { ...options, accessClientSecret: 'x' }])('불완전한 중계 설정은 원본 요청 전에 거절한다', async (p) => { + vi.stubGlobal('fetch', vi.fn()); + await expect(requestJapanProductRelay(raw.handle, p)).rejects.toMatchObject({ code: 'DAISO_JP_RELAY_CONFIG_ERROR' }); + expect(fetch).not.toHaveBeenCalled(); +}); +it.each([302, 401, 429, 503, 504])('중계 HTTP%i를 성공으로 바꾸지 않는다', async (status) => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('private body', { status }))); + await expect(requestJapanProductRelay(raw.handle, options)).rejects.toMatchObject({ upstreamStatus: status }); +}); +it('본문 없는 중계 실패도 오류로 유지한다', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 401 }))); + await expect(requestJapanProductRelay(raw.handle, options)).rejects.toMatchObject({ status: 502 }); +}); +it('잘못된 JSON·다른 상품·네트워크 실패를 거절한다', async () => { + for (const response of [new Response('html'), Response.json({ ...raw, handle: '4550746075918' })]) { + vi.stubGlobal('fetch', vi.fn(async () => response)); + await expect(requestJapanProductRelay(raw.handle, options)).rejects.toMatchObject({ status: 502 }); + } + vi.stubGlobal('fetch', vi.fn(async () => { throw Error('private'); })); + await expect(requestJapanProductRelay(raw.handle, options)).rejects.toThrow('일본 다이소'); +}); +it('응답 본문이 멈춰도 전체 기한을 지킨다', async () => { + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn(async () => ({ status: 200, json: () => new Promise(() => {}) }))); + const pending = requestJapanProductRelay(raw.handle, options); + const assertion = expect(pending).rejects.toMatchObject({ status: 504 }); + await vi.advanceTimersByTimeAsync(15000); await assertion; +}); + +it('실패 응답 회수 오류는 원래 중계 오류를 바꾸지 않는다', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ status: 403, headers: new Headers(), body: { cancel: async () => { throw Error('cancel failed'); } } }))); + await expect(requestJapanProductRelay(raw.handle, options)).rejects.toMatchObject({ upstreamStatus: 403 }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 935048af..c9234504 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,7 +30,7 @@ export default defineConfig({ reporter: ['text', 'text-summary', 'html', 'json'], // 커버리지 대상 파일 - include: ['scripts/ops/health-check-retry.ts', 'src/**/*.ts', 'scripts/relay/cache.ts', 'scripts/relay/logging.ts', 'scripts/relay/observed.ts', 'scripts/relay/oliveyoung.ts', 'scripts/relay/dtryx.ts', 'scripts/relay/http-relay.ts', 'scripts/relay/convenience.ts', 'scripts/relay/gs25-session.ts', 'scripts/relay/browser.ts', 'scripts/relay/lifecycle.ts', 'scripts/relay/ownership.ts', 'scripts/relay/guard-controller.ts', 'scripts/relay/guard-runtime.ts', 'scripts/relay/supervisor.ts', 'scripts/relay/quota.ts', 'scripts/relay/consumer.ts', 'scripts/relay/browser-environment.ts', 'scripts/relay/http-lifecycle.ts', 'scripts/relay/transport-lifecycle.ts'], + include: ['scripts/relay/daiso-jp.ts', 'scripts/ops/health-check-retry.ts', 'src/**/*.ts', 'scripts/relay/cache.ts', 'scripts/relay/logging.ts', 'scripts/relay/observed.ts', 'scripts/relay/oliveyoung.ts', 'scripts/relay/dtryx.ts', 'scripts/relay/http-relay.ts', 'scripts/relay/convenience.ts', 'scripts/relay/gs25-session.ts', 'scripts/relay/browser.ts', 'scripts/relay/lifecycle.ts', 'scripts/relay/ownership.ts', 'scripts/relay/guard-controller.ts', 'scripts/relay/guard-runtime.ts', 'scripts/relay/supervisor.ts', 'scripts/relay/quota.ts', 'scripts/relay/consumer.ts', 'scripts/relay/browser-environment.ts', 'scripts/relay/http-lifecycle.ts', 'scripts/relay/transport-lifecycle.ts'], // 커버리지 제외 파일 exclude: [ diff --git a/wrangler.toml b/wrangler.toml index e4753f8f..2f5007f1 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -12,6 +12,7 @@ not_found_handling = "none" html_handling = "none" [vars] +DAISO_JP_PRODUCT_RELAY_ENABLED = "true" HEALTH_CHECK_BASE_URL = "https://daiso-mcp.hmmhmmhm.workers.dev" [observability]