diff --git a/src/api/healthCheckDefinitions.ts b/src/api/healthCheckDefinitions.ts index b545c66..636b094 100644 --- a/src/api/healthCheckDefinitions.ts +++ b/src/api/healthCheckDefinitions.ts @@ -143,6 +143,7 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [ 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'], + degradedFailurePatterns: EMART24_UPSTREAM_403_PATTERNS, }, { id: 'gs25.inventory', diff --git a/src/services/cgv/client.ts b/src/services/cgv/client.ts index b550c48..7425577 100644 --- a/src/services/cgv/client.ts +++ b/src/services/cgv/client.ts @@ -26,6 +26,10 @@ interface CommonFetchParams { const DEFAULT_THEATER_CODE = '0056'; const MAX_FALLBACK_THEATERS = 5; +function asArray(value: T[] | null | undefined): T[] { + return Array.isArray(value) ? value : []; +} + async function resolveTheaterCode(playDate: string, theaterCode: string | undefined, params: CommonFetchParams) { if (theaterCode) { return theaterCode; @@ -59,7 +63,7 @@ async function fetchMoviesByTheaterCode( params.zyteApiKey, ); - return (response.data || []) + return asArray(response.data) .filter((item) => item.movNo && item.movNm) .map((item) => ({ movieCode: item.movNo as string, @@ -89,7 +93,7 @@ async function fetchTimetableByMovieCode( params.zyteApiKey, ); - return (response.data || []) + return asArray(response.data) .filter((item) => item.siteNo && item.movNo && item.scnYmd) .map((item) => ({ scheduleId: `${item.scnYmd}${item.siteNo}${item.scnSseq || ''}`, @@ -124,7 +128,7 @@ async function fetchTimetableBySite( params.zyteApiKey, ); - return (response.data || []) + return asArray(response.data) .filter((item) => item.siteNo && item.scnYmd) .map((item) => ({ scheduleId: `${item.scnYmd}${item.siteNo}${item.scnSseq || ''}`, @@ -152,8 +156,8 @@ export async function fetchCgvTheaters(params: CommonFetchParams): Promise - (region.siteList || []).map((site) => ({ + const list = asArray(response.data).flatMap((region) => + asArray(region.siteList).map((site) => ({ theaterCode: site.siteNo || '', theaterName: site.siteNm || '', regionCode: region.regnGrpCd || undefined, diff --git a/tests/app/app-health-checks.test.ts b/tests/app/app-health-checks.test.ts index 570b4a5..08f7210 100644 --- a/tests/app/app-health-checks.test.ts +++ b/tests/app/app-health-checks.test.ts @@ -249,6 +249,39 @@ describe('GET /api/health/checks', () => { ); }); + it('이마트24 재고 upstream 403은 degraded로 집계한다', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse( + { + success: false, + error: { message: 'API 요청 실패: 403 Forbidden - 403 Forbidden' }, + }, + 502, + ), + ); + + const res = await app.request( + '/api/health/checks?check=emart24.inventory&mode=deep&fresh=true&transport=network', + { + headers: { Authorization: 'Bearer test-secret' }, + }, + { + HEALTH_CHECK_SECRET: 'test-secret', + }, + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.status).toBe('degraded'); + expect(data.checks[0]).toEqual( + expect.objectContaining({ + id: 'emart24.inventory', + status: 'degraded', + message: expect.stringContaining('403 Forbidden'), + }), + ); + }); + it('CLI 계약 체크에서 이마트24 upstream 403은 degraded로 집계한다', async () => { mockFetch.mockImplementation((input: RequestInfo | URL) => Promise.resolve( diff --git a/tests/services/cgv/client.test.ts b/tests/services/cgv/client.test.ts index 170cc89..3d82dba 100644 --- a/tests/services/cgv/client.test.ts +++ b/tests/services/cgv/client.test.ts @@ -125,6 +125,20 @@ describe('fetchCgvTheaters', () => { const result = await fetchCgvTheaters({}); expect(result).toEqual([]); }); + + it('data가 배열이 아니면 빈 배열을 반환한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + statusCode: 0, + data: { status: 'temporarily-changed' }, + }), + ), + ); + + const result = await fetchCgvTheaters({}); + expect(result).toEqual([]); + }); }); describe('fetchCgvMovies', () => { @@ -239,6 +253,20 @@ describe('fetchCgvMovies', () => { const result = await fetchCgvMovies({ playDate: '20260304', theaterCode: '0056' }); expect(result).toEqual([]); }); + + it('영화 목록 data가 배열이 아니면 빈 배열을 반환한다', async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + statusCode: 0, + data: { status: 'temporarily-changed' }, + }), + ), + ); + + const result = await fetchCgvMovies({ playDate: '20260304', theaterCode: '0056' }); + expect(result).toEqual([]); + }); }); describe('fetchCgvTimetable', () => { @@ -660,6 +688,34 @@ describe('fetchCgvTimetable', () => { expect(result).toEqual([]); }); + it('시간표 data가 배열이 아니면 빈 배열로 처리 후 fallback한다', async () => { + mockFetch + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + statusCode: 0, + data: { status: 'temporarily-changed' }, + }), + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + statusCode: 0, + data: { status: 'temporarily-changed' }, + }), + ), + ); + + const result = await fetchCgvTimetable({ + playDate: '20260304', + theaterCode: '0056', + movieCode: '30000985', + }); + + expect(result).toEqual([]); + }); + it('movieCode가 있고 사이트 시간표가 비면 영화코드 조회로 fallback한다', async () => { mockFetch .mockResolvedValueOnce(