diff --git a/docs/gs25-auth-recovery-runbook.md b/docs/gs25-auth-recovery-runbook.md index 29c38265..1ea7934a 100644 --- a/docs/gs25-auth-recovery-runbook.md +++ b/docs/gs25-auth-recovery-runbook.md @@ -82,6 +82,17 @@ JWT의 `exp`만 로컬에서 검사하고 토큰 자체·URL query·JWT 전체 c 구현: [gs25-session.ts](../scripts/relay/gs25-session.ts), [dtryx-start.ts](../scripts/relay/dtryx-start.ts), [convenience.ts](../scripts/relay/convenience.ts). 세션이 설정되면 `GS25_API_KEY`로 자동 폴백하지 않는다. 키만 바꾸는 것은 이번 복구 방법이 아니다. +### 만료된 access와 살아 있는 refresh를 구분한다 + +운영 프로세스가 403을 유지하더라도 파일의 refresh가 서버에서 철회됐다고 단정하지 않는다. 전송기는 갱신 401/403 또는 재시도 인증 거절을 메모리에 유지하므로, 파일의 토큰과 프로세스가 보관한 토큰이 다르거나 이전 거절이 있었다면 재기동 전까지 실패할 수 있다. 만료 시각이 미래인 refresh도 서버 유효성의 증거는 아니다. + +1. 두 서비스가 유휴 상태인지 확인하고 4절에 따라 기존 프로세스를 정상 종료한다. 비공개 작업 디렉터리에 현재 0600 세션의 복사본을 만든다. +2. 복사본을 `createGs25SessionTransport`에 연결해 고정 재고 URL을 한 번 호출한다. access가 만료됐다면 기존 전송기가 정상 `tokenReissue`를 수행하고 회전된 두 토큰을 복사본에 저장한다. JWT 만료를 수정하거나 옛 refresh로 수동 갱신을 반복하지 않는다. +3. 갱신 HTTP 상태·`resultCode`·재고 HTTP 상태만 기록한다. 실패 응답을 조사할 때도 content-type과 challenge 여부처럼 비밀이 없는 메타데이터만 기록한다. 전체 응답에는 회원정보가 포함될 수 있다. 갱신 401/403이 재현되면 정상 재로그인 절차로 진행한다. +4. 갱신과 실제 재고 조회가 성공하면 **회전된 복사본**을 4절의 원자 교체 절차로 설치하고 중계를 재기동한다. 옛 세션 복사본을 다시 설치하면 회전된 refresh를 잃을 수 있다. + +2026-10-11 KST 복구에서는 운영 재고 403, 만료된 access, 아직 만료되지 않은 refresh를 관측했다. 비공개 복사본을 사용한 정상 갱신은 HTTP 200/`0000`, 갱신 후 재고는 HTTP 200과 양수 재고를 반환했다. 갱신된 파일은 0600으로 저장했다. 이 증거는 파일 세션으로 갱신 가능함을 확인하지만, 이전 운영 프로세스의 403이 옛 메모리 토큰 때문인지 일시적인 원본 거절 때문인지는 확정하지 않는다. 로그인이나 토큰 계약을 바꾸지 않았고 인증 실패를 품절로 변환하지 않았다. + ## 3. 만료·철회 시 정상 네이버 로그인으로 GS 세션을 다시 발급한다 사용자의 로그인·추가 인증은 사용자가 직접 완료한다. 새 티켓을 발급하고 격리된 headed 브라우저에서 정상 흐름을 진행한다. 비밀번호·네이버 쿠키·개인정보를 저장소나 도구 출력에 남기지 않는다. diff --git a/docs/health-check-stock-and-quota.md b/docs/health-check-stock-and-quota.md index 56a29187..1d62023e 100644 --- a/docs/health-check-stock-and-quota.md +++ b/docs/health-check-stock-and-quota.md @@ -9,6 +9,12 @@ 확인된 수량 0은 품절입니다. 미확인 값·음수·상품 목록만 있는 응답은 정상 재고 응답과 구분합니다. 재고 증거가 없는 결과는 `fail`로 보고하며, 원본 응답과 진단은 점검 기록에 남깁니다. +## 캐시와 실제 원본 점검 + +`fresh=true`와 `x-health-check-force-fresh: true`를 함께 사용한 인증된 운영 점검은 결과 요약뿐 아니라 서비스 내부·중계 캐시도 우회합니다. 캐시된 성공 응답 뒤에 원본이 실패하면 점검도 실패합니다. 공개 요청에 플래그만 붙여서는 캐시를 우회할 수 없습니다. + +일반 사용자 요청의 캐시와 동일 요청 병합은 유지합니다. 점검 요청도 기존 동시성·대기·시간·호출량 상한을 적용받습니다. `ready` 중계 상태는 프로세스 실행 준비를 뜻하며, 실제 상품·매장·재고 점검 성공과 구분합니다. + ## 429의 출처 CU·GS25·세븐일레븐 Mac 중계는 같은 원장을 공유하며 분당 690회, UTC 하루 10,000,000회로 제한합니다. Cloudflare 직접 경로의 호출과 별개입니다. 분당 상한에 도달해도 일일 상한에 도달했다는 의미는 아닙니다. @@ -17,7 +23,7 @@ CU·GS25·세븐일레븐 Mac 중계는 같은 원장을 공유하며 분당 690 헬스체크 결과에도 `errorCode`, `upstreamStatus`, `quotaReason`, `retryAfter`를 보존합니다. Actions는 실패 직후 전체 점검을 다시 실행하지 않습니다. 정상 경로까지 반복 호출하며 중계 한도를 소비하던 동작을 제거했습니다. 첫 실패 기록을 보존하고 다음 점검에서 재확인합니다. -CGV·디트릭스 중계는 기존 디트릭스 원장을 공유합니다. 편의점 원장과 분리되어 있으며 이번 복구로 한도를 늘리거나 원장을 초기화하지 않습니다. +CGV·디트릭스 중계는 같은 원장을 공유하며 분당 300회, UTC 하루 300,000회로 제한합니다. 편의점 원장과 분리되어 있으며 원장을 초기화하지 않습니다. 편의점 중계가 자체 분·일 한도로 거부하면 HTTP 429에 `x-relay-quota-reason: minute|daily`와 `Retry-After`를 반환합니다. Worker는 이 값을 공개 진단과 재시도 대기 시간에 반영합니다. 외부 서비스가 반환한 429에는 자체 한도 사유를 붙이지 않습니다. diff --git a/docs/oliveyoung-http-relay.md b/docs/oliveyoung-http-relay.md index 1324b1a5..029513aa 100644 --- a/docs/oliveyoung-http-relay.md +++ b/docs/oliveyoung-http-relay.md @@ -35,3 +35,13 @@ HTTP 조회가 차단되면 원인을 확인하고 `OY_RELAY_TRANSPORT=browser` 같은 맥에서 쿠키 없는 Python urllib는 네 API의 HTTP200/SUCCESS를 반환했고 Node fetch·Node https·기본 curl은 403을 받았습니다. Python도 필수 헤더를 제거하면403이었습니다. JSON 공백/문자 인코딩 변경은 원인이 아니었으며 정확한 차단 기준을 TLS만으로 확정하지 않습니다. 브라우저 중계와 동일 조건에서 상품 코드·상세 식별자·매장 코드·실제 재고 필드를 대조했습니다. 명동2가점 핸드크림 일반9/오늘드림7, 선크림 일반54/오늘드림43 및 픽업/취급 상태가 일치했습니다. 이 수량은 확인 당시 관측값이며 이후 변할 수 있습니다. HTTP 성공 여부는 제공자 정책·네트워크·클라이언트 버전에 따라 달라질 수 있으므로 장기간 무차단을 보장하지 않습니다. + +## 2026-10-11 장애 확인 + +운영 health의 `ready`는 Python 실행 가능 여부만 확인합니다. 같은 고정 헤더와 공식 `product-search-v3` 요청이 HTTP403/HTML을 반환하면 상품·재고 조회는 중계502가 됩니다. 이번 확인에서는 설치된 Python 헬퍼의 요청도403을 받았으며, 설정·JSON 스키마 오류를 나타내는 근거는 없었습니다. 로컬에 기록된 네 공식 경로 이외의 대체 공개 API는 확인하지 못했습니다. 차단 기준이나 변경 시점은 이 응답만으로 확정할 수 없습니다. + +HTTP 모드의 health에 `lastFailure: { status, at }`가 추가됩니다. `status`는 헬퍼가 받은 원래 HTTP 상태 숫자이며, 네트워크·프로세스·직렬화 실패 등 상태를 알 수 없는 경우 `null`입니다. `at`는 실패를 관측한 UTC 시각입니다. 다음 요청을 시작하거나 성공하면 이전 실패 정보는 지워집니다. 상위 응답 본문·URL·쿠키·토큰은 포함하지 않습니다. 외부 오류 응답과 요청 허용 경로,15/18초 제한,2MiB 상한은 유지합니다. + +기존 소유 브라우저를 임시 독립 세션으로 실행한 정상 페이지 이동 후 네 공식 조회(매장9개·상품 검색·상품 식별자·매장 재고)는 모두 `SUCCESS`를 반환했고 감시 프로세스의 종료도 확인했습니다. HTTP 실패를 헤더 변형·쿠키 복사·보안 검증 자동화로 회피하지 않습니다. browser 모드 복구는 명시적 설정 변경으로만 수행하며, 브라우저 없는 실행이 필요하면 현재 HTTP403 차단이 해소되거나 제공자가 지원하는 다른 접근 경로가 필요합니다. 이 검증 자체는 운영 설정이나 원장을 수정하지 않았습니다. + +인증된 운영 점검의 `x-health-check-force-fresh: true`는 상품·매장 fallback과 상품 식별자 캐시를 우회하고, 재고 보강 오류를 그대로 보고합니다. 릴레이에 `x-relay-cache-bypass: true`를 전달하여 저장 응답과 진행 중인 동일 요청도 재사용하지 않습니다. 이때도 인증·호출 한도·동시 작업 상한을 적용합니다. 인증되지 않은 공개 요청은 이 우회를 사용할 수 없습니다. diff --git a/docs/service-freshness-checks.md b/docs/service-freshness-checks.md new file mode 100644 index 00000000..e9c6ebb7 --- /dev/null +++ b/docs/service-freshness-checks.md @@ -0,0 +1,20 @@ +# 운영 헬스 체크의 캐시 우회 + +`GET /api/health/checks?fresh=true`를 새 원본 점검으로 실행하려면 운영 시크릿 인증과 `x-health-check-force-fresh: true`를 함께 보냅니다. Better Stack 자동 모니터 요청에는 기존 결과 재사용 정책이 적용됩니다. 인증된 새 점검은 요약 캐시와 하위 REST 응답 캐시를 우회하고, 신뢰된 원점에만 운영 인증 및 새 점검 헤더를 전달합니다. 인증되지 않은 공개 요청의 헤더로 내부 캐시를 우회할 수 없습니다. + +GS25·CU·세븐일레븐·CGV는 인증된 새 점검에서 중계에 `x-relay-cache-bypass: true`를 전달합니다. 중계는 Bearer 토큰을 먼저 검증하며, 이 요청은 저장 응답이나 기존 진행 작업을 사용하지 않고 기존 동시 실행 상한·대기 기한·호출 예산을 그대로 적용받습니다. 새 점검 결과는 일반 요청 캐시에 저장하지 않습니다. 세븐일레븐 직접 상품·매장·인기 검색어 캐시와 GS25 직접 매장 캐시도 우회합니다. 디트릭스 중계에는 응답 캐시가 없습니다. + +상품 검색 성공은 재고 확인 성공을 의미하지 않습니다. 세븐일레븐 재고 점검은 매장별 실제 확인 여부와 수량을 따로 검증합니다. 원본 오류가 발생한 상태에서는 과거 상품 검색 성공을 사용해 점검을 정상으로 판정하지 않습니다. + +## 메가박스 지점 조회 + +근처 지점의 정확한 거리순 정렬에는 해당 지역의 모든 지점 좌표가 필요합니다. 반환 개수가 1개여도 좌표 조회 전에 후보를 자르면 실제 최단 거리 지점을 놓칠 수 있습니다. + +일반 조회는 공식 지점 페이지에서 확인한 주소·좌표를 최대 256개, 1시간 보관합니다. 동일 지점의 진행 중 조회를 공유하고 반환 객체를 분리합니다. 오류 및 좌표 미확인 결과는 저장하지 않습니다. 상영 목록·회차·잔여 좌석은 이 캐시에 저장하지 않고 매번 원본을 조회합니다. 인증된 새 운영 점검은 정적 지점 캐시와 진행 작업도 우회합니다. 따라서 새 원본 점검의 지연은 그대로 관찰되며, 정상 판정 기준을 높여 감추지 않습니다. + +## 회귀 검증 + +- 원본 성공으로 캐시를 채운 뒤 원본이 403을 반환하면 인증된 새 세븐일레븐 점검은 정상으로 판정하지 않습니다. +- 따뜻한 `caches.default`가 있어도 인증된 운영 요청은 GS25·CU·세븐일레븐·CGV 응답 캐시를 우회합니다. +- 토큰이 틀린 요청은 캐시 우회나 중계 예산 사용 권한을 얻지 못합니다. +- 메가박스 지점 캐시는 만료·용량·동시 요청·객체 변경·오류·좌표 누락·새 점검 우회를 검증합니다. diff --git a/scripts/relay/http-lifecycle.ts b/scripts/relay/http-lifecycle.ts index 24e72981..6fd2bcbc 100644 --- a/scripts/relay/http-lifecycle.ts +++ b/scripts/relay/http-lifecycle.ts @@ -15,6 +15,7 @@ export function createHttpLifecycle() { let starting: Promise | undefined; let active = false; let calls = 0; + let lastFailure: { status: number | null; at: string } | null = null; const execute = (check: boolean, input?: string) => { pending = new Promise((resolve, reject) => { child = execFile( @@ -27,7 +28,13 @@ export function createHttpLifecycle() { maxBuffer: LIMIT, encoding: 'utf8', }, - (error, stdout) => { + (error, stdout, stderr) => { + if (error && !check && !isClosed()) { + const match = /^HTTP relay request failed upstream_status=([1-5][0-9]{2})\n?$/.exec( + stderr || '', + ); + lastFailure = { status: match ? Number(match[1]) : null, at: new Date().toISOString() }; + } child = undefined; if (error || isClosed()) reject(new Error('HTTP relay request failed')); else resolve(stdout); @@ -59,6 +66,7 @@ export function createHttpLifecycle() { if (state !== 'ready') throw new Error('HTTP lifecycle not ready'); if (active) throw new Error('HTTP lifecycle already active'); active = true; + lastFailure = null; calls++; try { const input = JSON.stringify({ path, body }); @@ -72,8 +80,10 @@ export function createHttpLifecycle() { Array.isArray(result.data) ) throw new Error(); + lastFailure = null; return result; } catch { + lastFailure ??= { status: null, at: new Date().toISOString() }; throw new Error('HTTP relay request failed'); } finally { active = false; @@ -90,6 +100,7 @@ export function createHttpLifecycle() { status: () => ({ state, transport: 'http', + lastFailure, browserRequired: false, pages: 0, active, diff --git a/scripts/relay/http-relay.ts b/scripts/relay/http-relay.ts index 8bf75d5e..cff1430a 100644 --- a/scripts/relay/http-relay.ts +++ b/scripts/relay/http-relay.ts @@ -15,7 +15,9 @@ export interface HttpRelayOptions { signal: AbortSignal, fetcher: typeof fetch, ) => Promise; - takeQuota: (() => Promise) & { status?: () => Pick }; + takeQuota: (() => Promise) & { + status?: () => Pick; + }; fetcher?: typeof fetch; onEvent?: (event: { stage: string; @@ -36,8 +38,13 @@ export class RelayError extends Error { upstreamStatus?: number, ) { super('Relay failure'); - if (typeof upstreamStatus === 'number' && Number.isInteger(upstreamStatus) && - upstreamStatus >= 100 && upstreamStatus <= 599) this.upstreamStatus = upstreamStatus; + if ( + typeof upstreamStatus === 'number' && + Number.isInteger(upstreamStatus) && + upstreamStatus >= 100 && + upstreamStatus <= 599 + ) + this.upstreamStatus = upstreamStatus; } } const reply = (status: number, body: unknown, headers: Record = {}) => @@ -122,7 +129,13 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) { queue.push(start); }); } - function startWork(key: string, name: string, params: unknown, deadline: number): SharedWork { + function startWork( + key: string, + name: string, + params: unknown, + deadline: number, + bypassCache = false, + ): SharedWork { const controller = new AbortController(); const work: SharedWork = { controller, @@ -162,8 +175,13 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) { const snapshot = options.takeQuota.status?.(); const reason = snapshot?.blockedBy; const retry = snapshot?.retryAfter; - if ((reason === 'minute' || reason === 'daily') && typeof retry === 'number' && - Number.isInteger(retry) && retry >= 1 && retry <= 86400) { + if ( + (reason === 'minute' || reason === 'daily') && + typeof retry === 'number' && + Number.isInteger(retry) && + retry >= 1 && + retry <= 86400 + ) { quota = { quotaReason: reason, retryAfter: retry }; } } catch { @@ -180,7 +198,7 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) { controller.signal.throwIfAborted(); const body = JSON.stringify(data); if (body === undefined) throw new RelayError(502); - if (cache) cache.set(key, body, options.cacheTtl!(name)); + if (cache && !bypassCache) cache.set(key, body, options.cacheTtl!(name)); return body; } catch (error) { if (error instanceof RelayError) work.upstreamStatus = error.upstreamStatus; @@ -197,7 +215,7 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) { } } })(); - if (cache) pending.set(key, work); + if (cache && !bypassCache) pending.set(key, work); return work; } return async (request: Request): Promise => { @@ -237,7 +255,8 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) { try { const p = options.validate(await readJson(request.body, 16384, controller.signal, 400), name); const key = canonicalKey(name, p as Record); - const cached = cache?.get(key); + const bypassCache = request.headers.get('x-relay-cache-bypass') === 'true'; + const cached = bypassCache ? undefined : cache?.get(key); if (cached !== undefined) { controller.signal.throwIfAborted(); stage = 'cache'; @@ -247,9 +266,9 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) { } controller.signal.throwIfAborted(); workKey = key; - work = cache ? pending.get(key) : undefined; + work = cache && !bypassCache ? pending.get(key) : undefined; const shared = work !== undefined; - work ??= startWork(key, name, p, deadline); + work ??= startWork(key, name, p, deadline, bypassCache); work.waiters++; stage = shared ? 'coalesced' : 'upstream'; const body = await abortable(work.result, controller.signal); @@ -265,13 +284,20 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) { ? error.status : 502; resultStatus = status; - return reply(error instanceof RelayError && error.upstreamError ? 200 : status, { - ...(error instanceof RelayError && error.upstreamError ? { success: false } : {}), - error: 'Relay request failed', - upstreamError: error instanceof RelayError ? error.upstreamError : undefined, - }, status === 429 && work?.stage === 'quota' && error instanceof RelayError && error.quota - ? { 'x-relay-quota-reason': error.quota.quotaReason, 'Retry-After': String(error.quota.retryAfter) } - : {}); + return reply( + error instanceof RelayError && error.upstreamError ? 200 : status, + { + ...(error instanceof RelayError && error.upstreamError ? { success: false } : {}), + error: 'Relay request failed', + upstreamError: error instanceof RelayError ? error.upstreamError : undefined, + }, + status === 429 && work?.stage === 'quota' && error instanceof RelayError && error.quota + ? { + 'x-relay-quota-reason': error.quota.quotaReason, + 'Retry-After': String(error.quota.retryAfter), + } + : {}, + ); } finally { try { void Promise.resolve( diff --git a/scripts/relay/oliveyoung-http.py b/scripts/relay/oliveyoung-http.py index 2ad68fa9..a1665730 100644 --- a/scripts/relay/oliveyoung-http.py +++ b/scripts/relay/oliveyoung-http.py @@ -2,6 +2,7 @@ import json import sys import urllib.request +from urllib.error import HTTPError ORIGIN = 'https://www.oliveyoung.co.kr' PATHS = frozenset([ @@ -23,6 +24,9 @@ class RelayError(Exception): """상위 응답 본문을 노출하지 않는 고정 오류입니다.""" + def __init__(self, message, status=None): + super().__init__(message) + self.status = status class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): @@ -40,7 +44,7 @@ def fetch(path, body): opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) with opener.open(request, timeout=15) as response: if response.status != 200: - raise RelayError('Upstream unavailable') + raise RelayError('Upstream unavailable', response.status) raw = response.read(LIMIT + 1) if len(raw) > LIMIT: raise RelayError('Upstream unavailable') @@ -48,6 +52,10 @@ def fetch(path, body): if not isinstance(result, dict) or result.get('status') != 'SUCCESS' or not isinstance(result.get('data'), dict): raise RelayError('Upstream unavailable') return result + except HTTPError as error: + raise RelayError('Upstream unavailable', error.code) from None + except RelayError: + raise except Exception: raise RelayError('Upstream unavailable') from None @@ -70,8 +78,10 @@ def main(): raise RelayError('Upstream unavailable') sys.stdout.buffer.write(output) return 0 - except Exception: - print('HTTP relay request failed', file=sys.stderr) + except Exception as error: + status = getattr(error, 'status', None) + suffix = f' upstream_status={status}' if type(status) is int and 100 <= status <= 599 else '' + print('HTTP relay request failed' + suffix, file=sys.stderr) return 1 if __name__ == '__main__': diff --git a/scripts/relay/oliveyoung.ts b/scripts/relay/oliveyoung.ts index 08adca2a..360592be 100644 --- a/scripts/relay/oliveyoung.ts +++ b/scripts/relay/oliveyoung.ts @@ -171,8 +171,9 @@ export function createOliveyoungRelay( /* 관측 실패는 요청 처리에 영향을 주지 않습니다. */ } }; - const key = canonicalKey(operation, body); - const cached = cache.get(key); + const bypassCache = request.headers.get('x-relay-cache-bypass') === 'true'; + const key = bypassCache ? randomUUID() : canonicalKey(operation, body); + const cached = bypassCache ? undefined : cache.get(key); if (cached !== undefined) { emit('cache', 'hit'); return new Response(cached, { @@ -210,7 +211,7 @@ export function createOliveyoungRelay( } reservation.commit(); const result = await run(paths[operation], body); - if (result?.status === 'SUCCESS') + if (!bypassCache && result?.status === 'SUCCESS') cache.set(key, JSON.stringify(result), ttl[operation]); emit('upstream', result?.status === 'SUCCESS' ? 'success' : 'error'); return result; diff --git a/src/api/cgvHandlers.ts b/src/api/cgvHandlers.ts index 4d1bdfc6..11c21707 100644 --- a/src/api/cgvHandlers.ts +++ b/src/api/cgvHandlers.ts @@ -1,3 +1,4 @@ +import { isFreshOperationalRequest } from './operationalFreshness.js'; import { hasInvalidCinemaQuery, CINEMA_INVALID_INPUT } from '../services/cgv/validation.js'; import { geocodeBindings } from '../utils/geocode.js'; /** @@ -56,6 +57,7 @@ export async function handleCgvFindTheaters(c: ApiContext) { timeout: timeoutMs, zyteApiKey: c.env?.ZYTE_API_KEY, ...cgvTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), ...geocodeBindings(c.env), }, ); @@ -68,7 +70,8 @@ export async function handleCgvFindTheaters(c: ApiContext) { regionCode, timeout: timeoutMs, zyteApiKey: c.env?.ZYTE_API_KEY, - ...cgvTransportFromBindings(c.env), + ...cgvTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), }); const sliced = theaters.slice(0, limit); @@ -129,6 +132,7 @@ export async function handleCgvSearchMovies(c: ApiContext) { timeout: timeoutMs, zyteApiKey: c.env?.ZYTE_API_KEY, ...cgvTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), ...geocodeBindings(c.env), }, ); @@ -146,6 +150,7 @@ export async function handleCgvSearchMovies(c: ApiContext) { timeout: timeoutMs, zyteApiKey: c.env?.ZYTE_API_KEY, ...cgvTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), }); return successResponse( @@ -207,6 +212,7 @@ export async function handleCgvGetTimetable(c: ApiContext) { timeout: timeoutMs, zyteApiKey: c.env?.ZYTE_API_KEY, ...cgvTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), ...geocodeBindings(c.env), }, ); @@ -225,6 +231,7 @@ export async function handleCgvGetTimetable(c: ApiContext) { timeout: timeoutMs, zyteApiKey: c.env?.ZYTE_API_KEY, ...cgvTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), }); const filtered = filterAndSortTimetable(timetable, { theaterCode, movieCode, limit }); diff --git a/src/api/gs25Handlers.ts b/src/api/gs25Handlers.ts index 301781c4..20712bd9 100644 --- a/src/api/gs25Handlers.ts +++ b/src/api/gs25Handlers.ts @@ -1,3 +1,4 @@ +import { isFreshOperationalRequest } from './operationalFreshness.js'; import { ServiceError } from '../core/errors.js'; import { selectGs25InventoryProduct } from '../services/gs25/productSelection.js'; import { convenienceTransportFromBindings } from '../utils/convenienceTransport.js'; @@ -7,7 +8,12 @@ import { geocodeBindings } from '../utils/geocode.js'; */ /* c8 ignore start */ -import { type ApiContext, errorResponse, serviceErrorResponse, successResponse } from './response.js'; +import { + type ApiContext, + errorResponse, + serviceErrorResponse, + successResponse, +} from './response.js'; import { attachDistanceToGs25Stores, fetchGs25SearchProducts, @@ -68,6 +74,7 @@ export async function handleGs25FindStores(c: ApiContext) { { timeout: 20000, ...convenienceTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), zyteApiKey: c.env?.ZYTE_API_KEY, apiKey: c.env?.GS25_API_KEY, }, @@ -79,7 +86,8 @@ export async function handleGs25FindStores(c: ApiContext) { }); if ( - !fallbackUsed && storeResult.stores.length === 0 && + !fallbackUsed && + storeResult.stores.length === 0 && typeof latitude === 'number' && typeof longitude === 'number' ) { @@ -96,7 +104,8 @@ export async function handleGs25FindStores(c: ApiContext) { { timeout: 20000, ...convenienceTransportFromBindings(c.env), - zyteApiKey: c.env?.ZYTE_API_KEY, + bypassCache: await isFreshOperationalRequest(c), + zyteApiKey: c.env?.ZYTE_API_KEY, apiKey: c.env?.GS25_API_KEY, }, ); @@ -165,7 +174,8 @@ export async function handleGs25SearchProducts(c: ApiContext) { const products = await fetchGs25SearchProducts(keyword, { timeout: 20000, ...convenienceTransportFromBindings(c.env), - zyteApiKey: c.env?.ZYTE_API_KEY, + bypassCache: await isFreshOperationalRequest(c), + zyteApiKey: c.env?.ZYTE_API_KEY, }); const limitedProducts = products.slice(0, limit); @@ -246,7 +256,8 @@ export async function handleGs25CheckInventory(c: ApiContext) { { timeout: 20000, ...convenienceTransportFromBindings(c.env), - zyteApiKey: c.env?.ZYTE_API_KEY, + bypassCache: await isFreshOperationalRequest(c), + zyteApiKey: c.env?.ZYTE_API_KEY, apiKey: c.env?.GS25_API_KEY, }, ); @@ -290,7 +301,8 @@ export async function handleGs25CheckInventory(c: ApiContext) { { timeout: 20000, ...convenienceTransportFromBindings(c.env), - zyteApiKey: c.env?.ZYTE_API_KEY, + bypassCache: await isFreshOperationalRequest(c), + zyteApiKey: c.env?.ZYTE_API_KEY, apiKey: c.env?.GS25_API_KEY, }, ); @@ -298,6 +310,7 @@ export async function handleGs25CheckInventory(c: ApiContext) { const searchProducts = await fetchGs25SearchProducts(keyword, { timeout: 20000, ...convenienceTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), zyteApiKey: c.env?.ZYTE_API_KEY, }); firstProduct = selectGs25InventoryProduct(searchProducts, keyword); @@ -318,7 +331,8 @@ export async function handleGs25CheckInventory(c: ApiContext) { { timeout: 20000, ...convenienceTransportFromBindings(c.env), - zyteApiKey: c.env?.ZYTE_API_KEY, + bypassCache: await isFreshOperationalRequest(c), + zyteApiKey: c.env?.ZYTE_API_KEY, apiKey: c.env?.GS25_API_KEY, }, ); @@ -335,7 +349,8 @@ export async function handleGs25CheckInventory(c: ApiContext) { { timeout: 20000, ...convenienceTransportFromBindings(c.env), - zyteApiKey: c.env?.ZYTE_API_KEY, + bypassCache: await isFreshOperationalRequest(c), + zyteApiKey: c.env?.ZYTE_API_KEY, apiKey: c.env?.GS25_API_KEY, }, ); @@ -357,12 +372,16 @@ export async function handleGs25CheckInventory(c: ApiContext) { null; const productPrice = filtered.find((item) => item.searchItemSellPrice !== null)?.searchItemSellPrice ?? null; - const inStockStoreCount = filtered.filter((item) => item.realStockQuantity !== null && item.realStockQuantity > 0).length; - const unknownStockStoreCount = filtered.filter((store) => store.realStockQuantity === null).length; - const totalStockQuantity = unknownStockStoreCount === filtered.length ? null : filtered.reduce( - (sum, item) => sum + Math.max(item.realStockQuantity ?? 0, 0), - 0, - ); + const inStockStoreCount = filtered.filter( + (item) => item.realStockQuantity !== null && item.realStockQuantity > 0, + ).length; + const unknownStockStoreCount = filtered.filter( + (store) => store.realStockQuantity === null, + ).length; + const totalStockQuantity = + unknownStockStoreCount === filtered.length + ? null + : filtered.reduce((sum, item) => sum + Math.max(item.realStockQuantity ?? 0, 0), 0); return successResponse(c, { serviceCode, diff --git a/src/api/handlers.ts b/src/api/handlers.ts index 65f6b023..7cbc6eef 100644 --- a/src/api/handlers.ts +++ b/src/api/handlers.ts @@ -1,3 +1,4 @@ +import { isFreshOperationalRequest } from './operationalFreshness.js'; import { convenienceTransportFromBindings } from '../utils/convenienceTransport.js'; import { geocodeBindings } from '../utils/geocode.js'; /** @@ -66,6 +67,7 @@ export async function handleOliveyoungSearchProducts(c: ApiContext) { includeSoldOut, }, { + bypassCache: await isFreshOperationalRequest(c), relayUrl: c.env?.OY_RELAY_URL, relayToken: c.env?.OY_RELAY_TOKEN, accessClientId: c.env?.OY_ACCESS_CLIENT_ID, @@ -115,6 +117,7 @@ export async function handleOliveyoungFindStores(c: ApiContext) { searchWords: keyword, }, { + bypassCache: await isFreshOperationalRequest(c), relayUrl: c.env?.OY_RELAY_URL, relayToken: c.env?.OY_RELAY_TOKEN, accessClientId: c.env?.OY_ACCESS_CLIENT_ID, @@ -172,6 +175,7 @@ export async function handleOliveyoungCheckInventory(c: ApiContext) { searchWords: storeKeyword, }, { + bypassCache: await isFreshOperationalRequest(c), relayUrl: c.env?.OY_RELAY_URL, relayToken: c.env?.OY_RELAY_TOKEN, accessClientId: c.env?.OY_ACCESS_CLIENT_ID, @@ -188,6 +192,7 @@ export async function handleOliveyoungCheckInventory(c: ApiContext) { includeSoldOut, }, { + bypassCache: await isFreshOperationalRequest(c), relayUrl: c.env?.OY_RELAY_URL, relayToken: c.env?.OY_RELAY_TOKEN, accessClientId: c.env?.OY_ACCESS_CLIENT_ID, @@ -205,6 +210,7 @@ export async function handleOliveyoungCheckInventory(c: ApiContext) { maxProducts: Math.min(productResult.products.length, stockCheckLimit), }, { + bypassCache: await isFreshOperationalRequest(c), relayUrl: c.env?.OY_RELAY_URL, relayToken: c.env?.OY_RELAY_TOKEN, accessClientId: c.env?.OY_ACCESS_CLIENT_ID, @@ -267,6 +273,7 @@ export async function handleCuFindStores(c: ApiContext) { { timeout: 15000, ...convenienceTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), apiKey: c.env.ZYTE_API_KEY, }, ); @@ -322,6 +329,7 @@ export async function handleCuCheckInventory(c: ApiContext) { { timeout: 15000, ...convenienceTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), apiKey: c.env?.ZYTE_API_KEY, }, ); @@ -343,6 +351,7 @@ export async function handleCuCheckInventory(c: ApiContext) { { timeout: 15000, ...convenienceTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), apiKey: c.env?.ZYTE_API_KEY, }, ); @@ -371,6 +380,7 @@ export async function handleCuCheckInventory(c: ApiContext) { { timeout: 15000, ...convenienceTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), apiKey: c.env?.ZYTE_API_KEY, }, ); @@ -396,6 +406,7 @@ export async function handleCuCheckInventory(c: ApiContext) { { timeout: 15000, ...convenienceTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), apiKey: c.env?.ZYTE_API_KEY, }, ); diff --git a/src/api/megaboxHandlers.ts b/src/api/megaboxHandlers.ts index cade1be9..a42ac9c7 100644 --- a/src/api/megaboxHandlers.ts +++ b/src/api/megaboxHandlers.ts @@ -1,3 +1,4 @@ +import { isFreshOperationalRequest } from './operationalFreshness.js'; import { hasInvalidCinemaQuery, CINEMA_INVALID_INPUT } from '../services/cgv/validation.js'; import { geocodeBindings } from '../utils/geocode.js'; /** @@ -43,6 +44,7 @@ export async function handleMegaboxFindNearbyTheaters(c: ApiContext) { }, { ...geocodeBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), timeout: timeoutMs, }, ); @@ -86,6 +88,7 @@ export async function handleMegaboxListNowShowing(c: ApiContext) { }, { ...geocodeBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), timeout: timeoutMs, }, ); @@ -162,6 +165,7 @@ export async function handleMegaboxGetRemainingSeats(c: ApiContext) { }, { ...geocodeBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), timeout: timeoutMs, }, ); diff --git a/src/api/operationalFreshness.ts b/src/api/operationalFreshness.ts new file mode 100644 index 00000000..1e299246 --- /dev/null +++ b/src/api/operationalFreshness.ts @@ -0,0 +1,9 @@ +/** 인증된 운영 점검만 서비스 내부 캐시를 우회할 수 있습니다. */ +import type { ApiContext } from './response.js'; +import { authorizeOperationalRequest } from './operationalAuth.js'; + +export async function isFreshOperationalRequest(c: ApiContext): Promise { + const headers = c.req.raw?.headers; + if (headers?.get('x-health-check-force-fresh') !== 'true') return false; + return (await authorizeOperationalRequest(headers, c.env?.HEALTH_CHECK_SECRET)) === 'authorized'; +} diff --git a/src/api/routes/cgvRoutes.ts b/src/api/routes/cgvRoutes.ts index c4a8b453..2f11924f 100644 --- a/src/api/routes/cgvRoutes.ts +++ b/src/api/routes/cgvRoutes.ts @@ -1,10 +1,15 @@ +import { isFreshOperationalRequest } from '../operationalFreshness.js'; /** * CGV GET API 라우트 등록 */ import type { Hono } from 'hono'; import { withEdgeCache } from '../../utils/cache.js'; -import { handleCgvFindTheaters, handleCgvGetTimetable, handleCgvSearchMovies } from '../cgvHandlers.js'; +import { + handleCgvFindTheaters, + handleCgvGetTimetable, + handleCgvSearchMovies, +} from '../cgvHandlers.js'; import type { AppBindings } from '../response.js'; export function registerCgvRoutes(app: Hono<{ Bindings: AppBindings }>): void { @@ -12,6 +17,7 @@ export function registerCgvRoutes(app: Hono<{ Bindings: AppBindings }>): void { withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 60 * 24, staleWhileRevalidateSeconds: 60 * 5, keyPrefix: 'cgv-theaters-v3', @@ -24,6 +30,7 @@ export function registerCgvRoutes(app: Hono<{ Bindings: AppBindings }>): void { withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 10, staleWhileRevalidateSeconds: 60, keyPrefix: 'cgv-movies-v3', @@ -36,6 +43,7 @@ export function registerCgvRoutes(app: Hono<{ Bindings: AppBindings }>): void { withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 3, staleWhileRevalidateSeconds: 30, keyPrefix: 'cgv-timetable-v3', diff --git a/src/api/routes/cuRoutes.ts b/src/api/routes/cuRoutes.ts index 0903dd8f..b5d8a256 100644 --- a/src/api/routes/cuRoutes.ts +++ b/src/api/routes/cuRoutes.ts @@ -1,3 +1,4 @@ +import { isFreshOperationalRequest } from '../operationalFreshness.js'; /** * CU GET API 라우트 등록 */ @@ -12,6 +13,7 @@ export function registerCuRoutes(app: Hono<{ Bindings: AppBindings }>): void { withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 60 * 24, staleWhileRevalidateSeconds: 60 * 5, keyPrefix: 'cu-stores-v5', @@ -24,6 +26,7 @@ export function registerCuRoutes(app: Hono<{ Bindings: AppBindings }>): void { withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 10, staleWhileRevalidateSeconds: 60, keyPrefix: 'cu-inventory-v5', diff --git a/src/api/routes/gs25Routes.ts b/src/api/routes/gs25Routes.ts index aacdd9ce..057286f8 100644 --- a/src/api/routes/gs25Routes.ts +++ b/src/api/routes/gs25Routes.ts @@ -1,3 +1,4 @@ +import { isFreshOperationalRequest } from '../operationalFreshness.js'; /** * GS25 GET API 라우트 등록 */ @@ -16,6 +17,7 @@ export function registerGs25Routes(app: Hono<{ Bindings: AppBindings }>): void { withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 10, staleWhileRevalidateSeconds: 60, keyPrefix: 'gs25-stores-v3', @@ -28,6 +30,7 @@ export function registerGs25Routes(app: Hono<{ Bindings: AppBindings }>): void { withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 3, staleWhileRevalidateSeconds: 60, keyPrefix: 'gs25-products-v3', @@ -40,6 +43,7 @@ export function registerGs25Routes(app: Hono<{ Bindings: AppBindings }>): void { withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 2, staleWhileRevalidateSeconds: 30, keyPrefix: 'gs25-inventory-v3', diff --git a/src/api/routes/healthRoutes.ts b/src/api/routes/healthRoutes.ts index 6870b333..111abc25 100644 --- a/src/api/routes/healthRoutes.ts +++ b/src/api/routes/healthRoutes.ts @@ -14,7 +14,9 @@ function parseBoolean(value: string | undefined): boolean { function isBetterStackHealthProbe(userAgent: string | undefined): boolean { const normalized = (userAgent || '').toLowerCase(); - return normalized.includes('better stack better uptime bot') || normalized === 'nginx-ssl early hints'; + return ( + normalized.includes('better stack better uptime bot') || normalized === 'nginx-ssl early hints' + ); } function isEarlyHintsProbe(userAgent: string | undefined): boolean { @@ -25,7 +27,11 @@ function canForceFresh(headers: Headers): boolean { return parseBoolean(headers.get('x-health-check-force-fresh') || undefined); } -function isTrustedHealthTarget(target: RequestInfo | URL, requestUrl: string, configuredBaseUrl?: string): boolean { +function isTrustedHealthTarget( + target: RequestInfo | URL, + requestUrl: string, + configuredBaseUrl?: string, +): boolean { const allowedOrigins = new Set([new URL(requestUrl).origin]); if (configuredBaseUrl) { allowedOrigins.add(new URL(configuredBaseUrl).origin); @@ -62,12 +68,22 @@ export function registerHealthRoutes(app: Hono<{ Bindings: AppBindings }>): void } if ((await authorizeOperationalRequest(c.req.raw.headers, secret)) !== 'authorized') { - return errorResponse(c, 'UNAUTHORIZED_HEALTH_CHECK', '유효한 헬스 체크 시크릿 키가 필요합니다.', 401); + return errorResponse( + c, + 'UNAUTHORIZED_HEALTH_CHECK', + '유효한 헬스 체크 시크릿 키가 필요합니다.', + 401, + ); } const mode = parseMode(c.req.query('mode')); if (!mode) { - return errorResponse(c, 'INVALID_HEALTH_CHECK_MODE', 'mode는 quick, deep, full 중 하나여야 합니다.', 400); + return errorResponse( + c, + 'INVALID_HEALTH_CHECK_MODE', + 'mode는 quick, deep, full 중 하나여야 합니다.', + 400, + ); } const timeoutMs = Number.parseInt(c.req.query('timeoutMs') || '3000', 10); @@ -75,13 +91,21 @@ export function registerHealthRoutes(app: Hono<{ Bindings: AppBindings }>): void c.req.query('slowThresholdMs') || c.env?.HEALTH_CHECK_SLOW_THRESHOLD_MS || '0', 10, ); - const baseUrl = c.req.query('baseUrl')?.trim() || c.env?.HEALTH_CHECK_BASE_URL?.trim() || new URL(c.req.url).origin; + const baseUrl = + c.req.query('baseUrl')?.trim() || + c.env?.HEALTH_CHECK_BASE_URL?.trim() || + new URL(c.req.url).origin; const transport = c.req.query('transport') || c.env?.HEALTH_CHECK_TRANSPORT || 'internal'; - const cacheBust = parseBoolean(c.req.query('cacheBust')) || transport === 'network'; + const fresh = + parseBoolean(c.req.query('fresh')) && + canForceFresh(c.req.raw.headers) && + !isBetterStackHealthProbe(userAgent); + const cacheBust = fresh || parseBoolean(c.req.query('cacheBust')) || transport === 'network'; const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit) => { const headers = new Headers(init?.headers); if (isTrustedHealthTarget(input, c.req.url, c.env?.HEALTH_CHECK_BASE_URL)) { headers.set('x-health-check-key', secret); + if (fresh) headers.set('x-health-check-force-fresh', 'true'); } const requestInit = { ...init, headers }; @@ -97,10 +121,7 @@ export function registerHealthRoutes(app: Hono<{ Bindings: AppBindings }>): void timeoutMs, slowThresholdMs, includeSamples: parseBoolean(c.req.query('includeSamples')), - fresh: - parseBoolean(c.req.query('fresh')) && - canForceFresh(c.req.raw.headers) && - !isBetterStackHealthProbe(userAgent), + fresh, cacheBust, fetchImpl, }); diff --git a/src/api/routes/megaboxRoutes.ts b/src/api/routes/megaboxRoutes.ts index 3280c11a..c8d8d05c 100644 --- a/src/api/routes/megaboxRoutes.ts +++ b/src/api/routes/megaboxRoutes.ts @@ -1,3 +1,4 @@ +import { isFreshOperationalRequest } from '../operationalFreshness.js'; /** * 메가박스 GET API 라우트 등록 */ @@ -16,6 +17,7 @@ export function registerMegaboxRoutes(app: Hono<{ Bindings: AppBindings }>): voi withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 60 * 24, staleWhileRevalidateSeconds: 60 * 5, keyPrefix: 'megabox-theaters-v4', @@ -28,6 +30,7 @@ export function registerMegaboxRoutes(app: Hono<{ Bindings: AppBindings }>): voi withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 10, staleWhileRevalidateSeconds: 60, keyPrefix: 'megabox-movies-v4', @@ -40,6 +43,7 @@ export function registerMegaboxRoutes(app: Hono<{ Bindings: AppBindings }>): voi withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 3, staleWhileRevalidateSeconds: 30, keyPrefix: 'megabox-seats-v4', diff --git a/src/api/routes/oliveyoungRoutes.ts b/src/api/routes/oliveyoungRoutes.ts index 3b50584a..a966fbc0 100644 --- a/src/api/routes/oliveyoungRoutes.ts +++ b/src/api/routes/oliveyoungRoutes.ts @@ -1,3 +1,4 @@ +import { isFreshOperationalRequest } from '../operationalFreshness.js'; /** * 올리브영 GET API 라우트 등록 */ @@ -14,38 +15,44 @@ import type { AppBindings } from '../response.js'; export function registerOliveyoungRoutes(app: Hono<{ Bindings: AppBindings }>): void { app.get('/api/oliveyoung/products', async (c) => - withEdgeCache( - oliveyoungCacheKey(c.req.url), - { - ttlSeconds: 60 * 5, - staleWhileRevalidateSeconds: 60, - keyPrefix: 'oliveyoung-products-v3', - }, - () => handleOliveyoungSearchProducts(c), - ), + (await isFreshOperationalRequest(c)) + ? handleOliveyoungSearchProducts(c) + : withEdgeCache( + oliveyoungCacheKey(c.req.url), + { + ttlSeconds: 60 * 5, + staleWhileRevalidateSeconds: 60, + keyPrefix: 'oliveyoung-products-v3', + }, + () => handleOliveyoungSearchProducts(c), + ), ); app.get('/api/oliveyoung/stores', async (c) => - withEdgeCache( - oliveyoungCacheKey(c.req.url), - { - ttlSeconds: 60 * 60 * 24, - staleWhileRevalidateSeconds: 60 * 5, - keyPrefix: 'oliveyoung-stores-v3', - }, - () => handleOliveyoungFindStores(c), - ), + (await isFreshOperationalRequest(c)) + ? handleOliveyoungFindStores(c) + : withEdgeCache( + oliveyoungCacheKey(c.req.url), + { + ttlSeconds: 60 * 60 * 24, + staleWhileRevalidateSeconds: 60 * 5, + keyPrefix: 'oliveyoung-stores-v3', + }, + () => handleOliveyoungFindStores(c), + ), ); app.get('/api/oliveyoung/inventory', async (c) => - withEdgeCache( - oliveyoungCacheKey(c.req.url), - { - ttlSeconds: 60 * 10, - staleWhileRevalidateSeconds: 60, - keyPrefix: 'oliveyoung-inventory-v4', - }, - () => handleOliveyoungCheckInventory(c), - ), + (await isFreshOperationalRequest(c)) + ? handleOliveyoungCheckInventory(c) + : withEdgeCache( + oliveyoungCacheKey(c.req.url), + { + ttlSeconds: 60 * 10, + staleWhileRevalidateSeconds: 60, + keyPrefix: 'oliveyoung-inventory-v4', + }, + () => handleOliveyoungCheckInventory(c), + ), ); } diff --git a/src/api/routes/sevenelevenRoutes.ts b/src/api/routes/sevenelevenRoutes.ts index 9d26d70c..966868f2 100644 --- a/src/api/routes/sevenelevenRoutes.ts +++ b/src/api/routes/sevenelevenRoutes.ts @@ -1,3 +1,4 @@ +import { isFreshOperationalRequest } from '../operationalFreshness.js'; /** * 세븐일레븐 GET API 라우트 등록 */ @@ -19,6 +20,7 @@ export function registerSevenElevenRoutes(app: Hono<{ Bindings: AppBindings }>): withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 3, staleWhileRevalidateSeconds: 60, keyPrefix: 'seveneleven-products-v4', @@ -31,6 +33,7 @@ export function registerSevenElevenRoutes(app: Hono<{ Bindings: AppBindings }>): withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 3, staleWhileRevalidateSeconds: 60, keyPrefix: 'seveneleven-stores-v2', @@ -43,6 +46,7 @@ export function registerSevenElevenRoutes(app: Hono<{ Bindings: AppBindings }>): withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 30, staleWhileRevalidateSeconds: 0, keyPrefix: 'seveneleven-inventory-v3', @@ -56,6 +60,7 @@ export function registerSevenElevenRoutes(app: Hono<{ Bindings: AppBindings }>): withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 5, staleWhileRevalidateSeconds: 60, keyPrefix: 'seveneleven-popwords-v2', @@ -68,6 +73,7 @@ export function registerSevenElevenRoutes(app: Hono<{ Bindings: AppBindings }>): withEdgeCache( c.req.url, { + bypassCache: await isFreshOperationalRequest(c), ttlSeconds: 60 * 5, staleWhileRevalidateSeconds: 60, keyPrefix: 'seveneleven-catalog-v2', diff --git a/src/api/sevenelevenHandlers.ts b/src/api/sevenelevenHandlers.ts index 8f817d6a..cc31bfd2 100644 --- a/src/api/sevenelevenHandlers.ts +++ b/src/api/sevenelevenHandlers.ts @@ -1,3 +1,4 @@ +import { isFreshOperationalRequest } from './operationalFreshness.js'; import { ServiceError } from '../core/errors.js'; import { convenienceTransportFromBindings } from '../utils/convenienceTransport.js'; /** @@ -5,7 +6,12 @@ import { convenienceTransportFromBindings } from '../utils/convenienceTransport. */ /* c8 ignore start */ -import { type ApiContext, errorResponse, serviceErrorResponse, successResponse } from './response.js'; +import { + type ApiContext, + errorResponse, + serviceErrorResponse, + successResponse, +} from './response.js'; import { fetchSevenElevenStoresByKeyword, fetchSevenElevenCatalogSnapshot, @@ -52,8 +58,11 @@ export async function handleSevenElevenSearchProducts(c: ApiContext) { size, sort, }, - { ...convenienceTransportFromBindings(c.env), - zyteApiKey: c.env?.ZYTE_API_KEY }, + { + ...convenienceTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), + zyteApiKey: c.env?.ZYTE_API_KEY, + }, ); return successResponse( @@ -97,8 +106,11 @@ export async function handleSevenElevenSearchStores(c: ApiContext) { keyword, limit: safeLimit, }, - { ...convenienceTransportFromBindings(c.env), - zyteApiKey: c.env?.ZYTE_API_KEY }, + { + ...convenienceTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), + zyteApiKey: c.env?.ZYTE_API_KEY, + }, ); return successResponse( @@ -148,6 +160,7 @@ export async function handleSevenElevenCheckInventory(c: ApiContext) { { timeout: safeTimeoutMs, ...convenienceTransportFromBindings(c.env), + bypassCache: await isFreshOperationalRequest(c), zyteApiKey: c.env?.ZYTE_API_KEY, }, ); @@ -196,7 +209,8 @@ export async function handleSevenElevenGetSearchPopwords(c: ApiContext) { try { const keywords = await fetchSevenElevenSearchPopwords(label, { ...convenienceTransportFromBindings(c.env), - zyteApiKey: c.env?.ZYTE_API_KEY, + bypassCache: await isFreshOperationalRequest(c), + zyteApiKey: c.env?.ZYTE_API_KEY, }); return successResponse(c, { @@ -230,7 +244,8 @@ export async function handleSevenElevenGetCatalogSnapshot(c: ApiContext) { includeIssues, includeExhibition, ...convenienceTransportFromBindings(c.env), - zyteApiKey: c.env?.ZYTE_API_KEY, + bypassCache: await isFreshOperationalRequest(c), + zyteApiKey: c.env?.ZYTE_API_KEY, }); return successResponse(c, { diff --git a/src/core/errors.ts b/src/core/errors.ts index 2a214a9b..43955864 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -139,11 +139,11 @@ export function toStandardErrorDiagnostics( operation: options.operation || inferred.operation, upstreamStatus: options.upstreamStatus, hint: oliveyoungRelayConfigurationError - ? '운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN 및 브라우저 릴레이 실행 상태를 확인하세요.' + ? '운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN 및 릴레이 실행 상태를 확인하세요.' : costPolicyError ? ZYTE_COST_POLICY_MESSAGE : gs25AuthenticationError - ? '운영자는 GS25_API_KEY 설정을 확인하세요.' + ? '운영자는 GS25_AUTH_SESSION_FILE의 인증 갱신 상태와 중계 재시작 여부를 확인하세요. API 키 방식은 GS25_API_KEY 설정을 확인하세요.' : configurationError ? '운영자는 ZYTE_API_KEY 설정과 Zyte 계정 상태를 확인하세요.' : buildHint(retryable), diff --git a/src/services/cgv/relayTransport.ts b/src/services/cgv/relayTransport.ts index 7ae67c50..5aca9d26 100644 --- a/src/services/cgv/relayTransport.ts +++ b/src/services/cgv/relayTransport.ts @@ -6,6 +6,7 @@ import { parseRelayQuota } from '../../utils/relayQuota.js'; import { createTimeoutController } from '../../utils/http.js'; import { CGV_API } from './api.js'; export interface CgvTransportOptions { + bypassCache?: boolean; cgvRelayUrl?: string; cgvRelayToken?: string; cgvAccessClientId?: string; @@ -122,6 +123,7 @@ export async function requestCgvRelay( 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }; + if (options.bypassCache) headers['x-relay-cache-bypass'] = 'true'; if (id && secret) { headers['CF-Access-Client-Id'] = id; headers['CF-Access-Client-Secret'] = secret; diff --git a/src/services/gs25/client.ts b/src/services/gs25/client.ts index 2e0e6647..9ff84953 100644 --- a/src/services/gs25/client.ts +++ b/src/services/gs25/client.ts @@ -1,4 +1,7 @@ -import { hasConvenienceRelay, type ConvenienceTransportOptions } from '../../utils/convenienceTransport.js'; +import { + hasConvenienceRelay, + type ConvenienceTransportOptions, +} from '../../utils/convenienceTransport.js'; import { geocodeLocation } from '../../utils/geocode.js'; /** * GS25 API 클라이언트 @@ -56,8 +59,6 @@ interface Gs25WebLocationStore { posY?: string | number; } - - interface CacheEntry { expiresAt: number; stores: Gs25Store[]; @@ -176,7 +177,7 @@ export async function fetchGs25Stores( longitude, }); - if (useCache && !hasConvenienceRelay(options)) { + if (useCache && !options.bypassCache && !hasConvenienceRelay(options)) { const cached = gs25StoresCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { return { @@ -240,7 +241,7 @@ export async function fetchGs25Stores( .map(normalizeStore) .filter((store) => store.storeCode.length > 0); - if (useCache && !hasConvenienceRelay(options)) { + if (useCache && !options.bypassCache && !hasConvenienceRelay(options)) { gs25StoresCache.set(cacheKey, { stores, expiresAt: Date.now() + GS25_STORES_CACHE_TTL_MS, diff --git a/src/services/gs25/errors.ts b/src/services/gs25/errors.ts index b175f241..e257bb42 100644 --- a/src/services/gs25/errors.ts +++ b/src/services/gs25/errors.ts @@ -4,7 +4,7 @@ export class Gs25UpstreamUnavailableError extends Error { constructor() { - super('GS25 재고 서비스 인증을 사용할 수 없습니다. 운영자는 GS25_API_KEY 설정을 확인하세요.'); + super('GS25 재고 서비스 인증을 사용할 수 없습니다. 운영자는 GS25_AUTH_SESSION_FILE의 인증 갱신 상태를 확인하세요. API 키 방식은 GS25_API_KEY 설정을 확인하세요.'); this.name = 'Gs25UpstreamUnavailableError'; } } diff --git a/src/services/megabox/location.ts b/src/services/megabox/location.ts index 5720df7f..1a5d96ae 100644 --- a/src/services/megabox/location.ts +++ b/src/services/megabox/location.ts @@ -1,3 +1,4 @@ +import { clearMegaboxTheaterInfoCache, withMegaboxTheaterInfoCache } from './theaterInfoCache.js'; /** * 메가박스 위치 해석 및 근처 지점 조회 보조 모듈 */ @@ -6,6 +7,7 @@ import { geocodeLocation, validKoreanCoordinates } from '../../utils/geocode.js' import { fetchMegaboxBookingList, fetchMegaboxTheaterInfo } from './client.js'; interface RequestOptions { + bypassCache?: boolean; timeout?: number; googleMapsApiKey?: string; kakaoRestApiKey?: string; @@ -164,7 +166,14 @@ export async function fetchMegaboxNearbyTheaters( }); const infoResults = await Promise.allSettled( - theaters.map((theater) => fetchMegaboxTheaterInfo(theater.theaterId, timeout)), + theaters.map((theater) => + withMegaboxTheaterInfoCache( + theater.theaterId, + () => fetchMegaboxTheaterInfo(theater.theaterId, timeout), + options.bypassCache, + timeout, + ), + ), ); const merged = theaters @@ -234,5 +243,5 @@ export async function resolveMegaboxNearestTheater( } export function __testOnlyClearMegaboxLocationCaches(): void { - // 캐시를 유지하지 않으므로 초기화할 상태가 없습니다. + clearMegaboxTheaterInfoCache(); } diff --git a/src/services/megabox/theaterInfoCache.ts b/src/services/megabox/theaterInfoCache.ts new file mode 100644 index 00000000..de8c46e2 --- /dev/null +++ b/src/services/megabox/theaterInfoCache.ts @@ -0,0 +1,45 @@ +/** 지점 주소·좌표만 한 시간 재사용하며 상영 회차와 좌석은 보관하지 않습니다. */ +import type { MegaboxTheaterInfo } from './types.js'; + +const MAX_ENTRIES = 256; +const TTL_MS = 60 * 60_000; +const values = new Map(); +const pending = new Map>(); + +export function clearMegaboxTheaterInfoCache(): void { + values.clear(); + pending.clear(); +} + +export async function withMegaboxTheaterInfoCache( + theaterId: string, + request: () => Promise, + bypassCache = false, + timeout?: number, +): Promise { + if (bypassCache) return request(); + const cached = values.get(theaterId); + if (cached && cached.expiresAt > Date.now()) return { ...cached.value }; + values.delete(theaterId); + const pendingKey = JSON.stringify([theaterId, timeout]); + const existing = pending.get(pendingKey); + if (existing) return { ...(await existing) }; + if (pending.size >= MAX_ENTRIES) return request(); + const promise = request().then((value) => { + if ( + value.latitude !== null && + value.longitude !== null && + pending.get(pendingKey) === promise + ) { + if (values.size >= MAX_ENTRIES) values.delete(values.keys().next().value!); + values.set(theaterId, { expiresAt: Date.now() + TTL_MS, value: { ...value } }); + } + return value; + }); + pending.set(pendingKey, promise); + try { + return { ...(await promise) }; + } finally { + if (pending.get(pendingKey) === promise) pending.delete(pendingKey); + } +} diff --git a/src/services/oliveyoung/client.ts b/src/services/oliveyoung/client.ts index 38d68327..daaca8b1 100644 --- a/src/services/oliveyoung/client.ts +++ b/src/services/oliveyoung/client.ts @@ -142,6 +142,7 @@ export async function fetchOliveyoungStores( writeStaleResult(oliveyoungStoreSearchCache, cacheKey, result, cloneOliveyoungStoreSearchResult); return result; } catch (error) { + if (options.bypassCache) throw error; const staleResult = readStaleResult(oliveyoungStoreSearchCache, cacheKey, cloneOliveyoungStoreSearchResult); if (staleResult) { return staleResult; @@ -197,6 +198,7 @@ export async function fetchOliveyoungProducts( writeStaleResult(oliveyoungProductSearchCache, cacheKey, result, cloneOliveyoungProductSearchResult); return result; } catch (error) { + if (options.bypassCache) throw error; const staleResult = readStaleResult(oliveyoungProductSearchCache, cacheKey, cloneOliveyoungProductSearchResult); if (staleResult) { return { @@ -226,7 +228,7 @@ async function fetchOliveyoungProductId( const cached = oliveyoungProductIdCache.get(normalizedGoodsNumber); const now = Date.now(); - if (cached && cached.expiresAt > now) { + if (!options.bypassCache && cached && cached.expiresAt > now) { return cached.productId; } @@ -315,11 +317,13 @@ export async function enrichOliveyoungProductsWithNearbyStoreInventory( let productId = ''; try { productId = await fetchOliveyoungProductId(product.goodsNumber, options); - } catch { + } catch (error) { + if (options.bypassCache) throw error; return { checked: false, product }; } if (!productId) { + if (options.bypassCache) throw new Error('올리브영 상품 식별자가 없습니다.'); return { checked: false, product }; } @@ -335,7 +339,8 @@ export async function enrichOliveyoungProductsWithNearbyStoreInventory( }, options ); - } catch { + } catch (error) { + if (options.bypassCache) throw error; return { checked: false, product }; } diff --git a/src/services/oliveyoung/errors.ts b/src/services/oliveyoung/errors.ts index 9bbcde37..1d799c1f 100644 --- a/src/services/oliveyoung/errors.ts +++ b/src/services/oliveyoung/errors.ts @@ -6,7 +6,7 @@ export function toOliveyoungRelayError(error: unknown): ServiceError { if (error instanceof HttpError) { return new ServiceError( 'OLIVEYOUNG_RELAY_HTTP_ERROR', - `올리브영 브라우저 릴레이 요청 실패 (HTTP ${error.status})`, + `올리브영 릴레이 요청 실패 (HTTP ${error.status})`, [429, 502, 503].includes(error.status) ? error.status as 429 | 502 | 503 : 502, error.status >= 500 || error.status === 408 || error.status === 429, error.status, @@ -14,10 +14,10 @@ export function toOliveyoungRelayError(error: unknown): ServiceError { ); } if (error instanceof Error && ['AbortError', 'TimeoutError'].includes(error.name)) { - return new ServiceError('OLIVEYOUNG_RELAY_TIMEOUT', '올리브영 브라우저 릴레이 요청 시간 초과', 504, true); + return new ServiceError('OLIVEYOUNG_RELAY_TIMEOUT', '올리브영 릴레이 요청 시간 초과', 504, true); } if (error instanceof SyntaxError || error instanceof UnexpectedHtmlResponseError) { - return new ServiceError('OLIVEYOUNG_RELAY_INVALID_RESPONSE', '올리브영 브라우저 릴레이 응답 형식 오류', 502, false); + return new ServiceError('OLIVEYOUNG_RELAY_INVALID_RESPONSE', '올리브영 릴레이 응답 형식 오류', 502, false); } - return new ServiceError('OLIVEYOUNG_RELAY_NETWORK_ERROR', '올리브영 브라우저 릴레이 네트워크 요청 실패', 502, true); + return new ServiceError('OLIVEYOUNG_RELAY_NETWORK_ERROR', '올리브영 릴레이 네트워크 요청 실패', 502, true); } diff --git a/src/services/oliveyoung/transport.ts b/src/services/oliveyoung/transport.ts index 088d1975..341ddec1 100644 --- a/src/services/oliveyoung/transport.ts +++ b/src/services/oliveyoung/transport.ts @@ -31,6 +31,7 @@ function waitForBusy(delay: number, signal?: AbortSignal): Promise { export interface OliveyoungRequestOptions { /** 기존 호출부와의 호환용이며 유료 요청에는 사용하지 않습니다. */ apiKey?: string; + bypassCache?: boolean; timeout?: number; signal?: AbortSignal; relayUrl?: string; @@ -112,6 +113,7 @@ async function requestOliveyoungRaw( url = `${relayUrl.replace(/\/$/, '')}/v1/oliveyoung/${path.split('/').pop()}`; Object.assign(headers, diagnosticHeaders()); headers.Authorization = `Bearer ${relayToken}`; + if (options.bypassCache) headers['x-relay-cache-bypass'] = 'true'; consumer = (await relayConsumer(relayToken!)) || 'legacy'; if (consumer !== 'legacy') headers['x-relay-consumer'] = consumer; const { accessClientId, accessClientSecret } = options; diff --git a/src/services/seveneleven/transport.ts b/src/services/seveneleven/transport.ts index ed86885f..aeaffb31 100644 --- a/src/services/seveneleven/transport.ts +++ b/src/services/seveneleven/transport.ts @@ -51,7 +51,7 @@ export async function requestSevenElevenJson( } const url = `${SEVENELEVEN_API.BASE_URL}${path}`; - return withSevenElevenReadCache(path, body, timeout, () => + const request = () => fetchJsonWithZyteFallback>(url, { ...SEVENELEVEN_DEFAULT_FETCH_OPTIONS, method, @@ -61,6 +61,6 @@ export async function requestSevenElevenJson( body: method === 'POST' ? JSON.stringify(body || {}) : undefined, zyteApiKey, zyteTags: { service: 'seveneleven' }, - }), - ); + }); + return options.bypassCache ? request() : withSevenElevenReadCache(path, body, timeout, request); } diff --git a/src/utils/cache.ts b/src/utils/cache.ts index a0a3ac61..c2077ea0 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -6,6 +6,7 @@ */ interface EdgeCacheOptions { + bypassCache?: boolean; ttlSeconds: number; staleWhileRevalidateSeconds?: number; keyPrefix?: string; @@ -22,7 +23,7 @@ function createCacheKey(urlString: string, keyPrefix?: string): Request { const normalized = new URL(`${url.origin}${url.pathname}`); const sortedEntries = Array.from(url.searchParams.entries()).sort(([a], [b]) => - a.localeCompare(b) + a.localeCompare(b), ); for (const [key, value] of sortedEntries) { @@ -52,8 +53,13 @@ function buildCacheControl(options: EdgeCacheOptions): string { export async function withEdgeCache( requestUrl: string, options: EdgeCacheOptions, - fetcher: () => Promise + fetcher: () => Promise, ): Promise { + if (options.bypassCache) { + const response = await fetcher(); + response.headers.set('Cache-Control', 'no-store'); + return response; + } const cacheStorage = getCacheStorage(); const cache = cacheStorage?.default; diff --git a/src/utils/convenienceTransport.ts b/src/utils/convenienceTransport.ts index 1742358f..d6c2462b 100644 --- a/src/utils/convenienceTransport.ts +++ b/src/utils/convenienceTransport.ts @@ -8,6 +8,7 @@ import { isValidDtryxRelayUrl } from '../services/dtryx/transport.js'; import { createTimeoutController, parseRetryAfterDelayMs } from './http.js'; import { parseRelayQuota } from './relayQuota.js'; export interface ConvenienceTransportOptions { + bypassCache?: boolean; convenienceRelayUrl?: string; convenienceRelayToken?: string; convenienceAccessClientId?: string; @@ -90,6 +91,7 @@ async function requestConvenienceRelayRaw( Accept: 'application/json', Authorization: `Bearer ${token}`, }; + if (options.bypassCache) headers['x-relay-cache-bypass'] = 'true'; if (access) { headers['CF-Access-Client-Id'] = id!; headers['CF-Access-Client-Secret'] = secret!; diff --git a/tests/api/gs25-handlers.test.ts b/tests/api/gs25-handlers.test.ts index a64c7e95..9cddc347 100644 --- a/tests/api/gs25-handlers.test.ts +++ b/tests/api/gs25-handlers.test.ts @@ -290,7 +290,7 @@ describe('handleGs25CheckInventory', () => { error: { code: 'GS25_UPSTREAM_UNAVAILABLE', message: - 'GS25 재고 서비스 인증을 사용할 수 없습니다. 운영자는 GS25_API_KEY 설정을 확인하세요.', + 'GS25 재고 서비스 인증을 사용할 수 없습니다. 운영자는 GS25_AUTH_SESSION_FILE의 인증 갱신 상태를 확인하세요. API 키 방식은 GS25_API_KEY 설정을 확인하세요.', }, }), 503, diff --git a/tests/api/operational-edge-freshness.test.ts b/tests/api/operational-edge-freshness.test.ts new file mode 100644 index 00000000..8ee6ad12 --- /dev/null +++ b/tests/api/operational-edge-freshness.test.ts @@ -0,0 +1,61 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; +import { registerSevenElevenRoutes } from '../../src/api/routes/sevenelevenRoutes.js'; +import { registerGs25Routes } from '../../src/api/routes/gs25Routes.js'; +import { registerCuRoutes } from '../../src/api/routes/cuRoutes.js'; +import { registerCgvRoutes } from '../../src/api/routes/cgvRoutes.js'; +import type { AppBindings } from '../../src/api/response.js'; +const { handler } = vi.hoisted(() => ({ + handler: vi.fn(async () => Response.json({ source: 'upstream' })), +})); +vi.mock('../../src/api/sevenelevenHandlers.js', () => ({ + handleSevenElevenSearchProducts: handler, + handleSevenElevenSearchStores: handler, + handleSevenElevenCheckInventory: handler, + handleSevenElevenGetSearchPopwords: handler, + handleSevenElevenGetCatalogSnapshot: handler, +})); +vi.mock('../../src/api/gs25Handlers.js', () => ({ + handleGs25FindStores: handler, + handleGs25SearchProducts: handler, + handleGs25CheckInventory: handler, +})); +vi.mock('../../src/api/handlers.js', () => ({ + handleCuFindStores: handler, + handleCuCheckInventory: handler, +})); +vi.mock('../../src/api/cgvHandlers.js', () => ({ + handleCgvFindTheaters: handler, + handleCgvSearchMovies: handler, + handleCgvGetTimetable: handler, +})); +afterEach(() => vi.unstubAllGlobals()); +it.each([ + [registerSevenElevenRoutes, '/api/seveneleven/products'], + [registerGs25Routes, '/api/gs25/products'], + [registerCuRoutes, '/api/cu/stores'], + [registerCgvRoutes, '/api/cgv/theaters'], +])('인증된 fresh 요청만 따뜻한 Edge 캐시를 우회한다 %s', async (register, path) => { + const match = vi.fn(async () => Response.json({ source: 'cached' })); + const put = vi.fn(); + vi.stubGlobal('caches', { default: { match, put } }); + const app = new Hono<{ Bindings: AppBindings }>(); + register(app); + const env = { HEALTH_CHECK_SECRET: 'secret' }; + expect(await (await app.request(path, undefined, env)).json()).toEqual({ source: 'cached' }); + const fresh = await app.request( + path, + { headers: { 'x-health-check-key': 'secret', 'x-health-check-force-fresh': 'true' } }, + env, + ); + expect(await fresh.json()).toEqual({ source: 'upstream' }); + expect(fresh.headers.get('Cache-Control')).toBe('no-store'); + expect(match).toHaveBeenCalledTimes(1); + expect(put).not.toHaveBeenCalled(); + const invalid = await app.request( + path, + { headers: { 'x-health-check-key': 'wrong', 'x-health-check-force-fresh': 'true' } }, + env, + ); + expect(await invalid.json()).toEqual({ source: 'cached' }); +}); diff --git a/tests/api/operational-freshness.test.ts b/tests/api/operational-freshness.test.ts new file mode 100644 index 00000000..e8806f34 --- /dev/null +++ b/tests/api/operational-freshness.test.ts @@ -0,0 +1,19 @@ +import { expect, it } from 'vitest'; +import { isFreshOperationalRequest } from '../../src/api/operationalFreshness.js'; +import type { ApiContext } from '../../src/api/response.js'; + +it.each([ + ['true', 'test-secret', 'test-secret', true], + ['true', 'wrong-secret', 'test-secret', false], + ['true', 'test-secret', undefined, false], + [undefined, 'test-secret', 'test-secret', false], + ['false', 'test-secret', 'test-secret', false], +])('운영 fresh=%s 인증=%s 설정=%s 조건을 검증한다', async (flag, token, secret, expected) => { + const headers = new Headers({ 'x-health-check-key': token }); + if (flag) headers.set('x-health-check-force-fresh', flag); + const c = { + req: { raw: { headers } }, + env: { HEALTH_CHECK_SECRET: secret }, + } as unknown as ApiContext; + expect(await isFreshOperationalRequest(c)).toBe(expected); +}); diff --git a/tests/api/response.test.ts b/tests/api/response.test.ts index 3954790d..bd78fdd7 100644 --- a/tests/api/response.test.ts +++ b/tests/api/response.test.ts @@ -102,7 +102,8 @@ describe('GS25 인증 장애 안내', () => { { status: 503 }, ); expect(diagnostics.retryable).toBe(false); - expect(diagnostics.hint).toContain('GS25_API_KEY'); + expect(diagnostics.hint).toContain('GS25_AUTH_SESSION_FILE'); + expect(diagnostics.hint).toContain('갱신'); }); }); @@ -127,7 +128,7 @@ describe('올리브영 무료 중계 설정 안내', () => { '올리브영 직접 요청 실패. 운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN으로 브라우저 릴레이를 설정해주세요.', ])('설정이 필요한 오류는 재시도를 권하지 않는다: %s', (message) => { expect(toStandardErrorDiagnostics('OLIVEYOUNG_PRODUCT_SEARCH_FAILED', message, { status: 500 })) - .toMatchObject({ retryable: false, hint: '운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN 및 브라우저 릴레이 실행 상태를 확인하세요.' }); + .toMatchObject({ retryable: false, hint: '운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN 및 릴레이 실행 상태를 확인하세요.' }); }); it.each(['올리브영 브라우저 릴레이 요청 실패', '올리브영 API 요청 시간 초과'])('일시적인 실행 오류의 재시도는 유지한다: %s', (message) => { expect(toStandardErrorDiagnostics('OLIVEYOUNG_PRODUCT_SEARCH_FAILED', message, { status: 500 }).retryable).toBe(true); diff --git a/tests/app/app-oliveyoung-fresh-health.test.ts b/tests/app/app-oliveyoung-fresh-health.test.ts new file mode 100644 index 00000000..569ec497 --- /dev/null +++ b/tests/app/app-oliveyoung-fresh-health.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, afterEach, expect, it, vi } from 'vitest'; +import app from '../../src/index.js'; +import { __testOnlyClearOliveyoungCaches } from '../../src/services/oliveyoung/client.js'; +const bindings = { + HEALTH_CHECK_SECRET: 'test-secret', + OY_RELAY_URL: 'https://relay.example', + OY_RELAY_TOKEN: 'trusted-token', +}; +beforeEach(() => __testOnlyClearOliveyoungCaches()); +afterEach(() => vi.unstubAllGlobals()); +it.each([ + 'products?keyword=fresh', + 'stores?keyword=fresh', + 'inventory?keyword=fresh&stockCheckLimit=0', +])('authenticated fresh %s forwards relay bypass', async (route) => { + const fetcher = vi + .fn() + .mockImplementation(async () => Response.json({ status: 'SUCCESS', data: {} })); + vi.stubGlobal('fetch', fetcher); + expect( + ( + await app.request( + '/api/oliveyoung/' + route, + { headers: { Authorization: 'Bearer test-secret', 'x-health-check-force-fresh': 'true' } }, + bindings, + ) + ).status, + ).toBe(200); + expect(fetcher.mock.calls.length).toBeGreaterThan(0); + for (const [, options] of fetcher.mock.calls) + expect(options.headers).toMatchObject({ 'x-relay-cache-bypass': 'true' }); +}); +it('unauthenticated fresh flags do not forward relay cache bypass', async () => { + const fetcher = vi + .fn() + .mockImplementation(async () => Response.json({ status: 'SUCCESS', data: {} })); + vi.stubGlobal('fetch', fetcher); + await app.request( + '/api/oliveyoung/products?keyword=untrusted', + { headers: { 'x-health-check-force-fresh': 'true' } }, + bindings, + ); + expect(fetcher.mock.calls[0][1].headers).not.toHaveProperty('x-relay-cache-bypass'); +}); +it('authenticated fresh product search reports upstream failure after warming stale data', async () => { + const fetcher = vi + .fn() + .mockImplementationOnce(async () => + Response.json({ + status: 'SUCCESS', + data: { totalCount: 1, serachList: [{ goodsNumber: 'A1' }] }, + }), + ) + .mockImplementation(async () => new Response('blocked', { status: 403 })); + vi.stubGlobal('fetch', fetcher); + expect( + (await app.request('/api/oliveyoung/products?keyword=stale-proof', undefined, bindings)).status, + ).toBe(200); + const response = await app.request( + '/api/oliveyoung/products?keyword=stale-proof', + { headers: { Authorization: 'Bearer test-secret', 'x-health-check-force-fresh': 'true' } }, + bindings, + ); + expect(response.status).toBe(502); + expect(fetcher).toHaveBeenCalledTimes(2); +}); diff --git a/tests/app/app-seven-fresh-health.test.ts b/tests/app/app-seven-fresh-health.test.ts new file mode 100644 index 00000000..ae408755 --- /dev/null +++ b/tests/app/app-seven-fresh-health.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +import app from '../../src/index.js'; +import { clearSevenElevenReadCache } from '../../src/services/seveneleven/readCache.js'; +import { __testOnlyClearHealthCheckCache } from '../../src/api/healthChecks.js'; + +beforeEach(() => { + clearSevenElevenReadCache(); + __testOnlyClearHealthCheckCache(); +}); +it('인증된 fresh 헬스 체크는 상품 캐시 이후 발생한 원본 오류를 감지한다', async () => { + const fetcher = vi + .fn() + .mockImplementationOnce(async () => + Response.json({ success: true, data: { content: [{ itemCd: '8801', itemOnm: '커피' }] } }), + ) + .mockImplementation(async () => new Response('blocked', { status: 403 })); + vi.stubGlobal('fetch', fetcher); + try { + expect((await app.request('/api/seveneleven/products?query=커피&size=1')).status).toBe(200); + const res = await app.request( + '/api/health/checks?check=seveneleven.products&fresh=true', + { + headers: { Authorization: 'Bearer test-secret', 'x-health-check-force-fresh': 'true' }, + }, + { HEALTH_CHECK_SECRET: 'test-secret' }, + ); + expect((await res.json()).checks[0].status).not.toBe('ok'); + expect(fetcher).toHaveBeenCalledTimes(2); + } finally { + vi.unstubAllGlobals(); + } +}); + +it('인증되지 않은 fresh 플래그로 공개 상품 캐시를 우회하지 않는다', async () => { + const fetcher = vi + .fn() + .mockImplementation(async () => + Response.json({ success: true, data: { content: [{ itemCd: '8801', itemOnm: '커피' }] } }), + ); + vi.stubGlobal('fetch', fetcher); + try { + await app.request('/api/seveneleven/products?query=커피&size=1'); + await app.request( + '/api/seveneleven/products?query=커피&size=1', + { + headers: { 'x-health-check-force-fresh': 'true', 'x-health-check-key': 'wrong' }, + }, + { HEALTH_CHECK_SECRET: 'test-secret' }, + ); + expect(fetcher).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + } +}); + +it.each([ + ['http://localhost', true], + ['https://untrusted.example', false], +])('fresh 플래그를 신뢰한 대상에만 전달한다: %s', async (origin, trusted) => { + const fetcher = vi + .fn() + .mockImplementation(async () => + Response.json({ + success: true, + data: { products: [{ itemName: '커피' }] }, + meta: { total: 1 }, + }), + ); + vi.stubGlobal('fetch', fetcher); + try { + await app.request( + `http://localhost/api/health/checks?check=seveneleven.products&fresh=true&transport=network&baseUrl=${encodeURIComponent(origin)}`, + { + headers: { Authorization: 'Bearer test-secret', 'x-health-check-force-fresh': 'true' }, + }, + { HEALTH_CHECK_SECRET: 'test-secret' }, + ); + const headers = new Headers(fetcher.mock.calls[0][1].headers); + expect(headers.get('x-health-check-force-fresh')).toBe(trusted ? 'true' : null); + expect(headers.get('x-health-check-key')).toBe(trusted ? 'test-secret' : null); + expect(String(fetcher.mock.calls[0][0])).toContain('_healthCheck='); + } finally { + vi.unstubAllGlobals(); + } +}); diff --git a/tests/relay/http-lifecycle.test.ts b/tests/relay/http-lifecycle.test.ts index ccd46064..84be46ba 100644 --- a/tests/relay/http-lifecycle.test.ts +++ b/tests/relay/http-lifecycle.test.ts @@ -5,7 +5,7 @@ const exec = vi.hoisted(() => vi.fn()); vi.mock('node:child_process', () => ({ execFile: exec })); import { createHttpLifecycle } from '../../scripts/relay/http-lifecycle.js'; let child: EventEmitter & { stdin: PassThrough; kill: ReturnType }; -let done: (error: Error | null, stdout: string) => void; +let done: (error: Error | null, stdout: string, stderr?: string) => void; beforeEach(() => { exec.mockReset(); exec.mockImplementation((_command, _args, _options, callback) => { @@ -181,3 +181,44 @@ it('ignores input pipe errors after the child has already exited', async () => { await call; await lifecycle.close(); }); + +it('records only numeric upstream failure metadata and clears it on success', async () => { + const lifecycle = await ready(); + const call = lifecycle.run('x', {}); + done(new Error('secret'), 'secret', 'HTTP relay request failed upstream_status=403\n'); + await expect(call).rejects.toThrow(/^HTTP relay request failed$/); + expect(lifecycle.status()).toMatchObject({ + lastFailure: { status: 403, at: expect.any(String) }, + }); + const retry = lifecycle.run('x', {}); + done(null, '{"status":"SUCCESS","data":{}}'); + await retry; + expect(lifecycle.status()).toMatchObject({ lastFailure: null }); + await lifecycle.close(); +}); +it.each([ + 'secret HTTP relay request failed upstream_status=403', + 'HTTP relay request failed upstream_status=999\n', + 'HTTP relay request failed upstream_status=403\nsecret', + '', +])('rejects untrusted child diagnostic text: %s', async (stderr) => { + const lifecycle = await ready(); + const call = lifecycle.run('x', {}); + done(new Error('secret'), '', stderr); + await expect(call).rejects.toThrow(/^HTTP relay request failed$/); + expect(lifecycle.status()).toMatchObject({ + lastFailure: { status: null, at: expect.any(String) }, + }); + await lifecycle.close(); +}); +it('does not reuse a prior upstream status for a later local serialization failure', async () => { + const lifecycle = await ready(); + const call = lifecycle.run('x', {}); + done(new Error('private'), '', 'HTTP relay request failed upstream_status=403\n'); + await expect(call).rejects.toThrow('HTTP relay request failed'); + await expect(lifecycle.run('x', { value: 1n })).rejects.toThrow('HTTP relay request failed'); + expect(lifecycle.status()).toMatchObject({ + lastFailure: { status: null, at: expect.any(String) }, + }); + await lifecycle.close(); +}); diff --git a/tests/relay/http-relay-coalescing.test.ts b/tests/relay/http-relay-coalescing.test.ts index ee43f48e..082b7e65 100644 --- a/tests/relay/http-relay-coalescing.test.ts +++ b/tests/relay/http-relay-coalescing.test.ts @@ -174,3 +174,25 @@ it('공유 요청도 각 request ID와 coalesced 단계를 기록한다', async ['second', 'coalesced'], ]); }); + +it('인증된 캐시 우회는 저장 결과와 진행 작업을 사용하지 않고 예산을 적용한다', async () => { + const s = setup(); + const first = s.relay(request()); + await vi.waitFor(() => expect(s.upstream).toHaveBeenCalledTimes(1)); + const freshRequest = request(); + freshRequest.headers.set('x-relay-cache-bypass', 'true'); + const fresh = s.relay(freshRequest); + await vi.waitFor(() => expect(s.upstream).toHaveBeenCalledTimes(2)); + s.resolve({ quantity: 7 }); + await Promise.all([first, fresh]); + s.upstream.mockResolvedValue({ quantity: 9 }); + const nextFresh = request(); + nextFresh.headers.set('x-relay-cache-bypass', 'true'); + expect(await (await s.relay(nextFresh)).json()).toEqual({ quantity: 9 }); + expect(await (await s.relay(request())).json()).toEqual({ quantity: 7 }); + expect(s.takeQuota).toHaveBeenCalledTimes(3); + const unauthorized = request(undefined, undefined, 'wrong'); + unauthorized.headers.set('x-relay-cache-bypass', 'true'); + expect((await s.relay(unauthorized)).status).toBe(401); + expect(s.takeQuota).toHaveBeenCalledTimes(3); +}); diff --git a/tests/relay/oliveyoung.test.ts b/tests/relay/oliveyoung.test.ts index b20d8fa0..de9221fa 100644 --- a/tests/relay/oliveyoung.test.ts +++ b/tests/relay/oliveyoung.test.ts @@ -431,3 +431,28 @@ it('브라우저 교체 중 39999밀리초 기다린 작업은 실행하고 40 expect(runner).toHaveBeenCalledTimes(2); clock.mockRestore(); }); +it('fresh authenticated calls skip cached and pending values while retaining quotas', async () => { + const runner = vi.fn().mockResolvedValue({ status: 'SUCCESS', data: { goodsInfo: {} } }); + const takeQuota = vi.fn().mockResolvedValue(true); + const relay = createOliveyoungRelay('test', runner, { takeQuota }); + await relay(request()); + const fresh = () => new Request(request(), { headers: { Authorization: 'Bearer test', ...consumerHeaders(), 'x-relay-cache-bypass': 'true' } }); + const response = await relay(fresh()); + expect(response.headers.get('x-relay-cache')).toBe('miss'); + expect(runner).toHaveBeenCalledTimes(2); + expect(takeQuota).toHaveBeenCalledTimes(2); + let finish!: (value: unknown) => void; + runner.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const first = relay(fresh()); + await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(3)); + const second = relay(fresh()); + await new Promise((resolve) => setTimeout(resolve, 0)); + finish({ status: 'SUCCESS', data: { goodsInfo: {} } }); + await Promise.all([first, second]); + expect(runner).toHaveBeenCalledTimes(4); + expect(takeQuota).toHaveBeenCalledTimes(4); + takeQuota.mockResolvedValueOnce(false); + expect((await relay(fresh())).status).toBe(429); + expect(runner).toHaveBeenCalledTimes(4); + expect((await relay(new Request(fresh(), { headers: { Authorization: 'Bearer wrong', 'x-relay-cache-bypass': 'true' } }))).status).toBe(401); +}); diff --git a/tests/relay/test_oliveyoung_http.py b/tests/relay/test_oliveyoung_http.py index 4ac9afa7..677b07a8 100644 --- a/tests/relay/test_oliveyoung_http.py +++ b/tests/relay/test_oliveyoung_http.py @@ -64,6 +64,15 @@ def test_http_error_and_timeout_are_sanitized(self): with self.assertRaisesRegex(self.helper.RelayError, '^Upstream unavailable$'): self.helper.fetch(next(iter(self.helper.PATHS)), {}) + def test_main_preserves_only_numeric_upstream_status(self): + for status in [403, 302, 503]: + opener = self.request() + opener.open.side_effect = HTTPError('secret-url', status, 'private', {}, io.BytesIO(b'secret-token')) + stdin = unittest.mock.Mock(buffer=io.BytesIO(json.dumps({'path': next(iter(self.helper.PATHS)), 'body': {}}).encode())) + with patch.object(self.helper.sys, 'argv', ['helper']), patch.object(self.helper.sys, 'stdin', stdin), patch.object(self.helper.sys, 'stderr', new=io.StringIO()) as output, patch.object(self.helper.urllib.request, 'build_opener', return_value=opener): + self.assertEqual(self.helper.main(), 1) + self.assertEqual(output.getvalue(), f'HTTP relay request failed upstream_status={status}\n') + def test_main_check_invalid_and_large_input(self): with patch.object(self.helper.sys, 'argv', ['helper', '--check']), patch.object(self.helper.sys, 'stdout', new=io.StringIO()) as output: self.assertEqual(self.helper.main(), 0) diff --git a/tests/services/cgv/relay.test.ts b/tests/services/cgv/relay.test.ts index b159d259..4d9d3ec4 100644 --- a/tests/services/cgv/relay.test.ts +++ b/tests/services/cgv/relay.test.ts @@ -179,16 +179,21 @@ it('네트워크 실패 응답의 원문은 외부에 노출하지 않는다', a it('실패 본문 취소 오류는 요청 오류를 바꾸지 않는다', async () => { vi.stubGlobal( 'fetch', - vi - .fn() - .mockResolvedValue({ - status: 503, - body: { cancel: () => Promise.reject(new Error('private')) }, - headers: new Headers(), - }), + vi.fn().mockResolvedValue({ + status: 503, + body: { cancel: () => Promise.reject(new Error('private')) }, + headers: new Headers(), + }), ); await expect(requestCgv(path, params, 1000, undefined, options)).rejects.toMatchObject({ code: 'CGV_RELAY_FAILED', upstreamStatus: 503, }); }); + +it('운영 fresh 옵션은 CGV 릴레이 캐시를 우회한다', async () => { + const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ statusCode: 0, data: [] })); + vi.stubGlobal('fetch', fetcher); + await requestCgv(path, params, 1000, undefined, { ...options, bypassCache: true }); + expect(fetcher.mock.calls[0][1].headers).toMatchObject({ 'x-relay-cache-bypass': 'true' }); +}); diff --git a/tests/services/gs25/client.test.ts b/tests/services/gs25/client.test.ts index 1a309551..8f7d2565 100644 --- a/tests/services/gs25/client.test.ts +++ b/tests/services/gs25/client.test.ts @@ -52,7 +52,9 @@ describe('fetchGs25Stores', () => { 'GS25', ); expect(mockFetch).toHaveBeenCalledTimes(1); - expect(mockFetch.mock.calls.some(([url]) => new URL(String(url)).hostname === 'api.zyte.com')).toBe(false); + expect( + mockFetch.mock.calls.some(([url]) => new URL(String(url)).hostname === 'api.zyte.com'), + ).toBe(false); }); it('store/stock 403이어도 Zyte 키가 없으면 원본 에러를 반환한다', async () => { @@ -258,12 +260,16 @@ describe('fetchGs25SearchProducts', () => { }); it('상품 검색 차단 시 유료 호출 없이 원본 오류를 반환한다', async () => { - mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403, statusText: 'Forbidden' })); + mockFetch.mockResolvedValueOnce( + new Response('blocked', { status: 403, statusText: 'Forbidden' }), + ); await expect(fetchGs25SearchProducts('콜라', { zyteApiKey: 'test-key' })).rejects.toThrow( 'API 요청 실패: 403 Forbidden - blocked', ); expect(mockFetch).toHaveBeenCalledTimes(1); - expect(mockFetch.mock.calls.some(([url]) => new URL(String(url)).hostname === 'api.zyte.com')).toBe(false); + expect( + mockFetch.mock.calls.some(([url]) => new URL(String(url)).hostname === 'api.zyte.com'), + ).toBe(false); }); it('상품 검색 403이어도 Zyte 키가 없으면 원본 에러를 반환한다', async () => { @@ -564,7 +570,16 @@ describe('geocodeGs25Address', () => { vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch.mockResolvedValue( new Response( - JSON.stringify({ documents: [{ place_name: '서울시 강남구', address_name: '경기도 안산시 단원구 중앙대로 885', y: 37.3172, x: 126.8354 }] }), + JSON.stringify({ + documents: [ + { + place_name: '서울시 강남구', + address_name: '경기도 안산시 단원구 중앙대로 885', + y: 37.3172, + x: 126.8354, + }, + ], + }), ), ); @@ -589,9 +604,7 @@ describe('geocodeGs25Address', () => { it('status가 OK가 아니면 null을 반환한다', async () => { vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); - mockFetch.mockResolvedValue( - new Response(JSON.stringify({ documents: [] })), - ); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ documents: [] }))); const result = await geocodeGs25Address('없는 주소', { kakaoRestApiKey: 'test-key', @@ -601,7 +614,20 @@ describe('geocodeGs25Address', () => { it('결과 location이 없으면 null을 반환한다', async () => { vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); - mockFetch.mockResolvedValue(new Response(JSON.stringify({ documents: [{ place_name: '서울시 강남구', address_name: '경기도 안산시 단원구 중앙대로 885', y: undefined, x: undefined }] }))); + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + documents: [ + { + place_name: '서울시 강남구', + address_name: '경기도 안산시 단원구 중앙대로 885', + y: undefined, + x: undefined, + }, + ], + }), + ), + ); const result = await geocodeGs25Address('서울 강남구', { kakaoRestApiKey: 'test-key', @@ -613,7 +639,16 @@ describe('geocodeGs25Address', () => { vi.stubEnv('KAKAO_REST_API_KEY', 'test-free-key'); mockFetch.mockResolvedValue( new Response( - JSON.stringify({ documents: [{ place_name: '서울시 강남구', address_name: '경기도 안산시 단원구 중앙대로 885', y: 0, x: 0 }] }), + JSON.stringify({ + documents: [ + { + place_name: '서울시 강남구', + address_name: '경기도 안산시 단원구 중앙대로 885', + y: 0, + x: 0, + }, + ], + }), ), ); @@ -623,3 +658,19 @@ describe('geocodeGs25Address', () => { expect(result).toBeNull(); }); }); + +it('운영 fresh 조회는 GS25 직접 매장 캐시를 읽거나 덮어쓰지 않는다', async () => { + mockFetch + .mockImplementationOnce(async () => + Response.json({ stores: [{ storeCode: 'VE463', storeName: '기존점' }] }), + ) + .mockImplementationOnce(async () => + Response.json({ stores: [{ storeCode: 'VE463', storeName: '새점' }] }), + ); + await fetchGs25Stores(); + const fresh = await fetchGs25Stores({}, { bypassCache: true }); + expect(fresh.stores[0].storeName).toBe('새점'); + expect(fresh.cacheHit).toBe(false); + expect((await fetchGs25Stores()).stores[0].storeName).toBe('기존점'); + expect(mockFetch).toHaveBeenCalledTimes(2); +}); diff --git a/tests/services/megabox/location-cache.test.ts b/tests/services/megabox/location-cache.test.ts new file mode 100644 index 00000000..21269b44 --- /dev/null +++ b/tests/services/megabox/location-cache.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { + __testOnlyClearMegaboxLocationCaches, + fetchMegaboxNearbyTheaters, +} from '../../../src/services/megabox/location.js'; + +const params = { latitude: 37.5, longitude: 127, areaCode: '11', playDate: '20261011', limit: 1 }; +const fetcher = vi.fn(); +beforeEach(() => { + __testOnlyClearMegaboxLocationCaches(); + fetcher.mockReset().mockImplementation(async (url: string) => { + if (String(url).includes('selectBokdList')) + return Response.json({ + areaBrchList: [ + { brchNo: 'one', brchNm: '멀리' }, + { brchNo: 'two', brchNm: '가까이' }, + ], + }); + return new Response( + '지도
  • 도로명주소 : 서울 지점
  • ', + ); + }); + vi.stubGlobal('fetch', fetcher); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +it('지점 좌표와 주소는 재사용하고 상영 목록은 매번 조회한다', async () => { + const first = await fetchMegaboxNearbyTheaters(params); + first.theaters[0].address = 'changed'; + const second = await fetchMegaboxNearbyTheaters(params); + expect(second.theaters[0].address).toBe('서울 지점'); + expect( + fetcher.mock.calls.filter(([url]) => !String(url).includes('selectBokdList')), + ).toHaveLength(2); + expect(fetcher.mock.calls.filter(([url]) => String(url).includes('selectBokdList'))).toHaveLength( + 2, + ); +}); + +it('운영 fresh 옵션은 정적 지점 캐시를 우회한다', async () => { + await fetchMegaboxNearbyTheaters(params); + await fetchMegaboxNearbyTheaters(params, { bypassCache: true }); + expect(fetcher).toHaveBeenCalledTimes(6); +}); diff --git a/tests/services/megabox/theaterInfoCache.test.ts b/tests/services/megabox/theaterInfoCache.test.ts new file mode 100644 index 00000000..e5f47ffe --- /dev/null +++ b/tests/services/megabox/theaterInfoCache.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { + clearMegaboxTheaterInfoCache, + withMegaboxTheaterInfoCache, +} from '../../../src/services/megabox/theaterInfoCache.js'; +const value = { theaterId: 'one', address: '서울', latitude: 37.5, longitude: 127 }; +beforeEach(clearMegaboxTheaterInfoCache); +afterEach(() => vi.useRealTimers()); + +it('동일 지점의 진행 중 조회를 공유하고 반환 객체를 분리한다', async () => { + let finish!: (result: typeof value) => void; + const request = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const a = withMegaboxTheaterInfoCache('one', request); + const b = withMegaboxTheaterInfoCache('one', request); + finish(value); + const [first, second] = await Promise.all([a, b]); + first.address = '수정'; + expect(second.address).toBe('서울'); + expect((await withMegaboxTheaterInfoCache('one', request)).address).toBe('서울'); + expect(request).toHaveBeenCalledTimes(1); +}); + +it('한 시간이 지나면 다시 조회한다', async () => { + vi.useFakeTimers(); + const request = vi.fn().mockResolvedValue(value); + await withMegaboxTheaterInfoCache('one', request); + vi.advanceTimersByTime(3_600_000 - 1); + await withMegaboxTheaterInfoCache('one', request); + vi.advanceTimersByTime(1); + await withMegaboxTheaterInfoCache('one', request); + expect(request).toHaveBeenCalledTimes(2); +}); + +it('오류와 좌표 미확인 응답은 저장하지 않는다', async () => { + const request = vi + .fn() + .mockRejectedValueOnce(new Error('failed')) + .mockResolvedValueOnce({ ...value, latitude: null }) + .mockResolvedValueOnce({ ...value, longitude: null }) + .mockResolvedValue(value); + await expect(withMegaboxTheaterInfoCache('one', request)).rejects.toThrow('failed'); + await withMegaboxTheaterInfoCache('one', request); + await withMegaboxTheaterInfoCache('one', request); + await withMegaboxTheaterInfoCache('one', request); + expect(request).toHaveBeenCalledTimes(4); +}); + +it('성공 결과 256개 상한과 초기화를 적용한다', async () => { + const request = vi.fn().mockResolvedValue(value); + for (let i = 0; i < 257; i++) await withMegaboxTheaterInfoCache(String(i), request); + await withMegaboxTheaterInfoCache('0', request); + expect(request).toHaveBeenCalledTimes(258); + clearMegaboxTheaterInfoCache(); + await withMegaboxTheaterInfoCache('0', request); + expect(request).toHaveBeenCalledTimes(259); +}); + +it('진행 중 작업 256개를 넘는 새 키는 보관하지 않는다', async () => { + const finishes: Array<(result: typeof value) => void> = []; + const request = vi.fn(() => new Promise((resolve) => finishes.push(resolve))); + const calls = Array.from({ length: 256 }, (_, i) => + withMegaboxTheaterInfoCache(String(i), request), + ); + const extra = vi.fn().mockResolvedValue(value); + await withMegaboxTheaterInfoCache('extra', extra); + await withMegaboxTheaterInfoCache('extra', extra); + expect(extra).toHaveBeenCalledTimes(2); + finishes.forEach((finish) => finish(value)); + await Promise.all(calls); +}); + +it('fresh 조회는 기존 진행 작업을 공유하거나 캐시를 갱신하지 않는다', async () => { + let finish!: (result: typeof value) => void; + const request = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const old = withMegaboxTheaterInfoCache('one', request); + const fresh = vi.fn().mockResolvedValue({ ...value, address: '새 주소' }); + expect((await withMegaboxTheaterInfoCache('one', fresh, true)).address).toBe('새 주소'); + finish(value); + await old; + expect((await withMegaboxTheaterInfoCache('one', fresh)).address).toBe('서울'); +}); + +it('초기화 전에 시작한 조회가 늦게 끝나도 새 조회 결과를 대체하지 않는다', async () => { + let finishOld!: (result: typeof value) => void; + let finishNew!: (result: typeof value) => void; + const oldRequest = vi.fn( + () => + new Promise((resolve) => { + finishOld = resolve; + }), + ); + const newRequest = vi.fn( + () => + new Promise((resolve) => { + finishNew = resolve; + }), + ); + const old = withMegaboxTheaterInfoCache('one', oldRequest); + clearMegaboxTheaterInfoCache(); + const current = withMegaboxTheaterInfoCache('one', newRequest); + finishOld({ ...value, address: '초기화 이전 주소' }); + expect((await old).address).toBe('초기화 이전 주소'); + const follower = withMegaboxTheaterInfoCache('one', newRequest); + finishNew({ ...value, address: '현재 주소' }); + expect((await current).address).toBe('현재 주소'); + expect((await follower).address).toBe('현재 주소'); + expect(newRequest).toHaveBeenCalledTimes(1); + expect((await withMegaboxTheaterInfoCache('one', newRequest)).address).toBe('현재 주소'); +}); + +it('서로 다른 제한 시간의 진행 요청은 공유하지 않고 짧은 요청의 실패를 격리한다', async () => { + vi.useFakeTimers(); + const longRequest = vi.fn( + () => new Promise((resolve) => setTimeout(() => resolve(value), 1000)), + ); + const shortRequest = vi.fn( + () => + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error('지점 조회 시간 초과')), 10), + ), + ); + let longFinished = false; + let shortError: string | undefined; + const long = withMegaboxTheaterInfoCache('one', longRequest, false, 1000).then((result) => { + longFinished = true; + return result; + }); + const short = withMegaboxTheaterInfoCache('one', shortRequest, false, 10).catch( + (error: Error) => { + shortError = error.message; + }, + ); + await vi.advanceTimersByTimeAsync(10); + expect(shortError).toBe('지점 조회 시간 초과'); + expect(longFinished).toBe(false); + expect(shortRequest).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(990); + expect(await long).toEqual(value); + await short; + expect(await withMegaboxTheaterInfoCache('one', shortRequest, false, 10)).toEqual(value); + expect(shortRequest).toHaveBeenCalledTimes(1); +}); diff --git a/tests/services/oliveyoung/freshness.test.ts b/tests/services/oliveyoung/freshness.test.ts new file mode 100644 index 00000000..75bccd42 --- /dev/null +++ b/tests/services/oliveyoung/freshness.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +const request = vi.hoisted(() => vi.fn()); +vi.mock('../../../src/services/oliveyoung/transport.js', () => ({ requestOliveyoung: request })); +import { + __testOnlyClearOliveyoungCaches, + fetchOliveyoungProducts, + fetchOliveyoungStores, + enrichOliveyoungProductsWithNearbyStoreInventory, +} from '../../../src/services/oliveyoung/client.js'; +const productsParams = { keyword: '선크림', page: 1, size: 1, sort: '01', includeSoldOut: false }; +const storesParams = { latitude: 37.5, longitude: 127, pageIdx: 1, searchWords: '' }; +const enrichParams = { latitude: 37.5, longitude: 127, storeKeyword: '', maxProducts: 1 }; +beforeEach(() => { + request.mockReset(); + __testOnlyClearOliveyoungCaches(); +}); +it.each(['products', 'stores'])( + 'fresh %s rejects upstream failure after warming stale fallback', + async (operation) => { + request.mockResolvedValueOnce({ status: 'SUCCESS', data: {} }); + const call = () => + operation === 'products' + ? fetchOliveyoungProducts(productsParams) + : fetchOliveyoungStores(storesParams); + await call(); + request.mockRejectedValue(new Error('upstream blocked')); + await expect( + operation === 'products' + ? fetchOliveyoungProducts(productsParams, { bypassCache: true }) + : fetchOliveyoungStores(storesParams, { bypassCache: true }), + ).rejects.toThrow('upstream blocked'); + }, +); +it('fresh inventory re-reads product identifiers and propagates stock failures', async () => { + request.mockResolvedValueOnce({ + status: 'SUCCESS', + data: { serachList: [{ goodsNumber: 'A1' }] }, + }); + const products = (await fetchOliveyoungProducts(productsParams)).products; + request + .mockResolvedValueOnce({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: 'M1' } } }) + .mockResolvedValueOnce({ status: 'SUCCESS', data: { storeList: [] } }); + await enrichOliveyoungProductsWithNearbyStoreInventory(products, enrichParams); + request + .mockResolvedValueOnce({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: 'M1' } } }) + .mockRejectedValueOnce(new Error('stock blocked')); + await expect( + enrichOliveyoungProductsWithNearbyStoreInventory(products, enrichParams, { bypassCache: true }), + ).rejects.toThrow('stock blocked'); + expect(request.mock.calls.filter(([path]) => path.endsWith('stock-goods-info-v3'))).toHaveLength( + 2, + ); +}); +it('fresh inventory propagates identifier lookup failures', async () => { + request.mockResolvedValueOnce({ + status: 'SUCCESS', + data: { serachList: [{ goodsNumber: 'A1' }] }, + }); + const products = (await fetchOliveyoungProducts(productsParams)).products; + request.mockRejectedValueOnce(new Error('goods blocked')); + await expect( + enrichOliveyoungProductsWithNearbyStoreInventory(products, enrichParams, { bypassCache: true }), + ).rejects.toThrow('goods blocked'); +}); +it('fresh inventory rejects a missing identifier rather than returning unchecked inventory', async () => { + request.mockResolvedValueOnce({ + status: 'SUCCESS', + data: { serachList: [{ goodsNumber: 'A1' }] }, + }); + const products = (await fetchOliveyoungProducts(productsParams)).products; + request.mockResolvedValueOnce({ status: 'SUCCESS', data: { goodsInfo: {} } }); + await expect( + enrichOliveyoungProductsWithNearbyStoreInventory(products, enrichParams, { bypassCache: true }), + ).rejects.toThrow('상품 식별자'); +}); diff --git a/tests/services/oliveyoung/transport.test.ts b/tests/services/oliveyoung/transport.test.ts index 443a57c6..90e72336 100644 --- a/tests/services/oliveyoung/transport.test.ts +++ b/tests/services/oliveyoung/transport.test.ts @@ -92,7 +92,7 @@ it('로컬 릴레이 요청의 실패는 비밀 없이 전달하고 재시도하 const { requestOliveyoung } = await import('../../../src/services/oliveyoung/transport.js'); await expect( requestOliveyoung('/p', {}, { relayUrl: 'http://127.0.0.1:4319/', relayToken: 'secret' }), - ).rejects.toThrow('올리브영 브라우저 릴레이 요청 실패'); + ).rejects.toThrow('올리브영 릴레이 요청 실패'); expect(fetch).toHaveBeenCalledTimes(1); }); it('도구의 사용자 입력으로 신뢰한 릴레이 설정을 덮어쓸 수 없다', async () => { diff --git a/tests/services/seveneleven/readCache.test.ts b/tests/services/seveneleven/readCache.test.ts index 77247aca..aa827851 100644 --- a/tests/services/seveneleven/readCache.test.ts +++ b/tests/services/seveneleven/readCache.test.ts @@ -1,22 +1,35 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SEVENELEVEN_API } from '../../../src/services/seveneleven/api.js'; -import { clearSevenElevenReadCache, withSevenElevenReadCache } from '../../../src/services/seveneleven/readCache.js'; +import { + clearSevenElevenReadCache, + withSevenElevenReadCache, +} from '../../../src/services/seveneleven/readCache.js'; -beforeEach(() => { clearSevenElevenReadCache(); vi.useRealTimers(); }); +beforeEach(() => { + clearSevenElevenReadCache(); + vi.useRealTimers(); +}); const path = SEVENELEVEN_API.SEARCH_GOODS_PATH; const result = () => ({ success: true, data: { values: ['original'] } }); describe('세븐일레븐 공용 읽기 캐시', () => { it('같은 조회를 합치고 성공 결과를 복제해서 재사용한다', async () => { let finish!: (value: ReturnType) => void; - const request = vi.fn(() => new Promise>(resolve => { finish = resolve; })); + const request = vi.fn( + () => + new Promise>((resolve) => { + finish = resolve; + }), + ); const first = withSevenElevenReadCache(path, { query: '커피' }, 1000, request); const second = withSevenElevenReadCache(path, { query: '커피' }, 1000, request); finish(result()); const [a, b] = await Promise.all([first, second]); a.data!.values.push('changed'); expect(b.data!.values).toEqual(['original']); - expect(await withSevenElevenReadCache(path, { query: '커피' }, 1000, request)).toEqual(result()); + expect(await withSevenElevenReadCache(path, { query: '커피' }, 1000, request)).toEqual( + result(), + ); expect(request).toHaveBeenCalledTimes(1); }); it('실패한 요청은 저장하지 않고 다음 조회를 허용한다', async () => { @@ -45,7 +58,10 @@ describe('세븐일레븐 공용 읽기 캐시', () => { it('상품은 5분, 매장은 30분이 지나면 갱신한다', async () => { vi.useFakeTimers(); const request = vi.fn().mockResolvedValue(result()); - for (const [target, ttl] of [[path, 300000], [SEVENELEVEN_API.SEARCH_STORE_PATH, 1800000]] as const) { + for (const [target, ttl] of [ + [path, 300000], + [SEVENELEVEN_API.SEARCH_STORE_PATH, 1800000], + ] as const) { await withSevenElevenReadCache(target, {}, 1000, request); vi.advanceTimersByTime(ttl - 1); await withSevenElevenReadCache(target, {}, 1000, request); @@ -65,36 +81,78 @@ describe('세븐일레븐 공용 읽기 캐시', () => { it('진행 중 조회가 128개이면 추가 키는 보관하지 않는다', async () => { const finishes: Array<(value: ReturnType) => void> = []; - const request = vi.fn(() => new Promise>(resolve => finishes.push(resolve))); - const calls = Array.from({ length: 128 }, (_, i) => withSevenElevenReadCache(path, { i }, 1000, request)); + const request = vi.fn( + () => new Promise>((resolve) => finishes.push(resolve)), + ); + const calls = Array.from({ length: 128 }, (_, i) => + withSevenElevenReadCache(path, { i }, 1000, request), + ); const extra = vi.fn().mockResolvedValue(result()); await withSevenElevenReadCache(path, { i: 129 }, 1000, extra); await withSevenElevenReadCache(path, { i: 129 }, 1000, extra); expect(extra).toHaveBeenCalledTimes(2); - finishes.forEach(finish => finish(result())); + finishes.forEach((finish) => finish(result())); await Promise.all(calls); }); it('실제 서비스 계층의 연속 검색이 원본 호출을 공유한다', async () => { const { searchSevenElevenProducts } = await import('../../../src/services/seveneleven/client.js'); - const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ success: true, data: { content: [{ itemCd: '8801', itemOnm: '커피' }] } }))); + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ success: true, data: { content: [{ itemCd: '8801', itemOnm: '커피' }] } }), + ), + ); vi.stubGlobal('fetch', fetchMock); try { const first = await searchSevenElevenProducts({ query: '공유 테스트' }); const second = await searchSevenElevenProducts({ query: '공유 테스트' }); expect(first).toEqual(second); expect(fetchMock).toHaveBeenCalledTimes(1); - } finally { vi.unstubAllGlobals(); } + } finally { + vi.unstubAllGlobals(); + } }); it('인기 검색어의 label 질의 문자열도 캐시하고 label별로 분리한다', async () => { - const { fetchSevenElevenSearchPopwords } = await import('../../../src/services/seveneleven/client.js'); - const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ success: true, data: ['커피'] })))); + const { fetchSevenElevenSearchPopwords } = + await import('../../../src/services/seveneleven/client.js'); + const fetchMock = vi + .fn() + .mockImplementation(() => + Promise.resolve(new Response(JSON.stringify({ success: true, data: ['커피'] }))), + ); vi.stubGlobal('fetch', fetchMock); try { await fetchSevenElevenSearchPopwords('goods'); await fetchSevenElevenSearchPopwords('goods'); await fetchSevenElevenSearchPopwords('store'); expect(fetchMock).toHaveBeenCalledTimes(2); - } finally { vi.unstubAllGlobals(); } + } finally { + vi.unstubAllGlobals(); + } +}); + +it('fresh 서비스 조회는 저장된 직접 조회 캐시를 읽거나 갱신하지 않는다', async () => { + const { searchSevenElevenProducts } = await import('../../../src/services/seveneleven/client.js'); + let name = '기존 커피'; + const fetcher = vi + .fn() + .mockImplementation(async () => + Response.json({ success: true, data: { content: [{ itemCd: '8801', itemOnm: name }] } }), + ); + vi.stubGlobal('fetch', fetcher); + try { + await searchSevenElevenProducts({ query: 'fresh-test' }); + name = '새 커피'; + const fresh = await searchSevenElevenProducts({ query: 'fresh-test' }, { bypassCache: true }); + expect(fresh.products[0].itemName).toBe('새 커피'); + expect((await searchSevenElevenProducts({ query: 'fresh-test' })).products[0].itemName).toBe( + '기존 커피', + ); + expect(fetcher).toHaveBeenCalledTimes(2); + } finally { + vi.unstubAllGlobals(); + } }); diff --git a/tests/utils/convenienceTransport.test.ts b/tests/utils/convenienceTransport.test.ts index dc557fd4..cd227ef4 100644 --- a/tests/utils/convenienceTransport.test.ts +++ b/tests/utils/convenienceTransport.test.ts @@ -198,3 +198,19 @@ it('세븐일레븐 GET·인기어·빈 POST도 고정 작업에 대응한다', vi.unstubAllGlobals(); } }); + +it('캐시 우회 옵션은 인증된 고정 릴레이 요청 헤더로만 전송한다', async () => { + const fetcher = vi.fn(async () => Response.json({ data: [] })); + vi.stubGlobal('fetch', fetcher); + try { + await requestConvenienceRelay('seven-pages', {}, { ...options, bypassCache: true }); + expect(fetcher.mock.calls[0][1].headers).toEqual( + expect.objectContaining({ + Authorization: 'Bearer token', + 'x-relay-cache-bypass': 'true', + }), + ); + } finally { + vi.unstubAllGlobals(); + } +});