diff --git a/.github/workflows/sync-worker-secrets.yml b/.github/workflows/sync-worker-secrets.yml
index 3466bd4e..55919a43 100644
--- a/.github/workflows/sync-worker-secrets.yml
+++ b/.github/workflows/sync-worker-secrets.yml
@@ -28,6 +28,7 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
ZYTE_API_KEY: ${{ secrets.ZYTE_API_KEY }}
+ GS25_API_KEY: ${{ secrets.GS25_API_KEY }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
NAVER_CLIENT_ID: ${{ secrets.NAVER_CLIENT_ID }}
NAVER_CLIENT_SECRET: ${{ secrets.NAVER_CLIENT_SECRET }}
@@ -48,6 +49,7 @@ jobs:
}
put_secret_if_set ZYTE_API_KEY "$ZYTE_API_KEY"
+ put_secret_if_set GS25_API_KEY "$GS25_API_KEY"
put_secret_if_set GOOGLE_MAPS_API_KEY "$GOOGLE_MAPS_API_KEY"
put_secret_if_set NAVER_CLIENT_ID "$NAVER_CLIENT_ID"
put_secret_if_set NAVER_CLIENT_SECRET "$NAVER_CLIENT_SECRET"
diff --git a/docs/superpowers/plans/2026-07-28-service-reliability-recovery.md b/docs/superpowers/plans/2026-07-28-service-reliability-recovery.md
new file mode 100644
index 00000000..07fadd4c
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-28-service-reliability-recovery.md
@@ -0,0 +1,114 @@
+# Service Reliability Recovery Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 운영에서 확인된 다섯 장애와 CGV 이슈 #156을 정직한 오류 계약과 실제 복구 경로로 개선한다.
+
+**Architecture:** 공용 날짜와 헬스체크 shape를 중앙에서 교정하고, 서비스별 전송 계층은 성공 가능한 경로를 우선 사용한다. 외부 인증·결제·차단으로 복구할 수 없는 경우 빈 성공 대신 서비스별 503 응답을 반환한다.
+
+**Tech Stack:** TypeScript 6, Hono, Cloudflare Workers, Vitest, Wrangler
+
+---
+
+### Task 1: 한국 날짜 고정
+
+**Files:**
+- Modify: `src/utils/format.ts`
+- Create: `tests/utils/format.test.ts`
+
+- [ ] `2026-07-27T15:30:00Z`가 `20260728`이 되는 실패 테스트를 추가한다.
+- [ ] `npx vitest run tests/utils/format.test.ts --maxWorkers=1 --no-file-parallelism`로 기존 로컬 날짜 구현의 실패를 확인한다.
+- [ ] `Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit' })`의 `formatToParts`로 `YYYYMMDD`를 만든다.
+- [ ] 같은 단일 테스트를 다시 실행해 통과를 확인한다.
+
+### Task 2: 헬스체크 shape와 빈 결과 판정
+
+**Files:**
+- Modify: `src/api/healthCheckTypes.ts`
+- Modify: `src/api/healthCheckShape.ts`
+- Modify: `src/api/healthCheckDefinitions.ts`
+- Modify: `src/api/healthChecks.ts`
+- Modify: `tests/api/health-checks.test.ts`
+
+- [ ] `inventoryStores`, 이마트24 top-level stores, count 미확인, 선택적 빈 결과의 기대 판정을 테스트한다.
+- [ ] `npx vitest run tests/api/health-checks.test.ts --maxWorkers=1 --no-file-parallelism`로 실패를 확인한다.
+- [ ] `collectionKey: 'inventoryStores'`와 `allowEmpty?: boolean`을 추가한다.
+- [ ] `inventory.stores`를 count·sample·shape에 포함하고 빈 필수 컬렉션을 shape 실패로 처리한다.
+- [ ] `count === null`은 degraded, `allowEmpty && count === 0`은 skipped로 판정한다.
+- [ ] 서비스 정의의 실제 컬렉션과 대표 필드를 교정하고 단일 테스트를 통과시킨다.
+
+### Task 3: GS25 인증 Secret과 unavailable 계약
+
+**Files:**
+- Modify: `src/api/response.ts`
+- Modify: `src/index.ts`
+- Modify: `src/services/gs25/index.ts`
+- Modify: `src/services/gs25/client.ts`
+- Modify: `src/services/gs25/tools/checkInventory.ts`
+- Modify: `src/api/gs25Handlers.ts`
+- Modify: `.github/workflows/sync-worker-secrets.yml`
+- Modify: `tests/services/gs25/client.test.ts`
+- Modify: `tests/api/gs25-handlers.test.ts`
+
+- [ ] `Api-Key` 헤더 주입과 401 인증 실패의 503 변환 테스트를 추가한다.
+- [ ] GS25 클라이언트 테스트와 핸들러 테스트를 각각 단독 실행해 실패를 확인한다.
+- [ ] `GS25_API_KEY`를 AppBindings와 서비스 옵션으로 전달하고 stock 요청 헤더에만 추가한다.
+- [ ] 401/403 인증 실패를 `Gs25UpstreamUnavailableError`로 정규화하고 핸들러에서 `GS25_UPSTREAM_UNAVAILABLE` 503을 반환한다.
+- [ ] MCP 도구와 Worker Secret 동기화 경로를 연결하고 두 단일 테스트를 통과시킨다.
+
+### Task 4: 롯데마트 전송 복구
+
+**Files:**
+- Modify: `src/services/lottemart/api.ts`
+- Modify: `src/services/lottemart/config.ts`
+- Modify: `src/services/lottemart/session.ts`
+- Modify: `tests/services/lottemart/session.test.ts`
+- Modify: `tests/services/lottemart/debug.test.ts`
+
+- [ ] 표준 fetch 우선, HTTP origin, 제한된 fallback 순서를 검증하는 실패 테스트를 추가한다.
+- [ ] 두 테스트 파일을 각각 단독 실행해 실패를 확인한다.
+- [ ] 공식 HTTP base URL을 사용하고 표준 fetch 성공 시 소켓과 Zyte를 호출하지 않도록 한다.
+- [ ] 표준 fetch 실패 시에만 남은 시간 예산으로 소켓과 Zyte를 순차 실행한다.
+- [ ] 두 단일 테스트를 통과시키고 운영 debug 요청이 제한시간 안에 매장을 반환하는지 확인한다.
+
+### Task 5: 세븐일레븐 선택적 인기 검색어
+
+**Files:**
+- Modify: `src/api/healthCheckDefinitions.ts`
+- Modify: `tests/api/health-checks.test.ts`
+- Modify: `tests/app/app-api-seveneleven.test.ts`
+
+- [ ] 원본 빈 객체가 `available:false` 응답과 skipped 헬스 상태로 유지되는 테스트를 추가한다.
+- [ ] 각 테스트 파일을 단독 실행해 실패를 확인한다.
+- [ ] popwords 정의에 `allowEmpty: true`를 적용하고 정상 데이터가 있을 때는 기존 ok 판정을 유지한다.
+- [ ] 두 단일 테스트를 통과시킨다.
+
+### Task 6: CGV 이슈 #156 graceful 처리
+
+**Files:**
+- Create: `src/services/cgv/errors.ts`
+- Modify: `src/services/cgv/transport.ts`
+- Modify: `src/api/cgvHandlers.ts`
+- Modify: `tests/services/cgv/transport.test.ts`
+- Modify: `tests/api/cgv-handlers.test.ts`
+
+- [ ] 직접 403 뒤 Zyte 403이 발생하면 typed unavailable 오류가 되는 테스트를 추가한다.
+- [ ] API가 `CGV_UPSTREAM_UNAVAILABLE` 503을 반환하는 테스트를 추가한다.
+- [ ] 두 테스트를 각각 단독 실행해 실패를 확인한다.
+- [ ] `CgvUpstreamUnavailableError`와 판별 함수를 추가하고 결제 상세를 일반 안내로 정규화한다.
+- [ ] 세 CGV 핸들러가 typed 오류에만 503을 반환하도록 최소 수정한다.
+- [ ] 두 단일 테스트를 통과시킨다.
+
+### Task 7: 전체 검증과 배포
+
+**Files:**
+- Verify all changed files
+
+- [ ] `npm run format:check`, `npm run lint`, `npm run lint:biome`, `npm run typecheck`, `npm run check:source-lines`를 차례로 실행한다.
+- [ ] `npx vitest run --maxWorkers=1 --no-file-parallelism`로 전체 테스트를 단일 워커로 실행한다.
+- [ ] `npx vitest run --coverage --maxWorkers=1 --no-file-parallelism`로 100% 커버리지를 확인한다.
+- [ ] `npm run build`와 `npm audit`를 순차 실행한다.
+- [ ] 변경사항을 자체 리뷰하고 커밋한 뒤 브랜치를 push하여 PR을 만든다.
+- [ ] PR CI를 확인하고 `main`에 병합한 뒤 Deploy 완료를 확인한다.
+- [ ] 운영 API에서 다이소·편의점·마트·영화관·오피넷·장소·비교 기능을 순차 재검증한다.
+- [ ] CGV 이슈 #156에 수정·배포·운영 확인 결과를 답변하고 필요하면 종료한다.
diff --git a/docs/superpowers/specs/2026-07-28-service-reliability-recovery-design.md b/docs/superpowers/specs/2026-07-28-service-reliability-recovery-design.md
new file mode 100644
index 00000000..8b721f42
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-28-service-reliability-recovery-design.md
@@ -0,0 +1,77 @@
+# 서비스 신뢰성 복구 설계
+
+## 목표
+
+운영 점검에서 확인된 영화관 기본 날짜, 헬스체크 오판, GS25 재고 인증 실패,
+롯데마트 매장 검색 지연, 세븐일레븐 인기 검색어 공백과 GitHub 이슈 #156의
+CGV Zyte 장애 처리를 함께 개선한다.
+
+## 검토한 접근
+
+1. 빈 결과를 계속 성공으로 반환한다.
+ 구현은 단순하지만 실제 장애와 정상적인 검색 결과 0건을 구분할 수 없다.
+2. 모든 외부 오류를 즉시 실패로 바꾼다.
+ 관측은 정확해지지만 선택적 데이터와 복구 가능한 경로까지 중단한다.
+3. 실제 대체 경로를 먼저 사용하고, 복구 불가능한 경우에만 명시적인
+ `upstream unavailable` 상태를 반환한다.
+
+세 번째 접근을 사용한다. 실제 데이터가 있는 경우에는 기존 성공 계약을 유지하고,
+인증·결제·차단 문제를 빈 성공으로 위장하지 않는다.
+
+## 설계
+
+### 한국 날짜
+
+공용 `toYyyymmdd`가 런타임의 로컬 시간대에 의존하지 않도록
+`Asia/Seoul` 달력 날짜를 `Intl.DateTimeFormat`으로 계산한다. CGV, 메가박스,
+롯데시네마의 API와 MCP 도구는 이미 공용 함수를 사용하므로 한 번의 수정으로
+같은 동작을 얻는다.
+
+### 헬스체크
+
+재고 응답별 실제 컬렉션을 구분한다.
+
+- CU: `data.inventory.items`
+- GS25·세븐일레븐: `data.inventory.stores`
+- 이마트24: `data.stores`
+- 올리브영: `data.inventory.products`
+
+필수 컬렉션을 찾지 못해 개수를 계산할 수 없는 경우도 `degraded`로 판정한다.
+빈 컬렉션의 대표 필드 검사를 자동 통과시키지 않는다. 세븐일레븐 인기 검색어처럼
+공식 API가 선택적 빈 데이터를 반환하는 체크는 `allowEmpty`로 표시하고
+`skipped`로 구분한다.
+
+### GS25 재고
+
+공식 앱이 사용하는 `Api-Key`를 `GS25_API_KEY` Secret으로만 주입한다. 키는
+소스·로그·오류 메시지에 기록하지 않는다. 401/403 또는 인증 오류 envelope를
+감지하면 빈 매장 목록 대신 `GS25_UPSTREAM_UNAVAILABLE` 503을 반환한다.
+MCP 서비스에도 같은 Secret을 전달한다. Secret을 확보할 수 없는 배포에서도
+거짓 재고 0건은 반환하지 않는다.
+
+### 롯데마트 매장
+
+공식 매장 안내가 가리키는 HTTP origin을 사용한다. Cloudflare 소켓을 먼저
+시도해 전체 요청 시간을 소모하지 않고, 표준 fetch를 우선한다. 표준 fetch가
+실패할 때만 제한된 소켓·Zyte 경로를 순차적으로 사용한다. 각 단계는 하나의
+총 요청 예산을 나눠 사용해 운영 요청이 45초 이상 멈추지 않도록 한다.
+
+### 세븐일레븐 인기 검색어
+
+공식 원본이 `200 {"data":{}}`를 반환하므로 인기 데이터를 임의 생성하지 않는다.
+API와 MCP 응답의 `available:false` 계약을 유지하고, 헬스체크에서 선택적 데이터
+미제공으로 정확히 분류한다.
+
+### CGV 이슈 #156
+
+직접 CGV 호출을 계속 우선하고 403일 때만 Zyte를 사용한다. Zyte 결제 정지,
+차단, 키 누락 등으로 우회까지 실패하면 결제 상세를 노출하지 않고
+`CGV_UPSTREAM_UNAVAILABLE` 503과 재시도 안내를 반환한다. 배포 후 CGV 극장과
+시간표를 운영 환경에서 확인하고 이슈 #156에 결과를 답변한다.
+
+## 테스트와 배포
+
+각 수정은 실패하는 단일 회귀 테스트를 먼저 실행한 다음 최소 구현으로 통과시킨다.
+마지막에 포맷, ESLint, Biome, 타입 검사, 450줄 제한, 전체 테스트, 100% 커버리지,
+빌드를 순차 실행한다. PR을 `main`에 병합하고 Deploy·CI·Coverage·CodeQL을 확인한
+뒤 운영 API 전체 스모크를 다시 수행한다.
diff --git a/src/api/cgvHandlers.ts b/src/api/cgvHandlers.ts
index a60f602f..58326b76 100644
--- a/src/api/cgvHandlers.ts
+++ b/src/api/cgvHandlers.ts
@@ -2,7 +2,13 @@
* CGV GET API 핸들러
*/
-import { fetchCgvMovies, fetchCgvTheaters, fetchCgvTimetable, toYyyymmdd } from '../services/cgv/client.js';
+import {
+ fetchCgvMovies,
+ fetchCgvTheaters,
+ fetchCgvTimetable,
+ toYyyymmdd,
+} from '../services/cgv/client.js';
+import { isCgvUpstreamUnavailableError } from '../services/cgv/errors.js';
import { fetchCgvNearbyTheaters, resolveCgvNearestTheater } from '../services/cgv/location.js';
import { filterAndSortTimetable } from '../services/cgv/timetable.js';
import { type ApiContext, errorResponse, successResponse } from './response.js';
@@ -75,6 +81,9 @@ export async function handleCgvFindTheaters(c: ApiContext) {
{ total: sliced.length, pageSize: limit },
);
} catch (error) {
+ if (isCgvUpstreamUnavailableError(error)) {
+ return errorResponse(c, 'CGV_UPSTREAM_UNAVAILABLE', error.message, 503);
+ }
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
return errorResponse(c, 'CGV_THEATER_SEARCH_FAILED', message, 500);
}
@@ -95,7 +104,10 @@ export async function handleCgvSearchMovies(c: ApiContext) {
try {
let resolvedTheater = null;
- if (!theaterCode && (keyword || typeof latitude === 'number' || typeof longitude === 'number')) {
+ if (
+ !theaterCode &&
+ (keyword || typeof latitude === 'number' || typeof longitude === 'number')
+ ) {
const resolved = await resolveCgvNearestTheater(
{
playDate,
@@ -141,6 +153,9 @@ export async function handleCgvSearchMovies(c: ApiContext) {
{ total: movies.length },
);
} catch (error) {
+ if (isCgvUpstreamUnavailableError(error)) {
+ return errorResponse(c, 'CGV_UPSTREAM_UNAVAILABLE', error.message, 503);
+ }
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
return errorResponse(c, 'CGV_MOVIE_SEARCH_FAILED', message, 500);
}
@@ -163,7 +178,10 @@ export async function handleCgvGetTimetable(c: ApiContext) {
try {
let resolvedTheater = null;
- if (!theaterCode && (keyword || typeof latitude === 'number' || typeof longitude === 'number')) {
+ if (
+ !theaterCode &&
+ (keyword || typeof latitude === 'number' || typeof longitude === 'number')
+ ) {
const resolved = await resolveCgvNearestTheater(
{
playDate,
@@ -218,6 +236,9 @@ export async function handleCgvGetTimetable(c: ApiContext) {
{ total: filtered.length, pageSize: limit },
);
} catch (error) {
+ if (isCgvUpstreamUnavailableError(error)) {
+ return errorResponse(c, 'CGV_UPSTREAM_UNAVAILABLE', error.message, 503);
+ }
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
return errorResponse(c, 'CGV_TIMETABLE_FETCH_FAILED', message, 500);
}
diff --git a/src/api/gs25Handlers.ts b/src/api/gs25Handlers.ts
index 7e74758a..fd9e82db 100644
--- a/src/api/gs25Handlers.ts
+++ b/src/api/gs25Handlers.ts
@@ -14,6 +14,7 @@ import {
selectGs25StoresForKeyword,
sortGs25Stores,
} from '../services/gs25/client.js';
+import { isGs25UpstreamUnavailableError } from '../services/gs25/errors.js';
const GS25_FALLBACK_STORE_LOOKUP_ITEM_CODE = '8801117752804';
@@ -62,6 +63,7 @@ export async function handleGs25FindStores(c: ApiContext) {
{
timeout: 20000,
zyteApiKey: c.env?.ZYTE_API_KEY,
+ apiKey: c.env?.GS25_API_KEY,
},
);
let fallbackUsed = false;
@@ -84,6 +86,7 @@ export async function handleGs25FindStores(c: ApiContext) {
{
timeout: 20000,
zyteApiKey: c.env?.ZYTE_API_KEY,
+ apiKey: c.env?.GS25_API_KEY,
},
);
@@ -242,6 +245,7 @@ export async function handleGs25CheckInventory(c: ApiContext) {
{
timeout: 20000,
zyteApiKey: c.env?.ZYTE_API_KEY,
+ apiKey: c.env?.GS25_API_KEY,
},
);
@@ -284,6 +288,7 @@ export async function handleGs25CheckInventory(c: ApiContext) {
{
timeout: 20000,
zyteApiKey: c.env?.ZYTE_API_KEY,
+ apiKey: c.env?.GS25_API_KEY,
},
);
} else {
@@ -309,6 +314,7 @@ export async function handleGs25CheckInventory(c: ApiContext) {
{
timeout: 20000,
zyteApiKey: c.env?.ZYTE_API_KEY,
+ apiKey: c.env?.GS25_API_KEY,
},
);
} else {
@@ -324,6 +330,7 @@ export async function handleGs25CheckInventory(c: ApiContext) {
{
timeout: 20000,
zyteApiKey: c.env?.ZYTE_API_KEY,
+ apiKey: c.env?.GS25_API_KEY,
},
);
}
@@ -378,6 +385,9 @@ export async function handleGs25CheckInventory(c: ApiContext) {
},
});
} catch (error) {
+ if (isGs25UpstreamUnavailableError(error)) {
+ return errorResponse(c, 'GS25_UPSTREAM_UNAVAILABLE', error.message, 503);
+ }
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
return errorResponse(c, 'GS25_INVENTORY_CHECK_FAILED', message, 500);
}
diff --git a/src/api/healthCheckDefinitions.ts b/src/api/healthCheckDefinitions.ts
index 87b11b66..220951aa 100644
--- a/src/api/healthCheckDefinitions.ts
+++ b/src/api/healthCheckDefinitions.ts
@@ -10,15 +10,9 @@ export const GS25_CLOUDFRONT_403_PATTERNS = [
'403 ERROR',
];
-export const EMART24_UPSTREAM_403_PATTERNS = [
- '403 Forbidden',
- '
403 Forbidden',
-];
+export const EMART24_UPSTREAM_403_PATTERNS = ['403 Forbidden', '403 Forbidden'];
-export const CU_UPSTREAM_BLOCK_PATTERNS = [
- '400 Bad Request',
- 'Request Blocked',
-];
+export const CU_UPSTREAM_BLOCK_PATTERNS = ['400 Bad Request', 'Request Blocked'];
export const SEVENELEVEN_UPSTREAM_403_PATTERNS = [
'403 Forbidden',
@@ -119,6 +113,7 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [
mode: 'quick',
path: '/api/seveneleven/popwords?label=home',
degradedFailurePatterns: SEVENELEVEN_UPSTREAM_403_PATTERNS,
+ allowEmpty: true,
},
{
id: 'lottemart.products',
@@ -182,8 +177,8 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [
target: 'inventory',
mode: 'deep',
path: '/api/emart24/inventory?keyword=%EC%BB%A4%ED%94%BC&storeKeyword=%EA%B0%95%EB%82%A8&limit=1',
- collectionKey: 'inventoryItems',
- requiredFields: ['pluCd', 'goodsName', 'itemName', 'name'],
+ collectionKey: 'stores',
+ requiredFields: ['storeCode', 'storeName', 'name'],
degradedFailurePatterns: EMART24_UPSTREAM_403_PATTERNS,
},
{
@@ -192,8 +187,8 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [
target: 'inventory',
mode: 'deep',
path: '/api/gs25/inventory?keyword=%EC%BD%9C%EB%9D%BC&storeKeyword=%EA%B0%95%EB%82%A8&limit=1',
- collectionKey: 'inventoryItems',
- requiredFields: ['itemCode', 'itemName', 'name'],
+ collectionKey: 'inventoryStores',
+ requiredFields: ['storeCode', 'storeName', 'name'],
degradedFailurePatterns: [
'401 Unauthorized',
'인증키가 제공되지 않음',
@@ -206,8 +201,8 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [
target: 'inventory',
mode: 'deep',
path: '/api/seveneleven/inventory?keyword=%EC%BB%A4%ED%94%BC&storeKeyword=%EA%B0%95%EB%82%A8&size=1',
- collectionKey: 'inventoryItems',
- requiredFields: ['itemCode', 'itemName', 'productNo', 'name'],
+ collectionKey: 'inventoryStores',
+ requiredFields: ['storeCode', 'storeName', 'name'],
degradedFailurePatterns: SEVENELEVEN_UPSTREAM_403_PATTERNS,
},
{
diff --git a/src/api/healthCheckShape.ts b/src/api/healthCheckShape.ts
index ca6cf187..85f64d9b 100644
--- a/src/api/healthCheckShape.ts
+++ b/src/api/healthCheckShape.ts
@@ -23,7 +23,7 @@ export function toCount(data: unknown): number | null {
if (record.inventory && typeof record.inventory === 'object') {
const inventory = record.inventory as Record;
- for (const key of ['products', 'items']) {
+ for (const key of ['products', 'items', 'stores']) {
const value = inventory[key];
if (Array.isArray(value)) {
return value.length;
@@ -46,7 +46,15 @@ export function toFirstName(data: unknown): string | undefined {
continue;
}
const item = value[0] as Record;
- for (const nameKey of ['productName', 'itemName', 'goodsName', 'name', 'storeName', 'theaterName', 'movieName']) {
+ for (const nameKey of [
+ 'productName',
+ 'itemName',
+ 'goodsName',
+ 'name',
+ 'storeName',
+ 'theaterName',
+ 'movieName',
+ ]) {
if (typeof item[nameKey] === 'string' && item[nameKey].trim().length > 0) {
return item[nameKey].trim();
}
@@ -55,13 +63,13 @@ export function toFirstName(data: unknown): string | undefined {
if (record.inventory && typeof record.inventory === 'object') {
const inventory = record.inventory as Record;
- for (const key of ['products', 'items']) {
+ for (const key of ['products', 'items', 'stores']) {
const value = inventory[key];
if (!Array.isArray(value) || !value[0] || typeof value[0] !== 'object') {
continue;
}
const item = value[0] as Record;
- for (const nameKey of ['productName', 'itemName', 'goodsName', 'name']) {
+ for (const nameKey of ['productName', 'itemName', 'goodsName', 'name', 'storeName']) {
if (typeof item[nameKey] === 'string' && item[nameKey].trim().length > 0) {
return item[nameKey].trim();
}
@@ -72,18 +80,30 @@ export function toFirstName(data: unknown): string | undefined {
return undefined;
}
-function getCollectionItems(data: unknown, collectionKey?: HealthCheckDefinition['collectionKey']): unknown[] {
+function getCollectionItems(
+ data: unknown,
+ collectionKey?: HealthCheckDefinition['collectionKey'],
+): unknown[] {
if (!data || typeof data !== 'object') {
return [];
}
const record = data as Record;
- if (collectionKey === 'inventoryProducts' || collectionKey === 'inventoryItems') {
+ if (
+ collectionKey === 'inventoryProducts' ||
+ collectionKey === 'inventoryItems' ||
+ collectionKey === 'inventoryStores'
+ ) {
const inventory = record.inventory;
if (!inventory || typeof inventory !== 'object') {
return [];
}
- const key = collectionKey === 'inventoryProducts' ? 'products' : 'items';
+ const key =
+ collectionKey === 'inventoryProducts'
+ ? 'products'
+ : collectionKey === 'inventoryStores'
+ ? 'stores'
+ : 'items';
const value = (inventory as Record)[key];
return Array.isArray(value) ? value : [];
}
@@ -114,7 +134,7 @@ export function hasRequiredRepresentativeFields(
const items = getCollectionItems(data, collectionKey);
if (items.length === 0) {
- return true;
+ return false;
}
const first = items[0];
@@ -125,6 +145,8 @@ export function hasRequiredRepresentativeFields(
const record = first as Record;
return requiredFields.some((field) => {
const value = record[field];
- return typeof value === 'string' ? value.trim().length > 0 : value !== undefined && value !== null;
+ return typeof value === 'string'
+ ? value.trim().length > 0
+ : value !== undefined && value !== null;
});
}
diff --git a/src/api/healthCheckTypes.ts b/src/api/healthCheckTypes.ts
index 2e0b3e0d..80a347ee 100644
--- a/src/api/healthCheckTypes.ts
+++ b/src/api/healthCheckTypes.ts
@@ -19,10 +19,12 @@ export interface HealthCheckDefinition {
| 'movies'
| 'showtimes'
| 'inventoryProducts'
- | 'inventoryItems';
+ | 'inventoryItems'
+ | 'inventoryStores';
requiredFields?: string[];
timeoutMs?: number;
degradedFailurePatterns?: string[];
+ allowEmpty?: boolean;
}
export interface HealthCheckResult {
diff --git a/src/api/healthChecks.ts b/src/api/healthChecks.ts
index 38845ae5..086e3013 100644
--- a/src/api/healthChecks.ts
+++ b/src/api/healthChecks.ts
@@ -78,7 +78,9 @@ function createCacheKey(params: HealthCheckCacheKeyParams): string {
].join('|');
}
-function selectChecks(params: Pick): HealthCheckDefinition[] {
+function selectChecks(
+ params: Pick,
+): HealthCheckDefinition[] {
const mode = params.mode || 'quick';
return HEALTH_CHECKS.filter((check) => {
if (mode !== 'full' && check.mode !== mode) {
@@ -126,6 +128,9 @@ function aggregateStatus(checks: HealthCheckResult[]): HealthCheckStatus {
if (checks.some((check) => check.status === 'degraded')) {
return 'degraded';
}
+ if (checks.every((check) => check.status === 'skipped')) {
+ return 'skipped';
+ }
return 'ok';
}
@@ -146,7 +151,10 @@ function shouldDegradeCliContractPath(path: string, message: string): boolean {
return false;
}
-function resolveCheckTimeoutMs(check: Pick, timeoutMs: number): number {
+function resolveCheckTimeoutMs(
+ check: Pick,
+ timeoutMs: number,
+): number {
if (check.timeoutMs === undefined) {
return timeoutMs;
}
@@ -160,7 +168,12 @@ function resolveCliContractTimeoutMs(path: string, timeoutMs: number): number {
return timeoutMs;
}
-function buildCheckUrl(baseUrl: string, check: HealthCheckDefinition, timeoutMs: number, cacheBustValue?: number): string {
+function buildCheckUrl(
+ baseUrl: string,
+ check: HealthCheckDefinition,
+ timeoutMs: number,
+ cacheBustValue?: number,
+): string {
const url = new URL(check.path, baseUrl);
url.searchParams.set('timeoutMs', String(timeoutMs));
if (typeof cacheBustValue === 'number') {
@@ -210,9 +223,12 @@ async function runCliContractCheck(
const checkTimeoutMs = resolveCliContractTimeoutMs(path, params.timeoutMs);
const syntheticCheck = { ...check, path };
try {
- const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, syntheticCheck, checkTimeoutMs, cacheBustValue), {
- signal: AbortSignal.timeout(checkTimeoutMs),
- });
+ const response = await params.fetchImpl(
+ buildCheckUrl(params.baseUrl, syntheticCheck, checkTimeoutMs, cacheBustValue),
+ {
+ signal: AbortSignal.timeout(checkTimeoutMs),
+ },
+ );
const body = (await response.json().catch(() => ({}))) as {
success?: boolean;
status?: string;
@@ -275,9 +291,12 @@ async function runSingleCheck(
const cacheBustValue = params.cacheBust ? startedAt : undefined;
try {
- const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, check, timeoutMs, cacheBustValue), {
- signal: AbortSignal.timeout(timeoutMs),
- });
+ const response = await params.fetchImpl(
+ buildCheckUrl(params.baseUrl, check, timeoutMs, cacheBustValue),
+ {
+ signal: AbortSignal.timeout(timeoutMs),
+ },
+ );
const body = (await response.json().catch(() => ({}))) as {
success?: boolean;
data?: unknown;
@@ -300,19 +319,31 @@ async function runSingleCheck(
}
const count = typeof body.meta?.total === 'number' ? body.meta.total : toCount(body.data);
- const shapeOk = hasRequiredRepresentativeFields(body.data, check.collectionKey, check.requiredFields);
+ const shapeOk = hasRequiredRepresentativeFields(
+ body.data,
+ check.collectionKey,
+ check.requiredFields,
+ );
const slowThresholdMs = params.slowThresholdMs || DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS;
const slow = slowThresholdMs > 0 && durationMs > slowThresholdMs;
- const status: HealthCheckStatus = count === 0 || !shapeOk || slow ? 'degraded' : 'ok';
+ const optionalEmpty = check.allowEmpty === true && count === 0;
+ const status: HealthCheckStatus = slow
+ ? 'degraded'
+ : optionalEmpty
+ ? 'skipped'
+ : count === null || count === 0 || !shapeOk
+ ? 'degraded'
+ : 'ok';
const first = params.includeSamples ? toFirstName(body.data) : undefined;
- const message =
- slow
- ? `slow response: ${durationMs}ms > ${slowThresholdMs}ms`
- : !shapeOk && count !== 0
- ? `response missing required fields: ${check.requiredFields!.join(', ')}`
+ const message = slow
+ ? `slow response: ${durationMs}ms > ${slowThresholdMs}ms`
+ : optionalEmpty
+ ? 'optional data unavailable'
: count === null
- ? 'response ok'
- : `${count} item(s) returned`;
+ ? 'response count unavailable'
+ : !shapeOk && count !== 0
+ ? `response missing required fields: ${check.requiredFields!.join(', ')}`
+ : `${count} item(s) returned`;
return {
id: check.id,
@@ -345,7 +376,13 @@ export async function runHealthChecks(params: RunHealthChecksParams): Promise 0
? Math.trunc(params.slowThresholdMs)
: DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS;
- const cacheKey = createCacheKey({ ...params, mode, timeoutMs, slowThresholdMs, baseUrl: params.baseUrl });
+ const cacheKey = createCacheKey({
+ ...params,
+ mode,
+ timeoutMs,
+ slowThresholdMs,
+ baseUrl: params.baseUrl,
+ });
const cached = healthCheckCache.get(cacheKey);
const startedAt = now();
@@ -356,7 +393,10 @@ export async function runHealthChecks(params: RunHealthChecksParams): Promise
+ const checks = await mapWithConcurrency(
+ selectChecks(params),
+ DEFAULT_HEALTH_CHECK_CONCURRENCY,
+ (check) =>
runSingleCheck(check, {
baseUrl: params.baseUrl,
fetchImpl,
diff --git a/src/api/response.ts b/src/api/response.ts
index aeea8f9f..3d0ed1c2 100644
--- a/src/api/response.ts
+++ b/src/api/response.ts
@@ -8,6 +8,7 @@ import { toStandardErrorDiagnostics } from '../core/errors.js';
export interface AppBindings {
DAILY_RATE_LIMITER?: DurableObjectNamespace;
ZYTE_API_KEY?: string;
+ GS25_API_KEY?: string;
GOOGLE_MAPS_API_KEY?: string;
NAVER_CLIENT_ID?: string;
NAVER_CLIENT_SECRET?: string;
diff --git a/src/index.ts b/src/index.ts
index d7f3d001..d13f3b11 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -81,6 +81,7 @@ const createRegistry = (bindings?: AppBindings) => {
createGs25Service({
googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
zyteApiKey: bindings?.ZYTE_API_KEY,
+ apiKey: bindings?.GS25_API_KEY,
}),
() => createSevenElevenService({ zyteApiKey: bindings?.ZYTE_API_KEY }),
createCompareService,
diff --git a/src/pages/openapiSpecPathsLotteMart.ts b/src/pages/openapiSpecPathsLotteMart.ts
index c70baef1..1d248f28 100644
--- a/src/pages/openapiSpecPathsLotteMart.ts
+++ b/src/pages/openapiSpecPathsLotteMart.ts
@@ -30,7 +30,15 @@ export const OPENAPI_PATHS_LOTTEMART = {
description: '브랜드 변형 필터',
schema: {
type: 'string',
- enum: ['lottemart', 'toysrus', 'max', 'bottlebunker', 'mealguru', 'grandgrocery', 'other'],
+ enum: [
+ 'lottemart',
+ 'toysrus',
+ 'max',
+ 'bottlebunker',
+ 'mealguru',
+ 'grandgrocery',
+ 'other',
+ ],
},
},
{
@@ -124,7 +132,7 @@ export const OPENAPI_PATHS_LOTTEMART = {
in: 'query',
required: false,
description: '요청 제한 시간(ms)',
- schema: { type: 'integer', default: 45000, minimum: 1 },
+ schema: { type: 'integer', default: 15000, minimum: 1 },
},
],
responses: {
diff --git a/src/services/cgv/errors.ts b/src/services/cgv/errors.ts
new file mode 100644
index 00000000..3046e10d
--- /dev/null
+++ b/src/services/cgv/errors.ts
@@ -0,0 +1,16 @@
+/**
+ * CGV 원본 및 대체 전송 경로를 사용할 수 없는 상태
+ */
+
+export class CgvUpstreamUnavailableError extends Error {
+ constructor() {
+ super('CGV 원본 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.');
+ this.name = 'CgvUpstreamUnavailableError';
+ }
+}
+
+export function isCgvUpstreamUnavailableError(
+ error: unknown,
+): error is CgvUpstreamUnavailableError {
+ return error instanceof CgvUpstreamUnavailableError;
+}
diff --git a/src/services/cgv/transport.ts b/src/services/cgv/transport.ts
index 4d082786..7d891078 100644
--- a/src/services/cgv/transport.ts
+++ b/src/services/cgv/transport.ts
@@ -7,6 +7,7 @@
import { createTimeoutController } from '../../utils/http.js';
import { decodeZyteHttpBody, requestByZyte } from '../../utils/zyte.js';
import { CGV_API } from './api.js';
+import { CgvUpstreamUnavailableError } from './errors.js';
function toBase64(bytes: Uint8Array): string {
if (typeof Buffer !== 'undefined') {
@@ -25,7 +26,11 @@ function toBase64(bytes: Uint8Array): string {
throw new Error('Base64 인코딩을 지원하지 않는 런타임입니다.');
}
-async function createSignature(pathname: string, bodyText: string, timestamp: string): Promise {
+async function createSignature(
+ pathname: string,
+ bodyText: string,
+ timestamp: string,
+): Promise {
const payload = `${timestamp}|${pathname}|${bodyText}`;
const encoder = new TextEncoder();
@@ -73,6 +78,9 @@ async function requestByZyteCgv(
tags: { service: 'cgv' },
});
+ if (result.statusCode === 401 || result.statusCode === 403) {
+ throw new CgvUpstreamUnavailableError();
+ }
return decodeZyteHttpBody(result);
}
@@ -103,8 +111,20 @@ export async function requestCgv(
return await parseJsonResponse(response);
}
- if (response.status === 403 && zyteApiKey) {
- return await requestByZyteCgv(path, searchParams, timeout, zyteApiKey);
+ if (response.status === 401 || response.status === 403) {
+ const normalizedZyteApiKey = zyteApiKey?.trim();
+ if (!normalizedZyteApiKey) {
+ throw new CgvUpstreamUnavailableError();
+ }
+
+ try {
+ return await requestByZyteCgv(path, searchParams, timeout, normalizedZyteApiKey);
+ } catch (fallbackError) {
+ if (fallbackError instanceof CgvUpstreamUnavailableError) {
+ throw fallbackError;
+ }
+ throw new CgvUpstreamUnavailableError();
+ }
}
throw new Error(`CGV API 호출 실패: ${response.status}`);
diff --git a/src/services/gs25/client.ts b/src/services/gs25/client.ts
index b4d89edd..14f18bbf 100644
--- a/src/services/gs25/client.ts
+++ b/src/services/gs25/client.ts
@@ -4,10 +4,10 @@
/* c8 ignore start */
import { fetchJson, fetchWithTimeout, HttpError } from '../../utils/http.js';
-import { decodeZyteHttpBody, requestByZyte } from '../../utils/zyte.js';
import { GS25_API } from './api.js';
import { normalizeStore, toNumber } from './storeUtils.js';
-import type { Gs25Store, Gs25StoreStockResponse } from './types.js';
+import { fetchGs25StoreStockResponse } from './storeStockTransport.js';
+import type { Gs25Store } from './types.js';
export {
fetchGs25NormalizedKeyword,
@@ -27,6 +27,7 @@ interface RequestOptions {
timeout?: number;
googleMapsApiKey?: string;
zyteApiKey?: string;
+ apiKey?: string;
}
interface FetchGs25StoresParams {
@@ -142,36 +143,6 @@ function normalizeGs25WebStore(raw: Gs25WebLocationStore): Gs25Store {
};
}
-async function fetchGs25StoreStock(
- url: string,
- options: RequestOptions,
-): Promise {
- try {
- return await fetchJson(url, {
- ...GS25_DEFAULT_FETCH_OPTIONS,
- method: 'GET',
- timeout: options.timeout,
- headers: GS25_DEFAULT_HEADERS,
- });
- } catch (error) {
- const zyteApiKey = options.zyteApiKey?.trim();
- if (!(error instanceof HttpError) || error.status !== 403 || !zyteApiKey) {
- throw error;
- }
-
- const result = await requestByZyte({
- apiKey: zyteApiKey,
- url,
- method: 'GET',
- timeout: options.timeout,
- retries: 1,
- headers: Object.entries(GS25_DEFAULT_HEADERS).map(([name, value]) => ({ name, value })),
- tags: { service: 'gs25' },
- });
- return decodeZyteHttpBody(result);
- }
-}
-
function buildCacheKey(
params: Required> &
Pick,
@@ -407,10 +378,15 @@ export async function fetchGs25Stores(
endpoint.searchParams.set('isGs25DlvyStoreSelected', 'N');
}
- const body = await fetchGs25StoreStock(endpoint.toString(), {
- timeout,
- zyteApiKey: options.zyteApiKey,
- });
+ const body = await fetchGs25StoreStockResponse(
+ endpoint.toString(),
+ {
+ timeout,
+ zyteApiKey: options.zyteApiKey,
+ apiKey: options.apiKey,
+ },
+ GS25_DEFAULT_HEADERS,
+ );
const stores = (body.stores || [])
.map(normalizeStore)
diff --git a/src/services/gs25/errors.ts b/src/services/gs25/errors.ts
new file mode 100644
index 00000000..dcd00a4c
--- /dev/null
+++ b/src/services/gs25/errors.ts
@@ -0,0 +1,16 @@
+/**
+ * GS25 원본 서비스 장애 오류
+ */
+
+export class Gs25UpstreamUnavailableError extends Error {
+ constructor() {
+ super('GS25 재고 서비스 인증을 사용할 수 없습니다. 잠시 후 다시 시도해주세요.');
+ this.name = 'Gs25UpstreamUnavailableError';
+ }
+}
+
+export function isGs25UpstreamUnavailableError(
+ error: unknown,
+): error is Gs25UpstreamUnavailableError {
+ return error instanceof Gs25UpstreamUnavailableError;
+}
diff --git a/src/services/gs25/index.ts b/src/services/gs25/index.ts
index 6508836d..2310f2b3 100644
--- a/src/services/gs25/index.ts
+++ b/src/services/gs25/index.ts
@@ -18,6 +18,7 @@ const GS25_METADATA: ServiceMetadata = {
interface Gs25ServiceOptions {
googleMapsApiKey?: string;
zyteApiKey?: string;
+ apiKey?: string;
}
class Gs25Service implements ServiceProvider {
@@ -27,9 +28,17 @@ class Gs25Service implements ServiceProvider {
getTools(): ToolRegistration[] {
return [
- createFindNearbyStoresTool(this.options.googleMapsApiKey, this.options.zyteApiKey),
+ createFindNearbyStoresTool(
+ this.options.googleMapsApiKey,
+ this.options.zyteApiKey,
+ this.options.apiKey,
+ ),
createSearchProductsTool(this.options.zyteApiKey),
- createCheckInventoryTool(this.options.googleMapsApiKey, this.options.zyteApiKey),
+ createCheckInventoryTool(
+ this.options.googleMapsApiKey,
+ this.options.zyteApiKey,
+ this.options.apiKey,
+ ),
];
}
}
diff --git a/src/services/gs25/storeStockTransport.ts b/src/services/gs25/storeStockTransport.ts
new file mode 100644
index 00000000..38790370
--- /dev/null
+++ b/src/services/gs25/storeStockTransport.ts
@@ -0,0 +1,71 @@
+/**
+ * GS25 재고 원본 요청과 Zyte 대체 경로
+ */
+
+import { fetchJson, HttpError } from '../../utils/http.js';
+import { decodeZyteHttpBody, requestByZyte } from '../../utils/zyte.js';
+import { Gs25UpstreamUnavailableError } from './errors.js';
+import type { Gs25StoreStockResponse } from './types.js';
+
+interface StoreStockTransportOptions {
+ timeout?: number;
+ zyteApiKey?: string;
+ apiKey?: string;
+}
+
+function withApiKey(headers: Record, apiKey?: string): Record {
+ const normalizedApiKey = apiKey?.trim();
+ return normalizedApiKey ? { ...headers, 'Api-Key': normalizedApiKey } : headers;
+}
+
+function isAuthenticationStatus(status?: number): boolean {
+ return status === 401 || status === 403;
+}
+
+export async function fetchGs25StoreStockResponse(
+ url: string,
+ options: StoreStockTransportOptions,
+ headers: Record,
+): Promise {
+ const requestHeaders = withApiKey(headers, options.apiKey);
+
+ try {
+ return await fetchJson(url, {
+ method: 'GET',
+ timeout: options.timeout,
+ retries: 1,
+ retryDelayMs: 250,
+ headers: requestHeaders,
+ });
+ } catch (error) {
+ if (!(error instanceof HttpError) || !isAuthenticationStatus(error.status)) {
+ throw error;
+ }
+
+ const zyteApiKey = options.zyteApiKey?.trim();
+ if (error.status === 401 || !zyteApiKey) {
+ throw new Gs25UpstreamUnavailableError();
+ }
+
+ try {
+ const result = await requestByZyte({
+ apiKey: zyteApiKey,
+ url,
+ method: 'GET',
+ timeout: options.timeout,
+ retries: 1,
+ headers: Object.entries(requestHeaders).map(([name, value]) => ({ name, value })),
+ tags: { service: 'gs25' },
+ });
+ if (isAuthenticationStatus(result.statusCode)) {
+ throw new Gs25UpstreamUnavailableError();
+ }
+ return decodeZyteHttpBody(result);
+ } catch (fallbackError) {
+ if (fallbackError instanceof Gs25UpstreamUnavailableError) {
+ throw fallbackError;
+ }
+ throw new Gs25UpstreamUnavailableError();
+ }
+ }
+}
diff --git a/src/services/gs25/tools/checkInventory.ts b/src/services/gs25/tools/checkInventory.ts
index bdc80aea..f828e346 100644
--- a/src/services/gs25/tools/checkInventory.ts
+++ b/src/services/gs25/tools/checkInventory.ts
@@ -30,6 +30,7 @@ interface CheckInventoryArgs {
timeoutMs?: number;
googleMapsApiKey?: string;
zyteApiKey?: string;
+ apiKey?: string;
}
async function checkInventory(args: CheckInventoryArgs): Promise {
@@ -44,6 +45,7 @@ async function checkInventory(args: CheckInventoryArgs): Promise Promise,
};
}
diff --git a/src/services/gs25/tools/findNearbyStores.ts b/src/services/gs25/tools/findNearbyStores.ts
index 3dea4f4f..a8db74e2 100644
--- a/src/services/gs25/tools/findNearbyStores.ts
+++ b/src/services/gs25/tools/findNearbyStores.ts
@@ -28,6 +28,7 @@ interface FindNearbyStoresArgs {
timeoutMs?: number;
googleMapsApiKey?: string;
zyteApiKey?: string;
+ apiKey?: string;
}
async function findNearbyStores(args: FindNearbyStoresArgs): Promise {
@@ -40,6 +41,7 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise Promise,
};
}
diff --git a/src/services/lottemart/config.ts b/src/services/lottemart/config.ts
index 24485f64..a281b40e 100644
--- a/src/services/lottemart/config.ts
+++ b/src/services/lottemart/config.ts
@@ -2,4 +2,4 @@
* 롯데마트 서비스 공통 설정
*/
-export const DEFAULT_LOTTEMART_TIMEOUT_MS = 45000;
+export const DEFAULT_LOTTEMART_TIMEOUT_MS = 15000;
diff --git a/src/services/lottemart/session.ts b/src/services/lottemart/session.ts
index a568fd02..d032bcb0 100644
--- a/src/services/lottemart/session.ts
+++ b/src/services/lottemart/session.ts
@@ -18,6 +18,7 @@ export {
} from './socketTransport.js';
const SESSION_CACHE_TTL_MS = 5 * 60 * 1000;
+const FALLBACK_TIMEOUT_MS = 5000;
let sessionCache: { expiresAt: number; cookie: string } | null = null;
function extractSessionCookie(response: Response): string {
@@ -87,22 +88,32 @@ async function fetchLotteMartResponse(
timeout: number,
sessionCookie: string,
): Promise {
- const socketResponse = await fetchLotteMartSocketResponse(url, init, sessionCookie, timeout);
- if (socketResponse) {
- return socketResponse;
- }
+ const headers = withLotteMartSessionCookie(
+ {
+ Accept: 'text/html, */*; q=0.01',
+ ...init.headers,
+ },
+ sessionCookie,
+ );
- return fetchWithTimeout(url, {
- ...init,
- timeout,
- headers: withLotteMartSessionCookie(
- {
- Accept: 'text/html, */*; q=0.01',
- ...init.headers,
- },
+ try {
+ return await fetchWithTimeout(url, {
+ ...init,
+ timeout,
+ headers,
+ });
+ } catch (directError) {
+ const socketResponse = await fetchLotteMartSocketResponse(
+ url,
+ init,
sessionCookie,
- ),
- });
+ Math.min(timeout, FALLBACK_TIMEOUT_MS),
+ ).catch(() => null);
+ if (socketResponse) {
+ return socketResponse;
+ }
+ throw directError;
+ }
}
function toZyteHeaders(headers: Headers): Array<{ name: string; value: string }> {
@@ -162,7 +173,9 @@ export async function probeLotteMartRequest(
success: response.ok,
status: response.status,
statusText: response.statusText,
- error: response.ok ? null : new HttpError(response.status, response.statusText, bodyText).message,
+ error: response.ok
+ ? null
+ : new HttpError(response.status, response.statusText, bodyText).message,
bodyPreview: toBodyPreview(bodyText),
sessionCookie: extractSessionCookie(response) || null,
});
@@ -180,7 +193,13 @@ export async function probeLotteMartRequest(
if (zyteApiKey) {
try {
- const bodyText = await fetchLotteMartHtmlByZyte(url, init, timeout, sessionCookie, zyteApiKey);
+ const bodyText = await fetchLotteMartHtmlByZyte(
+ url,
+ init,
+ timeout,
+ sessionCookie,
+ zyteApiKey,
+ );
attempts.push({
used: 'zyte',
success: true,
@@ -237,7 +256,11 @@ export async function fetchLotteMartHtml(
throw new HttpError(response.status, response.statusText, bodyText);
}
- if (bodyText.trim().length === 0 && sessionCookie.trim().length === 0 && cachedCookie.length > 0) {
+ if (
+ bodyText.trim().length === 0 &&
+ sessionCookie.trim().length === 0 &&
+ cachedCookie.length > 0
+ ) {
const retried = await fetchLotteMartResponse(url, init, timeout, cachedCookie);
cacheSessionCookieFromResponse(retried);
const retriedBodyText = await retried.text();
@@ -251,7 +274,13 @@ export async function fetchLotteMartHtml(
} catch (error) {
if (zyteApiKey && error instanceof Error && !error.message.includes('Zyte')) {
const fallbackCookie = await getCachedLotteMartSessionCookie(timeout);
- return fetchLotteMartHtmlByZyte(url, init, timeout, fallbackCookie || sessionCookie, zyteApiKey);
+ return fetchLotteMartHtmlByZyte(
+ url,
+ init,
+ Math.min(timeout, FALLBACK_TIMEOUT_MS),
+ fallbackCookie || sessionCookie,
+ zyteApiKey,
+ );
}
throw error;
@@ -265,7 +294,13 @@ export async function fetchLotteMartPageWithSession(
sessionCookie: string,
zyteApiKey?: string,
): Promise {
- return fetchLotteMartHtml(new URL(path, LOTTEMART_API.BASE_URL).toString(), init, timeout, sessionCookie, zyteApiKey);
+ return fetchLotteMartHtml(
+ new URL(path, LOTTEMART_API.BASE_URL).toString(),
+ init,
+ timeout,
+ sessionCookie,
+ zyteApiKey,
+ );
}
export function __testOnlyClearLotteMartSessionCache(): void {
diff --git a/src/utils/format.ts b/src/utils/format.ts
index 55ab2306..3d9a02da 100644
--- a/src/utils/format.ts
+++ b/src/utils/format.ts
@@ -32,8 +32,15 @@ export function formatTime(raw: string | undefined): string {
}
export function toYyyymmdd(value: Date = new Date()): string {
- const year = value.getFullYear();
- const month = `${value.getMonth() + 1}`.padStart(2, '0');
- const day = `${value.getDate()}`.padStart(2, '0');
+ const parts = new Intl.DateTimeFormat('en-CA', {
+ timeZone: 'Asia/Seoul',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ }).formatToParts(value);
+ const byType = Object.fromEntries(parts.map((part) => [part.type, part.value]));
+ const year = byType.year;
+ const month = byType.month;
+ const day = byType.day;
return `${year}${month}${day}`;
}
diff --git a/tests/api/cgv-handlers.test.ts b/tests/api/cgv-handlers.test.ts
index 0486720b..73f4e372 100644
--- a/tests/api/cgv-handlers.test.ts
+++ b/tests/api/cgv-handlers.test.ts
@@ -35,6 +35,24 @@ function createMockContext(query: Record = {}, env: Record {
+ it('CGV 원본과 Zyte를 사용할 수 없으면 명시적인 503을 반환한다', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('forbidden', { status: 403 }));
+
+ const ctx = createMockContext({ playDate: '20260304' });
+ await handleCgvFindTheaters(ctx);
+
+ expect(ctx.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ success: false,
+ error: {
+ code: 'CGV_UPSTREAM_UNAVAILABLE',
+ message: 'CGV 원본 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.',
+ },
+ }),
+ 503,
+ );
+ });
+
it('CGV 극장 목록을 반환한다', async () => {
mockFetch.mockResolvedValue(
new Response(
@@ -137,7 +155,10 @@ describe('handleCgvFindTheaters', () => {
),
);
- const ctx = createMockContext({ playDate: '20260315', keyword: '안산 중앙역' }, { GOOGLE_MAPS_API_KEY: 'test-google-key' });
+ const ctx = createMockContext(
+ { playDate: '20260315', keyword: '안산 중앙역' },
+ { GOOGLE_MAPS_API_KEY: 'test-google-key' },
+ );
await handleCgvFindTheaters(ctx);
const payload = (ctx.json as ReturnType).mock.calls[0][0] as {
@@ -255,6 +276,21 @@ describe('handleCgvFindTheaters', () => {
});
describe('handleCgvSearchMovies', () => {
+ it('CGV 원본과 Zyte를 사용할 수 없으면 명시적인 503을 반환한다', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('forbidden', { status: 403 }));
+
+ const ctx = createMockContext({ playDate: '20260304', theaterCode: '0056' });
+ await handleCgvSearchMovies(ctx);
+
+ expect(ctx.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ success: false,
+ error: expect.objectContaining({ code: 'CGV_UPSTREAM_UNAVAILABLE' }),
+ }),
+ 503,
+ );
+ });
+
it('CGV 영화 목록을 반환한다', async () => {
mockFetch.mockResolvedValue(
new Response(
@@ -370,7 +406,10 @@ describe('handleCgvSearchMovies', () => {
),
);
- const ctx = createMockContext({ playDate: '20260315', keyword: '안산 중앙역' }, { GOOGLE_MAPS_API_KEY: 'test-google-key' });
+ const ctx = createMockContext(
+ { playDate: '20260315', keyword: '안산 중앙역' },
+ { GOOGLE_MAPS_API_KEY: 'test-google-key' },
+ );
await handleCgvSearchMovies(ctx);
const payload = (ctx.json as ReturnType).mock.calls[0][0] as {
@@ -446,6 +485,25 @@ describe('handleCgvSearchMovies', () => {
});
describe('handleCgvGetTimetable', () => {
+ it('CGV 원본과 Zyte를 사용할 수 없으면 명시적인 503을 반환한다', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('forbidden', { status: 403 }));
+
+ const ctx = createMockContext({
+ playDate: '20260304',
+ theaterCode: '0056',
+ movieCode: '30000985',
+ });
+ await handleCgvGetTimetable(ctx);
+
+ expect(ctx.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ success: false,
+ error: expect.objectContaining({ code: 'CGV_UPSTREAM_UNAVAILABLE' }),
+ }),
+ 503,
+ );
+ });
+
it('CGV 시간표를 반환한다', async () => {
mockFetch.mockResolvedValue(
new Response(
@@ -735,7 +793,10 @@ describe('handleCgvGetTimetable', () => {
),
);
- const ctx = createMockContext({ playDate: '20260315', keyword: '안산 중앙역' }, { GOOGLE_MAPS_API_KEY: 'test-google-key' });
+ const ctx = createMockContext(
+ { playDate: '20260315', keyword: '안산 중앙역' },
+ { GOOGLE_MAPS_API_KEY: 'test-google-key' },
+ );
await handleCgvGetTimetable(ctx);
const payload = (ctx.json as ReturnType).mock.calls[0][0] as {
diff --git a/tests/api/gs25-handlers.test.ts b/tests/api/gs25-handlers.test.ts
index b1154805..286369b3 100644
--- a/tests/api/gs25-handlers.test.ts
+++ b/tests/api/gs25-handlers.test.ts
@@ -61,14 +61,11 @@ describe('handleGs25FindStores', () => {
mockFetch
.mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] })))
.mockResolvedValueOnce(
- new Response(
- '',
- {
- headers: {
- 'Set-Cookie': 'JSESSIONID=session-id; Path=/; HttpOnly',
- },
+ new Response('', {
+ headers: {
+ 'Set-Cookie': 'JSESSIONID=session-id; Path=/; HttpOnly',
},
- ),
+ }),
)
.mockResolvedValueOnce(
new Response(
@@ -117,7 +114,11 @@ describe('handleGs25FindStores', () => {
),
)
.mockResolvedValueOnce(
- new Response(JSON.stringify({ stores: [{ storeCode: '1', storeName: '강남역점', storeAddress: '서울 강남구' }] })),
+ new Response(
+ JSON.stringify({
+ stores: [{ storeCode: '1', storeName: '강남역점', storeAddress: '서울 강남구' }],
+ }),
+ ),
);
await handleGs25FindStores(ctx);
@@ -277,6 +278,41 @@ describe('handleGs25SearchProducts', () => {
});
describe('handleGs25CheckInventory', () => {
+ it('GS25 재고 인증이 거부되면 명시적인 503을 반환한다', async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response('authentication required', { status: 401, statusText: 'Unauthorized' }),
+ );
+
+ const ctx = createMockContext({ itemCode: '123' });
+ await handleGs25CheckInventory(ctx);
+
+ expect(ctx.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ success: false,
+ error: {
+ code: 'GS25_UPSTREAM_UNAVAILABLE',
+ message: 'GS25 재고 서비스 인증을 사용할 수 없습니다. 잠시 후 다시 시도해주세요.',
+ },
+ }),
+ 503,
+ );
+ });
+
+ it('Worker의 GS25 API 키를 재고 요청에 전달한다', async () => {
+ mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ stores: [] })));
+
+ const ctx = createMockContext({ itemCode: '123' });
+ (ctx as { env: Record }).env = { GS25_API_KEY: 'test-gs25-key' };
+ await handleGs25CheckInventory(ctx);
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ headers: expect.objectContaining({ 'Api-Key': 'test-gs25-key' }),
+ }),
+ );
+ });
+
it('keyword가 없으면 에러를 반환한다', async () => {
const ctx = createMockContext({});
await handleGs25CheckInventory(ctx);
@@ -358,7 +394,9 @@ describe('handleGs25CheckInventory', () => {
new Response(
JSON.stringify({
SearchQueryResult: {
- Collection: [{ Documentset: { Document: [{ field: { itemCode: '123', itemName: '오감자' } }] } }],
+ Collection: [
+ { Documentset: { Document: [{ field: { itemCode: '123', itemName: '오감자' } }] } },
+ ],
},
}),
),
@@ -397,7 +435,9 @@ describe('handleGs25CheckInventory', () => {
const zyteBody = Buffer.from(
JSON.stringify({
SearchQueryResult: {
- Collection: [{ Documentset: { Document: [{ field: { itemCode: '123', itemName: '오감자' } }] } }],
+ Collection: [
+ { Documentset: { Document: [{ field: { itemCode: '123', itemName: '오감자' } }] } },
+ ],
},
}),
'utf8',
@@ -455,7 +495,9 @@ describe('handleGs25CheckInventory', () => {
new Response(
JSON.stringify({
SearchQueryResult: {
- Collection: [{ Documentset: { Document: [{ field: { itemCode: '123', itemName: '오감자' } }] } }],
+ Collection: [
+ { Documentset: { Document: [{ field: { itemCode: '123', itemName: '오감자' } }] } },
+ ],
},
}),
),
@@ -463,7 +505,14 @@ describe('handleGs25CheckInventory', () => {
.mockResolvedValueOnce(
new Response(
JSON.stringify({
- stores: [{ storeCode: '1', storeName: '강남역점', searchItemName: '오감자', realStockQuantity: 1 }],
+ stores: [
+ {
+ storeCode: '1',
+ storeName: '강남역점',
+ searchItemName: '오감자',
+ realStockQuantity: 1,
+ },
+ ],
}),
),
);
@@ -487,7 +536,11 @@ describe('handleGs25CheckInventory', () => {
mockFetch
// 1. storeKeyword 기준 매장 조회 (지오코딩 주소 획득용)
.mockResolvedValueOnce(
- new Response(JSON.stringify({ stores: [{ storeCode: 'B', storeName: '강남역점', storeAddress: '서울 강남구' }] })),
+ new Response(
+ JSON.stringify({
+ stores: [{ storeCode: 'B', storeName: '강남역점', storeAddress: '서울 강남구' }],
+ }),
+ ),
)
// 2. 지오코딩 실패
.mockResolvedValueOnce(new Response(JSON.stringify({ status: 'ZERO_RESULTS', results: [] })))
@@ -496,14 +549,22 @@ describe('handleGs25CheckInventory', () => {
new Response(
JSON.stringify({
SearchQueryResult: {
- Collection: [{ Documentset: { Document: [{ field: { itemCode: '123', itemName: '오감자' } }] } }],
+ Collection: [
+ { Documentset: { Document: [{ field: { itemCode: '123', itemName: '오감자' } }] } },
+ ],
},
}),
),
)
// 4. store/stock API (기본 좌표 사용)
.mockResolvedValueOnce(
- new Response(JSON.stringify({ stores: [{ storeCode: '1', storeName: '강남역점', searchItemName: '', realStockQuantity: 0 }] })),
+ new Response(
+ JSON.stringify({
+ stores: [
+ { storeCode: '1', storeName: '강남역점', searchItemName: '', realStockQuantity: 0 },
+ ],
+ }),
+ ),
);
await handleGs25CheckInventory(ctx);
diff --git a/tests/api/health-checks.test.ts b/tests/api/health-checks.test.ts
index 79eed195..6eb5f47a 100644
--- a/tests/api/health-checks.test.ts
+++ b/tests/api/health-checks.test.ts
@@ -98,22 +98,46 @@ describe('runHealthChecks', () => {
expect.stringContaining('/api/lottemart/products?'),
expect.any(Object),
);
- const oliveyoungCall = fetchImpl.mock.calls.find((call) => String(call[0]).includes('/api/oliveyoung/products?'));
+ const oliveyoungCall = fetchImpl.mock.calls.find((call) =>
+ String(call[0]).includes('/api/oliveyoung/products?'),
+ );
expect(String(oliveyoungCall?.[0])).toContain('timeoutMs=5000');
});
it('full 모드에서 quick과 deep 체크를 함께 실행한다', async () => {
+ const representative = {
+ id: 'A1',
+ name: '상품',
+ productName: '상품',
+ itemCode: 'A1',
+ itemName: '상품',
+ goodsNumber: 'A1',
+ goodsName: '상품',
+ pluCd: 'A1',
+ productNo: 'A1',
+ storeCode: 'S1',
+ storeName: '매장',
+ theaterCode: 'T1',
+ theaterName: '극장',
+ };
const fetchImpl = vi.fn((input: RequestInfo | URL) =>
Promise.resolve(
String(input).includes('/health')
? jsonResponse({ status: 'ok' })
- : String(input).includes('/inventory')
- ? jsonResponse({
- success: true,
- data: { inventory: { products: [{ name: '상품' }], items: [{ name: '상품' }] } },
- meta: { total: 1 },
- })
- : jsonResponse({ success: true, data: { products: [{ name: '상품' }] }, meta: { total: 1 } }),
+ : jsonResponse({
+ success: true,
+ data: {
+ products: [representative],
+ stores: [representative],
+ theaters: [representative],
+ inventory: {
+ products: [representative],
+ items: [representative],
+ stores: [representative],
+ },
+ },
+ meta: { total: 1 },
+ }),
),
);
@@ -142,7 +166,11 @@ describe('runHealthChecks', () => {
return String(input).includes('/health')
? jsonResponse({ status: 'ok' })
- : jsonResponse({ success: true, data: { products: [{ name: '상품' }] }, meta: { total: 1 } });
+ : jsonResponse({
+ success: true,
+ data: { products: [{ name: '상품' }] },
+ meta: { total: 1 },
+ });
});
const result = await runHealthChecks({
@@ -281,6 +309,64 @@ describe('runHealthChecks', () => {
expect(String(fetchImpl.mock.calls[0][0])).toContain('storeCheck=false');
});
+ it('GS25 inventory stores 응답에서 개수와 매장 이름을 읽는다', async () => {
+ const fetchImpl = vi.fn().mockResolvedValueOnce(
+ jsonResponse({
+ success: true,
+ data: {
+ inventory: {
+ stores: [{ storeCode: 'G1', storeName: '강남점' }],
+ },
+ },
+ }),
+ );
+
+ const result = await runHealthChecks({
+ baseUrl: 'https://example.com',
+ check: 'gs25.inventory',
+ mode: 'deep',
+ fetchImpl,
+ now: () => 1000,
+ fresh: true,
+ includeSamples: true,
+ });
+
+ expect(result.checks[0]).toEqual(
+ expect.objectContaining({
+ status: 'ok',
+ message: '1 item(s) returned',
+ sample: { first: '강남점' },
+ }),
+ );
+ });
+
+ it('이마트24 inventory의 top-level stores 응답을 읽는다', async () => {
+ const fetchImpl = vi.fn().mockResolvedValueOnce(
+ jsonResponse({
+ success: true,
+ data: {
+ stores: [{ storeCode: 'E1', storeName: '강남점' }],
+ },
+ }),
+ );
+
+ const result = await runHealthChecks({
+ baseUrl: 'https://example.com',
+ check: 'emart24.inventory',
+ mode: 'deep',
+ fetchImpl,
+ now: () => 1000,
+ fresh: true,
+ });
+
+ expect(result.checks[0]).toEqual(
+ expect.objectContaining({
+ status: 'ok',
+ message: '1 item(s) returned',
+ }),
+ );
+ });
+
it('CU 재고 조회 Request Blocked 400은 degraded로 처리한다', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(
jsonResponse(
@@ -315,7 +401,7 @@ describe('runHealthChecks', () => {
);
});
- it('inventory 컬렉션 값이 배열이 아니면 빈 컬렉션으로 처리한다', async () => {
+ it('필수 inventory 컬렉션 값이 배열이 아니면 degraded로 처리한다', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(
jsonResponse({
success: true,
@@ -338,14 +424,18 @@ describe('runHealthChecks', () => {
expect(result.checks[0]).toEqual(
expect.objectContaining({
- status: 'ok',
- message: 'response ok',
+ status: 'degraded',
+ message: 'response count unavailable',
}),
);
});
it('느린 성공 응답은 degraded로 표시한다', async () => {
- const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse({ success: true, data: { products: [{ name: '상품' }] } }));
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(
+ jsonResponse({ success: true, data: { products: [{ name: '상품' }] } }),
+ );
const timestamps = [0, 1000, 7000, 7000];
const result = await runHealthChecks({
@@ -368,7 +458,11 @@ describe('runHealthChecks', () => {
});
it('cacheBust가 켜지면 체크 URL에 캐시 우회 파라미터를 붙인다', async () => {
- const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse({ success: true, data: { products: [{ name: '상품' }] } }));
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(
+ jsonResponse({ success: true, data: { products: [{ name: '상품' }] } }),
+ );
await runHealthChecks({
baseUrl: 'https://example.com',
@@ -466,7 +560,9 @@ describe('runHealthChecks', () => {
if (this !== globalThis) {
throw new Error('invalid fetch this');
}
- return Promise.resolve(jsonResponse({ success: true, data: { products: [{ name: '상품' }] } }));
+ return Promise.resolve(
+ jsonResponse({ success: true, data: { products: [{ name: '상품' }] } }),
+ );
});
vi.stubGlobal('fetch', globalFetch);
@@ -482,7 +578,9 @@ describe('runHealthChecks', () => {
});
it('빈 결과는 degraded 상태로 집계한다', async () => {
- const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse({ success: true, data: { products: [] } }));
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(jsonResponse({ success: true, data: { products: [] } }));
const result = await runHealthChecks({
baseUrl: 'https://example.com',
@@ -501,8 +599,10 @@ describe('runHealthChecks', () => {
);
});
- it('카운트를 알 수 없는 성공 응답은 ok로 처리한다', async () => {
- const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse({ success: true, data: { pong: true } }));
+ it('카운트를 알 수 없는 성공 응답은 degraded로 처리한다', async () => {
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(jsonResponse({ success: true, data: { pong: true } }));
const result = await runHealthChecks({
baseUrl: 'https://example.com',
@@ -512,12 +612,38 @@ describe('runHealthChecks', () => {
fresh: true,
});
- expect(result.status).toBe('ok');
- expect(result.checks[0].message).toBe('response ok');
+ expect(result.status).toBe('degraded');
+ expect(result.checks[0].message).toBe('response count unavailable');
+ });
+
+ it('선택적 인기 검색어가 비면 skipped로 처리한다', async () => {
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(
+ jsonResponse({ success: true, data: { available: false, count: 0, keywords: [] } }),
+ );
+
+ const result = await runHealthChecks({
+ baseUrl: 'https://example.com',
+ check: 'seveneleven.popwords',
+ fetchImpl,
+ now: () => 1000,
+ fresh: true,
+ });
+
+ expect(result.status).toBe('skipped');
+ expect(result.checks[0]).toEqual(
+ expect.objectContaining({
+ status: 'skipped',
+ message: 'optional data unavailable',
+ }),
+ );
});
it('data.count 값을 결과 개수로 사용한다', async () => {
- const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse({ success: true, data: { count: 2 } }));
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(jsonResponse({ success: true, data: { count: 2 } }));
const result = await runHealthChecks({
baseUrl: 'https://example.com',
@@ -527,7 +653,12 @@ describe('runHealthChecks', () => {
fresh: true,
});
- expect(result.checks[0].message).toBe('2 item(s) returned');
+ expect(result.checks[0]).toEqual(
+ expect.objectContaining({
+ status: 'degraded',
+ message: expect.stringContaining('response missing required fields'),
+ }),
+ );
});
it('대표 필드가 숫자로 내려와도 shape를 통과한다', async () => {
@@ -572,7 +703,9 @@ describe('runHealthChecks', () => {
it('대표 컬렉션 필드가 바뀌면 degraded 메시지를 반환한다', async () => {
const fetchImpl = vi
.fn()
- .mockResolvedValueOnce(jsonResponse({ success: true, data: { products: [{ unexpected: 'value' }] } }));
+ .mockResolvedValueOnce(
+ jsonResponse({ success: true, data: { products: [{ unexpected: 'value' }] } }),
+ );
const result = await runHealthChecks({
baseUrl: 'https://example.com',
@@ -857,7 +990,9 @@ describe('runHealthChecks', () => {
});
it('timeoutMs를 기본값과 최대값으로 보정한다', async () => {
- const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ success: true, data: { products: [{ name: '상품' }] } }));
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValue(jsonResponse({ success: true, data: { products: [{ name: '상품' }] } }));
await runHealthChecks({
baseUrl: 'https://example.com',
@@ -881,7 +1016,11 @@ describe('runHealthChecks', () => {
});
it('timeoutMs가 1보다 작으면 기본값으로 보정한다', async () => {
- const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse({ success: true, data: { products: [{ name: '상품' }] } }));
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(
+ jsonResponse({ success: true, data: { products: [{ name: '상품' }] } }),
+ );
await runHealthChecks({
baseUrl: 'https://example.com',
diff --git a/tests/api/lottemart-handlers.test.ts b/tests/api/lottemart-handlers.test.ts
index 1e2ec8ea..b653b769 100644
--- a/tests/api/lottemart-handlers.test.ts
+++ b/tests/api/lottemart-handlers.test.ts
@@ -13,7 +13,8 @@ import {
} from '../../src/api/lottemartHandlers.js';
const mockFetch = vi.fn();
-const createSessionResponse = () => new Response('', { headers: { 'set-cookie': 'ASPSESSIONID=TEST; path=/' } });
+const createSessionResponse = () =>
+ new Response('', { headers: { 'set-cookie': 'ASPSESSIONID=TEST; path=/' } });
beforeEach(() => {
mockFetch.mockReset();
@@ -130,7 +131,10 @@ describe('handleLotteMartFindStores', () => {
expect(ctx.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
- error: { code: 'LOTTEMART_STORE_SEARCH_FAILED', message: '지원하지 않는 지역입니다: 잘못된지역' },
+ error: {
+ code: 'LOTTEMART_STORE_SEARCH_FAILED',
+ message: '지원하지 않는 지역입니다: 잘못된지역',
+ },
}),
500,
);
@@ -176,7 +180,10 @@ describe('handleLotteMartSearchProducts', () => {
expect(ctx.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
- error: { code: 'MISSING_STORE', message: 'storeCode 또는 storeName 중 하나를 입력해주세요.' },
+ error: {
+ code: 'MISSING_STORE',
+ message: 'storeCode 또는 storeName 중 하나를 입력해주세요.',
+ },
}),
400,
);
@@ -260,7 +267,12 @@ describe('handleLotteMartSearchProducts', () => {
});
it('지원하지 않는 source는 400을 반환한다', async () => {
- const ctx = createMockContext({ area: '서울', storeCode: '2301', keyword: '콜라', source: 'bad' });
+ const ctx = createMockContext({
+ area: '서울',
+ storeCode: '2301',
+ keyword: '콜라',
+ source: 'bad',
+ });
await handleLotteMartSearchProducts(ctx);
expect(ctx.json).toHaveBeenCalledWith(
@@ -285,13 +297,18 @@ describe('handleLotteMartSearchProducts', () => {
products: [],
});
- const ctx = createMockContext({ area: '서울', storeCode: '2301', keyword: '콜라', timeoutMs: '0' });
+ const ctx = createMockContext({
+ area: '서울',
+ storeCode: '2301',
+ keyword: '콜라',
+ timeoutMs: '0',
+ });
await handleLotteMartSearchProducts(ctx);
expect(searchSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
- timeout: 45000,
+ timeout: 15000,
}),
);
});
@@ -375,7 +392,7 @@ describe('handleLotteMartDebug', () => {
method: 'POST',
url: 'https://company.lottemart.com/mobiledowa/market/search_shop.asp',
bodyText: 'm_area=4401',
- timeout: 45000,
+ timeout: 15000,
hasZyteApiKey: false,
},
attempts: [
@@ -431,7 +448,9 @@ describe('handleLotteMartDebug', () => {
});
it('예외 발생 시 debug 에러를 반환한다', async () => {
- vi.spyOn(lotteMartDebug, 'probeLotteMartUpstream').mockRejectedValueOnce(new Error('debug fail'));
+ vi.spyOn(lotteMartDebug, 'probeLotteMartUpstream').mockRejectedValueOnce(
+ new Error('debug fail'),
+ );
const ctx = createMockContext({ target: 'stores' });
await handleLotteMartDebug(ctx);
diff --git a/tests/app/app-health-checks.test.ts b/tests/app/app-health-checks.test.ts
index 45a103ce..1d6d2f7f 100644
--- a/tests/app/app-health-checks.test.ts
+++ b/tests/app/app-health-checks.test.ts
@@ -114,7 +114,9 @@ describe('GET /api/health/checks', () => {
);
expect(res.status).toBe(200);
- expect(String(mockFetch.mock.calls[0][0])).toMatch(/^https:\/\/daiso-mcp\.example\.workers\.dev\/api\/lottemart\/products/);
+ expect(String(mockFetch.mock.calls[0][0])).toMatch(
+ /^https:\/\/daiso-mcp\.example\.workers\.dev\/api\/lottemart\/products/,
+ );
});
it('기본 internal transport는 같은 앱으로 내부 체크를 dispatch한다', async () => {
@@ -164,7 +166,9 @@ describe('GET /api/health/checks', () => {
meta: { total: 1 },
}),
)
- .mockResolvedValueOnce(jsonResponse({ success: false, error: { message: 'upstream fail' } }, 500));
+ .mockResolvedValueOnce(
+ jsonResponse({ success: false, error: { message: 'upstream fail' } }, 500),
+ );
const res = await app.request(
'/api/health/checks?service=gs25&fresh=true&transport=network',
@@ -179,7 +183,10 @@ describe('GET /api/health/checks', () => {
expect(res.status).toBe(200);
const data = await res.json();
expect(data.status).toBe('fail');
- expect(data.checks.map((check: { id: string }) => check.id)).toEqual(['gs25.products', 'gs25.stores']);
+ expect(data.checks.map((check: { id: string }) => check.id)).toEqual([
+ 'gs25.products',
+ 'gs25.stores',
+ ]);
expect(data.checks.map((check: { status: string }) => check.status)).toEqual(['ok', 'fail']);
});
@@ -221,7 +228,9 @@ describe('GET /api/health/checks', () => {
jsonResponse(
{
success: false,
- error: { message: 'API 요청 실패: 403 Forbidden - 403 Forbidden' },
+ error: {
+ message: 'API 요청 실패: 403 Forbidden - 403 Forbidden',
+ },
},
502,
),
@@ -326,7 +335,9 @@ describe('GET /api/health/checks', () => {
jsonResponse(
{
success: false,
- error: { message: 'API 요청 실패: 403 Forbidden - 403 Forbidden' },
+ error: {
+ message: 'API 요청 실패: 403 Forbidden - 403 Forbidden',
+ },
},
502,
),
@@ -361,13 +372,20 @@ describe('GET /api/health/checks', () => {
? jsonResponse(
{
success: false,
- error: { message: 'API 요청 실패: 403 Forbidden - 403 Forbidden' },
+ error: {
+ message:
+ 'API 요청 실패: 403 Forbidden - 403 Forbidden',
+ },
},
502,
)
: String(input).includes('/health')
? jsonResponse({ status: 'ok' })
- : jsonResponse({ success: true, data: { products: [{ name: '상품' }] }, meta: { total: 1 } }),
+ : jsonResponse({
+ success: true,
+ data: { products: [{ name: '상품' }] },
+ meta: { total: 1 },
+ }),
),
);
@@ -409,7 +427,11 @@ describe('GET /api/health/checks', () => {
)
: String(input).includes('/health')
? jsonResponse({ status: 'ok' })
- : jsonResponse({ success: true, data: { products: [{ name: '상품' }] }, meta: { total: 1 } }),
+ : jsonResponse({
+ success: true,
+ data: { products: [{ name: '상품' }] },
+ meta: { total: 1 },
+ }),
),
);
@@ -476,8 +498,16 @@ describe('GET /api/health/checks', () => {
};
const env = { HEALTH_CHECK_SECRET: 'test-secret', HEALTH_CHECK_TRANSPORT: 'network' };
- const first = await app.request('/api/health/checks?check=daiso.products&fresh=true', requestInit, env);
- const second = await app.request('/api/health/checks?check=daiso.products&fresh=true', requestInit, env);
+ const first = await app.request(
+ '/api/health/checks?check=daiso.products&fresh=true',
+ requestInit,
+ env,
+ );
+ const second = await app.request(
+ '/api/health/checks?check=daiso.products&fresh=true',
+ requestInit,
+ env,
+ );
expect(first.status).toBe(200);
expect(second.status).toBe(200);
@@ -502,8 +532,16 @@ describe('GET /api/health/checks', () => {
};
const env = { HEALTH_CHECK_SECRET: 'test-secret', HEALTH_CHECK_TRANSPORT: 'network' };
- const first = await app.request('/api/health/checks?check=daiso.products&fresh=true', requestInit, env);
- const second = await app.request('/api/health/checks?check=daiso.products&fresh=true', requestInit, env);
+ const first = await app.request(
+ '/api/health/checks?check=daiso.products&fresh=true',
+ requestInit,
+ env,
+ );
+ const second = await app.request(
+ '/api/health/checks?check=daiso.products&fresh=true',
+ requestInit,
+ env,
+ );
expect(first.status).toBe(200);
expect(second.status).toBe(200);
@@ -531,11 +569,37 @@ describe('GET /api/health/checks', () => {
});
it('deep 모드와 y 플래그를 파싱하고 CLI 계약 체크를 실행한다', async () => {
+ const representative = {
+ id: '1',
+ code: '1',
+ name: '상품',
+ productCode: 'P1',
+ productName: '상품',
+ branchCode: 'B1',
+ branchName: '강남점',
+ storeCode: 'S1',
+ storeName: '강남점',
+ theaterCode: 'T1',
+ theaterName: '강남점',
+ };
mockFetch.mockImplementation((input: RequestInfo | URL) =>
Promise.resolve(
String(input).includes('/health')
? jsonResponse({ status: 'ok' })
- : jsonResponse({ success: true, data: { products: [{ name: '상품' }] }, meta: { total: 1 } }),
+ : jsonResponse({
+ success: true,
+ data: {
+ products: [representative],
+ stores: [representative],
+ theaters: [representative],
+ inventory: {
+ products: [representative],
+ items: [representative],
+ stores: [representative],
+ },
+ },
+ meta: { total: 1 },
+ }),
),
);
@@ -572,7 +636,11 @@ describe('GET /api/health/checks', () => {
Promise.resolve(
String(input).includes('/health')
? jsonResponse({ status: 'ok' })
- : jsonResponse({ success: true, data: { products: [{ name: '상품' }] }, meta: { total: 1 } }),
+ : jsonResponse({
+ success: true,
+ data: { products: [{ name: '상품' }] },
+ meta: { total: 1 },
+ }),
),
);
@@ -591,11 +659,15 @@ describe('GET /api/health/checks', () => {
const data = await res.json();
expect(data.filters.mode).toBe('full');
expect(mockFetch).toHaveBeenCalled();
- expect(String(mockFetch.mock.calls[0][0])).toMatch(/^https:\/\/daiso-mcp\.example\.workers\.dev\//);
- expect(String(mockFetch.mock.calls[0][0])).toContain('_healthCheck=');
- expect(new Headers((mockFetch.mock.calls[0][1] as RequestInit | undefined)?.headers).get('x-health-check-key')).toBe(
- 'test-secret',
+ expect(String(mockFetch.mock.calls[0][0])).toMatch(
+ /^https:\/\/daiso-mcp\.example\.workers\.dev\//,
);
+ expect(String(mockFetch.mock.calls[0][0])).toContain('_healthCheck=');
+ expect(
+ new Headers((mockFetch.mock.calls[0][1] as RequestInit | undefined)?.headers).get(
+ 'x-health-check-key',
+ ),
+ ).toBe('test-secret');
});
it('baseUrl 쿼리가 있으면 헬스 체크 기준 URL로 우선 사용한다', async () => {
@@ -621,9 +693,13 @@ describe('GET /api/health/checks', () => {
);
expect(res.status).toBe(200);
- expect(String(mockFetch.mock.calls[0][0])).toMatch(/^https:\/\/probe\.example\.com\/api\/daiso\/products/);
+ expect(String(mockFetch.mock.calls[0][0])).toMatch(
+ /^https:\/\/probe\.example\.com\/api\/daiso\/products/,
+ );
expect(
- new Headers((mockFetch.mock.calls[0][1] as RequestInit | undefined)?.headers).get('x-health-check-key'),
+ new Headers((mockFetch.mock.calls[0][1] as RequestInit | undefined)?.headers).get(
+ 'x-health-check-key',
+ ),
).toBeNull();
});
diff --git a/tests/services/cgv/client.test.ts b/tests/services/cgv/client.test.ts
index 3d82dba9..50c80b87 100644
--- a/tests/services/cgv/client.test.ts
+++ b/tests/services/cgv/client.test.ts
@@ -9,6 +9,7 @@ import {
fetchCgvTimetable,
toYyyymmdd,
} from '../../../src/services/cgv/client.js';
+import { CgvUpstreamUnavailableError } from '../../../src/services/cgv/errors.js';
const mockFetch = vi.fn();
@@ -171,9 +172,7 @@ describe('fetchCgvMovies', () => {
JSON.stringify({
statusCode: 0,
statusMessage: '조회 되었습니다.',
- data: [
- { regnGrpCd: '01', siteList: [{ siteNo: '0056', siteNm: '강남' }] },
- ],
+ data: [{ regnGrpCd: '01', siteList: [{ siteNo: '0056', siteNm: '강남' }] }],
}),
),
)
@@ -606,8 +605,8 @@ describe('fetchCgvTimetable', () => {
),
);
- await expect(fetchCgvTheaters({ zyteApiKey: 'test-key' })).rejects.toThrow(
- 'Zyte API 호출 실패: 400 zyte fail',
+ await expect(fetchCgvTheaters({ zyteApiKey: 'test-key' })).rejects.toBeInstanceOf(
+ CgvUpstreamUnavailableError,
);
});
@@ -623,8 +622,8 @@ describe('fetchCgvTimetable', () => {
),
);
- await expect(fetchCgvTheaters({ zyteApiKey: 'test-key' })).rejects.toThrow(
- 'Zyte HTTP 응답 본문이 비어 있습니다.',
+ await expect(fetchCgvTheaters({ zyteApiKey: 'test-key' })).rejects.toBeInstanceOf(
+ CgvUpstreamUnavailableError,
);
});
@@ -634,8 +633,8 @@ describe('fetchCgvTimetable', () => {
mockFetch.mockResolvedValue(new Response('forbidden', { status: 403 }));
- await expect(fetchCgvTheaters({ zyteApiKey: ' ' })).rejects.toThrow(
- 'ZYTE_API_KEY가 설정되지 않았습니다.',
+ await expect(fetchCgvTheaters({ zyteApiKey: ' ' })).rejects.toBeInstanceOf(
+ CgvUpstreamUnavailableError,
);
process.env.ZYTE_API_KEY = original;
@@ -938,7 +937,6 @@ describe('fetchCgvTimetable', () => {
const result = await fetchCgvTimetable({ playDate: '20260304' });
expect(result).toEqual([]);
});
-
});
describe('toYyyymmdd', () => {
diff --git a/tests/services/cgv/transport.test.ts b/tests/services/cgv/transport.test.ts
index fab0067e..f6e2e389 100644
--- a/tests/services/cgv/transport.test.ts
+++ b/tests/services/cgv/transport.test.ts
@@ -3,6 +3,7 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { CgvUpstreamUnavailableError } from '../../../src/services/cgv/errors.js';
import { requestCgv } from '../../../src/services/cgv/transport.js';
const mockFetch = vi.fn();
@@ -19,7 +20,9 @@ afterEach(() => {
describe('requestCgv', () => {
it('정상 응답을 JSON으로 파싱한다', async () => {
- mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ statusCode: 0, data: [] }), { status: 200 }));
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({ statusCode: 0, data: [] }), { status: 200 }),
+ );
const result = await requestCgv<{ statusCode: number; data: unknown[] }>(
'/cnm/atkt/searchRegnList',
@@ -48,9 +51,10 @@ describe('requestCgv', () => {
});
it('403 + zyteApiKey면 Zyte fallback을 사용한다', async () => {
- const body = Buffer.from(JSON.stringify({ statusCode: 0, data: [{ siteNo: '0056' }] }), 'utf8').toString(
- 'base64',
- );
+ const body = Buffer.from(
+ JSON.stringify({ statusCode: 0, data: [{ siteNo: '0056' }] }),
+ 'utf8',
+ ).toString('base64');
mockFetch
.mockResolvedValueOnce(new Response('forbidden', { status: 403 }))
@@ -75,6 +79,74 @@ describe('requestCgv', () => {
expect(String(mockFetch.mock.calls[1][0])).toContain('https://api.zyte.com/v1/extract');
});
+ it('직접 요청이 403이고 Zyte 키가 없으면 명시적인 upstream unavailable 오류를 던진다', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('forbidden', { status: 403 }));
+
+ await expect(
+ requestCgv('/cnm/atkt/searchRegnList', new URLSearchParams({ coCd: 'A420' }), 1000),
+ ).rejects.toBeInstanceOf(CgvUpstreamUnavailableError);
+ });
+
+ it('직접 요청이 401이어도 명시적인 upstream unavailable 오류를 던진다', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('unauthorized', { status: 401 }));
+
+ await expect(
+ requestCgv('/cnm/atkt/searchRegnList', new URLSearchParams({ coCd: 'A420' }), 1000),
+ ).rejects.toBeInstanceOf(CgvUpstreamUnavailableError);
+ });
+
+ it.each([401, 403])(
+ 'Zyte 대상 응답이 %i이면 명시적인 upstream unavailable 오류를 던진다',
+ async (statusCode) => {
+ mockFetch
+ .mockResolvedValueOnce(new Response('forbidden', { status: 403 }))
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ statusCode,
+ httpResponseBody: Buffer.from('forbidden').toString('base64'),
+ }),
+ { status: 200 },
+ ),
+ );
+
+ await expect(
+ requestCgv(
+ '/cnm/atkt/searchRegnList',
+ new URLSearchParams({ coCd: 'A420' }),
+ 1000,
+ 'test-key',
+ ),
+ ).rejects.toBeInstanceOf(CgvUpstreamUnavailableError);
+ },
+ );
+
+ it('Zyte 계정이 중지된 경우 원문 대신 명시적인 upstream unavailable 오류를 던진다', async () => {
+ mockFetch
+ .mockResolvedValueOnce(new Response('forbidden', { status: 403 }))
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ title: 'Forbidden',
+ detail: 'account suspended',
+ }),
+ { status: 403 },
+ ),
+ );
+
+ await expect(
+ requestCgv(
+ '/cnm/atkt/searchRegnList',
+ new URLSearchParams({ coCd: 'A420' }),
+ 1000,
+ 'test-key',
+ ),
+ ).rejects.toMatchObject({
+ name: 'CgvUpstreamUnavailableError',
+ message: expect.not.stringContaining('account suspended'),
+ });
+ });
+
it('AbortError는 시간 초과 에러로 변환한다', async () => {
mockFetch.mockRejectedValueOnce(new DOMException('aborted', 'AbortError'));
diff --git a/tests/services/gs25/client.test.ts b/tests/services/gs25/client.test.ts
index cbb31fdc..944ba477 100644
--- a/tests/services/gs25/client.test.ts
+++ b/tests/services/gs25/client.test.ts
@@ -15,6 +15,7 @@ import {
geocodeGs25Address,
sortGs25Stores,
} from '../../../src/services/gs25/client.js';
+import { Gs25UpstreamUnavailableError } from '../../../src/services/gs25/errors.js';
import { normalizeStore, toNumber } from '../../../src/services/gs25/storeUtils.js';
const mockFetch = vi.fn();
@@ -74,16 +75,73 @@ describe('fetchGs25Stores', () => {
);
});
+ it('Zyte 대상이 인증을 거부하면 명시적인 upstream unavailable 오류를 던진다', async () => {
+ mockFetch
+ .mockResolvedValueOnce(new Response('forbidden', { status: 403, statusText: 'Forbidden' }))
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ statusCode: 403,
+ httpResponseBody: Buffer.from('forbidden').toString('base64'),
+ }),
+ ),
+ );
+
+ await expect(
+ fetchGs25Stores({ useCache: false }, { zyteApiKey: 'test-zyte-key' }),
+ ).rejects.toBeInstanceOf(Gs25UpstreamUnavailableError);
+ });
+
+ it('Zyte 호출 자체가 실패해도 명시적인 upstream unavailable 오류를 던진다', async () => {
+ mockFetch
+ .mockResolvedValueOnce(new Response('forbidden', { status: 403, statusText: 'Forbidden' }))
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ detail: 'account suspended' }), { status: 403 }),
+ );
+
+ await expect(
+ fetchGs25Stores({ useCache: false }, { zyteApiKey: 'test-zyte-key' }),
+ ).rejects.toBeInstanceOf(Gs25UpstreamUnavailableError);
+ });
+
it('store/stock 403이어도 Zyte 키가 없으면 원본 에러를 반환한다', async () => {
mockFetch.mockResolvedValueOnce(
new Response('forbidden', { status: 403, statusText: 'Forbidden' }),
);
- await expect(fetchGs25Stores({ useCache: false })).rejects.toThrow(
- 'API 요청 실패: 403 Forbidden - forbidden',
+ await expect(fetchGs25Stores({ useCache: false })).rejects.toBeInstanceOf(
+ Gs25UpstreamUnavailableError,
);
});
+ it('GS25 API 키가 있으면 store/stock 요청에만 전달한다', async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({ stores: [{ storeCode: 'VE463', storeName: '강남역점' }] })),
+ );
+
+ await fetchGs25Stores({ useCache: false }, { apiKey: 'test-gs25-key' });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ 'Api-Key': 'test-gs25-key',
+ }),
+ }),
+ );
+ });
+
+ it('store/stock 인증 거부를 명시적인 upstream unavailable 오류로 변환한다', async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response('authentication required', { status: 401, statusText: 'Unauthorized' }),
+ );
+
+ await expect(fetchGs25Stores({ useCache: false })).rejects.toMatchObject({
+ name: 'Gs25UpstreamUnavailableError',
+ message: expect.not.stringContaining('authentication required'),
+ });
+ });
+
it('GS25 매장 목록을 정규화해 반환한다', async () => {
mockFetch.mockResolvedValue(
new Response(
@@ -194,7 +252,12 @@ describe('fetchGs25Stores', () => {
it('기본 store/stock 요청에는 차단되는 페이지 파라미터를 넣지 않는다', async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify({ stores: [] })));
- await fetchGs25Stores({ itemCode: '8801056038861', latitude: 37.5, longitude: 127, useCache: false });
+ await fetchGs25Stores({
+ itemCode: '8801056038861',
+ latitude: 37.5,
+ longitude: 127,
+ useCache: false,
+ });
const calledUrl = new URL(String(mockFetch.mock.calls[0][0]));
expect(calledUrl.searchParams.has('pageNumber')).toBe(false);
@@ -217,14 +280,11 @@ describe('fetchGs25WebStores', () => {
it('GS25 웹 매장 검색 응답을 정규화한다', async () => {
mockFetch
.mockResolvedValueOnce(
- new Response(
- '',
- {
- headers: {
- 'Set-Cookie': 'JSESSIONID=session-id; Path=/; HttpOnly',
- },
+ new Response('', {
+ headers: {
+ 'Set-Cookie': 'JSESSIONID=session-id; Path=/; HttpOnly',
},
- ),
+ }),
)
.mockResolvedValueOnce(
new Response(
diff --git a/tests/services/lottemart/debug.test.ts b/tests/services/lottemart/debug.test.ts
index 088e3cea..bd58c991 100644
--- a/tests/services/lottemart/debug.test.ts
+++ b/tests/services/lottemart/debug.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from 'vitest';
import * as session from '../../../src/services/lottemart/session.js';
-import { buildLotteMartDebugRequest, probeLotteMartUpstream } from '../../../src/services/lottemart/debug.js';
+import {
+ buildLotteMartDebugRequest,
+ probeLotteMartUpstream,
+} from '../../../src/services/lottemart/debug.js';
describe('buildLotteMartDebugRequest', () => {
it('market-options 요청을 만든다', () => {
@@ -26,7 +29,9 @@ describe('buildLotteMartDebugRequest', () => {
});
expect(request.method).toBe('POST');
- expect(request.bodyText).toBe('m_area=%EC%84%9C%EC%9A%B8&m_market=2301&m_schWord=%EA%B0%95%EB%B3%80');
+ expect(request.bodyText).toBe(
+ 'm_area=%EC%84%9C%EC%9A%B8&m_market=2301&m_schWord=%EA%B0%95%EB%B3%80',
+ );
});
it('products 요청을 만든다', () => {
@@ -38,7 +43,9 @@ describe('buildLotteMartDebugRequest', () => {
});
expect(request.method).toBe('POST');
- expect(request.bodyText).toBe('p_area=%EA%B2%BD%EA%B8%B0&p_market=2415&p_schWord=%ED%95%AB%EC%8B%9D%EC%8A%A4');
+ expect(request.bodyText).toBe(
+ 'p_area=%EA%B2%BD%EA%B8%B0&p_market=2415&p_schWord=%ED%95%AB%EC%8B%9D%EC%8A%A4',
+ );
});
it('product-page 요청을 만든다', () => {
@@ -73,7 +80,9 @@ describe('buildLotteMartDebugRequest', () => {
expect(marketOptions.url).toContain('p_area=%EA%B2%BD%EA%B8%B0');
expect(marketOptions.url).toContain('p_type=1');
expect(stores.bodyText).toBe('m_area=%EA%B2%BD%EA%B8%B0');
- expect(products.bodyText).toBe('p_area=%EA%B2%BD%EA%B8%B0&p_market=2415&p_schWord=%ED%95%AB%EC%8B%9D%EC%8A%A4');
+ expect(products.bodyText).toBe(
+ 'p_area=%EA%B2%BD%EA%B8%B0&p_market=2415&p_schWord=%ED%95%AB%EC%8B%9D%EC%8A%A4',
+ );
expect(productPage.url).toContain('p_market=2415');
expect(productPage.url).toContain('p_schWord=%ED%95%AB%EC%8B%9D%EC%8A%A4');
expect(productPage.url).toContain('page=2');
@@ -140,7 +149,7 @@ describe('probeLotteMartUpstream', () => {
'X-Requested-With': 'XMLHttpRequest',
}),
}),
- 45000,
+ 15000,
'',
undefined,
);
diff --git a/tests/services/lottemart/session.test.ts b/tests/services/lottemart/session.test.ts
index 512c124d..157b4dd8 100644
--- a/tests/services/lottemart/session.test.ts
+++ b/tests/services/lottemart/session.test.ts
@@ -62,7 +62,9 @@ describe('lottemart session helpers', () => {
it('빈 바디와 세션 쿠키를 받으면 같은 요청을 한 번 더 시도한다', async () => {
mockFetch
- .mockResolvedValueOnce(new Response('', { headers: { 'set-cookie': 'ASPSESSIONID=A; path=/' } }))
+ .mockResolvedValueOnce(
+ new Response('', { headers: { 'set-cookie': 'ASPSESSIONID=A; path=/' } }),
+ )
.mockResolvedValueOnce(new Response('ok'));
await expect(
@@ -83,7 +85,9 @@ describe('lottemart session helpers', () => {
});
it('정상 HTML 응답의 세션 쿠키를 캐시에 반영한다', async () => {
- mockFetch.mockResolvedValue(new Response('ok', { headers: { 'set-cookie': 'ASPSESSIONID=B; path=/' } }));
+ mockFetch.mockResolvedValue(
+ new Response('ok', { headers: { 'set-cookie': 'ASPSESSIONID=B; path=/' } }),
+ );
await expect(
fetchLotteMartHtml(
@@ -100,15 +104,29 @@ describe('lottemart session helpers', () => {
});
it('강제 새로고침이면 캐시를 비운다', async () => {
- mockFetch.mockResolvedValue(new Response('ok', { headers: { 'set-cookie': 'ASPSESSIONID=C; path=/' } }));
- await fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, '');
+ mockFetch.mockResolvedValue(
+ new Response('ok', { headers: { 'set-cookie': 'ASPSESSIONID=C; path=/' } }),
+ );
+ await fetchLotteMartHtml(
+ 'https://company.lottemart.com/mobiledowa/test',
+ { method: 'GET' },
+ 1000,
+ '',
+ );
await expect(getCachedLotteMartSessionCookie(1000, true)).resolves.toBe('');
});
it('getFreshLotteMartSessionCookie는 캐시를 비운 뒤 빈 문자열을 반환한다', async () => {
- mockFetch.mockResolvedValue(new Response('ok', { headers: { 'set-cookie': 'ASPSESSIONID=C; path=/' } }));
- await fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, '');
+ mockFetch.mockResolvedValue(
+ new Response('ok', { headers: { 'set-cookie': 'ASPSESSIONID=C; path=/' } }),
+ );
+ await fetchLotteMartHtml(
+ 'https://company.lottemart.com/mobiledowa/test',
+ { method: 'GET' },
+ 1000,
+ '',
+ );
await expect(getFreshLotteMartSessionCookie(1000)).resolves.toBe('');
});
@@ -119,15 +137,24 @@ describe('lottemart session helpers', () => {
headers.getSetCookie = () => ['ASPSESSIONID=D; path=/', 'other=value; path=/'];
mockFetch.mockResolvedValue(response);
- await fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, '');
+ await fetchLotteMartHtml(
+ 'https://company.lottemart.com/mobiledowa/test',
+ { method: 'GET' },
+ 1000,
+ '',
+ );
await expect(getCachedLotteMartSessionCookie(1000)).resolves.toBe('ASPSESSIONID=D');
});
it('소켓 raw 응답에 HTTP 헤더 경계가 없으면 fallback 가능하도록 null을 반환한다', async () => {
expect(__testOnlyCreateLotteMartSocketResponse(new TextEncoder().encode(''))).toBeNull();
- expect(__testOnlyCreateLotteMartSocketResponse(new TextEncoder().encode('not-http'))).toBeNull();
- expect(__testOnlyCreateLotteMartSocketResponse(new TextEncoder().encode('not-http\r\n\r\nbody'))).toBeNull();
+ expect(
+ __testOnlyCreateLotteMartSocketResponse(new TextEncoder().encode('not-http')),
+ ).toBeNull();
+ expect(
+ __testOnlyCreateLotteMartSocketResponse(new TextEncoder().encode('not-http\r\n\r\nbody')),
+ ).toBeNull();
});
it('소켓 raw HTTP 응답을 Response로 변환한다', async () => {
@@ -174,7 +201,9 @@ describe('lottemart session helpers', () => {
});
it('소켓 응답을 정상 HTTP Response로 변환한다', async () => {
- const raw = new TextEncoder().encode('HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nsocket ok');
+ const raw = new TextEncoder().encode(
+ 'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nsocket ok',
+ );
const connect = vi.fn(() => ({
readable: new ReadableStream({
start(controller) {
@@ -198,14 +227,57 @@ describe('lottemart session helpers', () => {
await expect(response?.text()).resolves.toBe('socket ok');
});
- it('소켓 전송이 응답하면 일반 fetch 없이 해당 응답을 사용한다', async () => {
+ it('일반 fetch가 응답하면 소켓 전송을 사용하지 않는다', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('fetch direct'));
socketMocks.fetchResponse.mockResolvedValueOnce(new Response('socket direct'));
await expect(
- fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''),
- ).resolves.toBe('socket direct');
+ fetchLotteMartHtml(
+ 'https://company.lottemart.com/mobiledowa/test',
+ { method: 'GET' },
+ 1000,
+ '',
+ ),
+ ).resolves.toBe('fetch direct');
- expect(mockFetch).not.toHaveBeenCalled();
+ expect(mockFetch).toHaveBeenCalledOnce();
+ expect(socketMocks.fetchResponse).not.toHaveBeenCalled();
+ });
+
+ it('일반 fetch가 실패하면 짧은 제한 시간으로 소켓 전송을 시도한다', async () => {
+ mockFetch.mockRejectedValueOnce(new Error('direct unavailable'));
+ socketMocks.fetchResponse.mockResolvedValueOnce(new Response('socket fallback'));
+
+ await expect(
+ fetchLotteMartHtml(
+ 'https://company.lottemart.com/mobiledowa/test',
+ { method: 'GET' },
+ 20000,
+ '',
+ ),
+ ).resolves.toBe('socket fallback');
+
+ expect(mockFetch).toHaveBeenCalledOnce();
+ expect(socketMocks.fetchResponse).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.any(Object),
+ '',
+ 5000,
+ );
+ });
+
+ it('일반 fetch와 소켓 전송이 모두 실패하면 원래 fetch 오류를 유지한다', async () => {
+ mockFetch.mockRejectedValueOnce(new Error('direct unavailable'));
+ socketMocks.fetchResponse.mockRejectedValueOnce(new Error('socket unavailable'));
+
+ await expect(
+ fetchLotteMartHtml(
+ 'https://company.lottemart.com/mobiledowa/test',
+ { method: 'GET' },
+ 1000,
+ '',
+ ),
+ ).rejects.toThrow('direct unavailable');
});
it('소켓 쓰기나 닫기가 멈추면 null을 반환한다', async () => {
@@ -282,7 +354,12 @@ describe('lottemart session helpers', () => {
text: async () => 'ok',
} as Response);
- await fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, '');
+ await fetchLotteMartHtml(
+ 'https://company.lottemart.com/mobiledowa/test',
+ { method: 'GET' },
+ 1000,
+ '',
+ );
await expect(getCachedLotteMartSessionCookie(1000)).resolves.toBe('ASPSESSIONID=E');
});
@@ -298,7 +375,12 @@ describe('lottemart session helpers', () => {
text: async () => 'ok',
} as Response);
- await fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, '');
+ await fetchLotteMartHtml(
+ 'https://company.lottemart.com/mobiledowa/test',
+ { method: 'GET' },
+ 1000,
+ '',
+ );
await expect(getCachedLotteMartSessionCookie(1000)).resolves.toBe('');
});
@@ -346,17 +428,31 @@ describe('lottemart session helpers', () => {
mockFetch.mockResolvedValue(new Response('boom', { status: 500, statusText: 'Server Error' }));
await expect(
- fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''),
+ fetchLotteMartHtml(
+ 'https://company.lottemart.com/mobiledowa/test',
+ { method: 'GET' },
+ 1000,
+ '',
+ ),
).rejects.toThrow('API 요청 실패: 500 Server Error');
});
it('재시도 응답이 실패면 HttpError를 던진다', async () => {
mockFetch
- .mockResolvedValueOnce(new Response('', { headers: { 'set-cookie': 'ASPSESSIONID=E; path=/' } }))
- .mockResolvedValueOnce(new Response('retry boom', { status: 500, statusText: 'Server Error' }));
+ .mockResolvedValueOnce(
+ new Response('', { headers: { 'set-cookie': 'ASPSESSIONID=E; path=/' } }),
+ )
+ .mockResolvedValueOnce(
+ new Response('retry boom', { status: 500, statusText: 'Server Error' }),
+ );
await expect(
- fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''),
+ fetchLotteMartHtml(
+ 'https://company.lottemart.com/mobiledowa/test',
+ { method: 'GET' },
+ 1000,
+ '',
+ ),
).rejects.toThrow('API 요청 실패: 500 Server Error');
});
@@ -376,7 +472,12 @@ describe('lottemart session helpers', () => {
.mockResolvedValueOnce(new Response('retry ok'));
await expect(
- fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''),
+ fetchLotteMartHtml(
+ 'https://company.lottemart.com/mobiledowa/test',
+ { method: 'GET' },
+ 1000,
+ '',
+ ),
).resolves.toBe('retry ok');
const retriedHeaders = mockFetch.mock.calls[1]?.[1]?.headers as Headers;
@@ -396,10 +497,17 @@ describe('lottemart session helpers', () => {
throw new Error('broken stream');
},
} as Response)
- .mockResolvedValueOnce(new Response('retry boom', { status: 500, statusText: 'Server Error' }));
+ .mockResolvedValueOnce(
+ new Response('retry boom', { status: 500, statusText: 'Server Error' }),
+ );
await expect(
- fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''),
+ fetchLotteMartHtml(
+ 'https://company.lottemart.com/mobiledowa/test',
+ { method: 'GET' },
+ 1000,
+ '',
+ ),
).rejects.toThrow('API 요청 실패: 500 Server Error');
});
@@ -417,7 +525,12 @@ describe('lottemart session helpers', () => {
} as Response);
await expect(
- fetchLotteMartHtml('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, 'ASPSESSIONID=EXIST'),
+ fetchLotteMartHtml(
+ 'https://company.lottemart.com/mobiledowa/test',
+ { method: 'GET' },
+ 1000,
+ 'ASPSESSIONID=EXIST',
+ ),
).rejects.toThrow('broken stream');
});
@@ -466,7 +579,9 @@ describe('lottemart session helpers', () => {
};
expect(zyteBody.httpRequestText).toBe('keyword=%ED%95%AB%EC%8B%9D%EC%8A%A4&page=2');
expect(zyteBody.customHttpRequestHeaders).toEqual(
- expect.arrayContaining([{ name: 'content-type', value: 'application/x-www-form-urlencoded' }]),
+ expect.arrayContaining([
+ { name: 'content-type', value: 'application/x-www-form-urlencoded' },
+ ]),
);
});
@@ -523,15 +638,13 @@ describe('lottemart session helpers', () => {
});
it('Zyte 응답 본문이 비어 있으면 에러를 던진다', async () => {
- mockFetch
- .mockRejectedValueOnce(new Error('The operation was aborted'))
- .mockResolvedValueOnce(
- new Response(JSON.stringify({ statusCode: 200 }), {
- headers: {
- 'Content-Type': 'application/json',
- },
- }),
- );
+ mockFetch.mockRejectedValueOnce(new Error('The operation was aborted')).mockResolvedValueOnce(
+ new Response(JSON.stringify({ statusCode: 200 }), {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ }),
+ );
await expect(
fetchLotteMartHtml(
@@ -551,14 +664,21 @@ describe('lottemart session helpers', () => {
fetchLotteMartPageWithSession('/mobiledowa/search_shop.asp', { method: 'POST' }, 1000, ''),
).resolves.toBe('page ok');
- expect(String(mockFetch.mock.calls[0]?.[0])).toBe('https://company.lottemart.com/mobiledowa/search_shop.asp');
+ expect(String(mockFetch.mock.calls[0]?.[0])).toBe(
+ 'https://company.lottemart.com/mobiledowa/search_shop.asp',
+ );
});
it('probeLotteMartRequest는 direct 결과를 요약한다', async () => {
mockFetch.mockResolvedValue(new Response('ok body', { status: 200, statusText: 'OK' }));
await expect(
- probeLotteMartRequest('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''),
+ probeLotteMartRequest(
+ 'https://company.lottemart.com/mobiledowa/test',
+ { method: 'GET' },
+ 1000,
+ '',
+ ),
).resolves.toEqual([
expect.objectContaining({
used: 'direct',
@@ -574,7 +694,12 @@ describe('lottemart session helpers', () => {
mockFetch.mockResolvedValue(new Response('', { status: 500, statusText: 'Server Error' }));
await expect(
- probeLotteMartRequest('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''),
+ probeLotteMartRequest(
+ 'https://company.lottemart.com/mobiledowa/test',
+ { method: 'GET' },
+ 1000,
+ '',
+ ),
).resolves.toEqual([
expect.objectContaining({
used: 'direct',
@@ -589,7 +714,12 @@ describe('lottemart session helpers', () => {
mockFetch.mockRejectedValue('network unavailable');
await expect(
- probeLotteMartRequest('https://company.lottemart.com/mobiledowa/test', { method: 'GET' }, 1000, ''),
+ probeLotteMartRequest(
+ 'https://company.lottemart.com/mobiledowa/test',
+ { method: 'GET' },
+ 1000,
+ '',
+ ),
).resolves.toEqual([
expect.objectContaining({
used: 'direct',
@@ -638,7 +768,9 @@ describe('lottemart session helpers', () => {
});
it('probeLotteMartRequest는 Error가 아닌 Zyte 실패도 기본 메시지로 요약한다', async () => {
- mockFetch.mockResolvedValueOnce(new Response('direct ok')).mockRejectedValueOnce('zyte unavailable');
+ mockFetch
+ .mockResolvedValueOnce(new Response('direct ok'))
+ .mockRejectedValueOnce('zyte unavailable');
const result = await probeLotteMartRequest(
'https://company.lottemart.com/mobiledowa/test',
@@ -658,16 +790,14 @@ describe('lottemart session helpers', () => {
});
it('probeLotteMartRequest는 direct 실패와 zyte 실패를 함께 기록한다', async () => {
- mockFetch
- .mockRejectedValueOnce(new Error('The operation was aborted'))
- .mockResolvedValueOnce(
- new Response(JSON.stringify({ title: 'Website Ban', detail: 'ban', status: 520 }), {
- status: 520,
- headers: {
- 'Content-Type': 'application/json',
- },
- }),
- );
+ mockFetch.mockRejectedValueOnce(new Error('The operation was aborted')).mockResolvedValueOnce(
+ new Response(JSON.stringify({ title: 'Website Ban', detail: 'ban', status: 520 }), {
+ status: 520,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ }),
+ );
const result = await probeLotteMartRequest(
'https://company.lottemart.com/mobiledowa/test',
diff --git a/tests/utils/format.test.ts b/tests/utils/format.test.ts
new file mode 100644
index 00000000..1bffc309
--- /dev/null
+++ b/tests/utils/format.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from 'vitest';
+import { formatTime, toNumber, toYyyymmdd } from '../../src/utils/format.js';
+
+describe('format utilities', () => {
+ it('한국 자정 이후에는 UTC 전날이 아닌 한국 날짜를 반환한다', () => {
+ expect(toYyyymmdd(new Date('2026-07-27T15:30:00.000Z'))).toBe('20260728');
+ });
+
+ it('숫자와 시간의 기존 정규화를 유지한다', () => {
+ expect(toNumber('12')).toBe(12);
+ expect(formatTime('0930')).toBe('09:30');
+ });
+});