diff --git a/src/api/lottecinemaHandlers.ts b/src/api/lottecinemaHandlers.ts index 83cd0b19..72bce4cd 100644 --- a/src/api/lottecinemaHandlers.ts +++ b/src/api/lottecinemaHandlers.ts @@ -1,3 +1,4 @@ +import { ServiceError } from '../core/errors.js'; import { hasInvalidCinemaQuery, CINEMA_INVALID_INPUT } from '../services/cgv/validation.js'; import { geocodeBindings } from '../utils/geocode.js'; /** @@ -9,7 +10,12 @@ import { fetchLotteCinemaNearbyTheaters, resolveLotteCinemaNearestTheater, } from '../services/lottecinema/location.js'; -import { type ApiContext, errorResponse, successResponse } from './response.js'; +import { + type ApiContext, + errorResponse, + serviceErrorResponse, + successResponse, +} from './response.js'; function parseOptionalNumber(value: string | undefined): number | undefined { return value === undefined ? undefined : Number(value); @@ -44,6 +50,7 @@ export async function handleLotteCinemaFindNearbyTheaters(c: ApiContext) { return successResponse(c, result, { total: result.count, pageSize: limit }); } catch (error) { + if (error instanceof ServiceError) return serviceErrorResponse(c, error, 'lottecinema_request'); const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'LOTTECINEMA_THEATER_SEARCH_FAILED', message, 500); } @@ -111,6 +118,7 @@ export async function handleLotteCinemaListNowShowing(c: ApiContext) { { total: showtimes.length }, ); } catch (error) { + if (error instanceof ServiceError) return serviceErrorResponse(c, error, 'lottecinema_request'); const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'LOTTECINEMA_MOVIE_LIST_FAILED', message, 500); } @@ -188,6 +196,7 @@ export async function handleLotteCinemaGetRemainingSeats(c: ApiContext) { { total: seats.length, pageSize: limit }, ); } catch (error) { + if (error instanceof ServiceError) return serviceErrorResponse(c, error, 'lottecinema_request'); const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'LOTTECINEMA_SEAT_LIST_FAILED', message, 500); } diff --git a/src/services/lottecinema/client.ts b/src/services/lottecinema/client.ts index 8b194754..6bfdcab8 100644 --- a/src/services/lottecinema/client.ts +++ b/src/services/lottecinema/client.ts @@ -1,3 +1,4 @@ +import { ServiceError } from '../../core/errors.js'; /** * 롯데시네마 API 클라이언트 */ @@ -81,7 +82,18 @@ async function requestLotteCinema( throw new Error(`롯데시네마 API 호출 실패: ${response.status}`); } - const parsed = (await response.json()) as { IsOK?: boolean; ResultMessage?: string } & T; + const parsed = (await response.json().catch((error: unknown) => { + if (error instanceof SyntaxError) { + throw new ServiceError( + 'LOTTECINEMA_INVALID_RESPONSE', + '롯데시네마 원본 응답을 읽을 수 없습니다. 잠시 후 다시 조회해주세요.', + 502, + true, + response.status, + ); + } + throw error; + })) as { IsOK?: boolean; ResultMessage?: string } & T; if (parsed.IsOK === false) { throw new Error(`롯데시네마 API 응답 실패: ${parsed.ResultMessage || 'UNKNOWN'}`); } @@ -207,9 +219,7 @@ async function fetchPlaySequenceByPair( }); } -export async function fetchLotteCinemaNowShowing( - params: CommonFetchParams, -): Promise<{ +export async function fetchLotteCinemaNowShowing(params: CommonFetchParams): Promise<{ theaters: LotteCinemaTheater[]; movies: LotteCinemaMovie[]; showtimes: LotteCinemaShowtime[]; diff --git a/tests/api/lottecinema-handlers.test.ts b/tests/api/lottecinema-handlers.test.ts index 30bece29..7d8aa6ec 100644 --- a/tests/api/lottecinema-handlers.test.ts +++ b/tests/api/lottecinema-handlers.test.ts @@ -460,3 +460,26 @@ describe('handleLotteCinemaGetRemainingSeats', () => { expect(ctx.json).toHaveBeenCalledWith(expect.objectContaining({ success: false, error: expect.objectContaining({ message: expect.stringContaining('위치를 좌표로 변환하지 못했습니다') }) }), 500); }); }); + +it.each([ + ['theaters', handleLotteCinemaFindNearbyTheaters], + ['movies', handleLotteCinemaListNowShowing], + ['seats', handleLotteCinemaGetRemainingSeats], +])('비 JSON 원본을 %s API의 내부 오류나 빈 결과로 바꾸지 않는다', async (_name, handler) => { + mockFetch.mockResolvedValue( + new Response('private upstream content', { status: 200 }), + ); + const ctx = createMockContext({ lat: '37.5132941', lng: '127.104215' }); + await handler(ctx); + expect(ctx.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: { + code: 'LOTTECINEMA_INVALID_RESPONSE', + message: '롯데시네마 원본 응답을 읽을 수 없습니다. 잠시 후 다시 조회해주세요.', + }, + diagnostics: expect.objectContaining({ upstreamStatus: 200, retryable: true }), + }), + 502, + ); +}); diff --git a/tests/services/lottecinema/client.test.ts b/tests/services/lottecinema/client.test.ts index f26559af..cf73e707 100644 --- a/tests/services/lottecinema/client.test.ts +++ b/tests/services/lottecinema/client.test.ts @@ -237,7 +237,9 @@ describe('fetchLotteCinemaNowShowing', () => { ), ) .mockResolvedValueOnce( - new Response(JSON.stringify({ IsOK: true, ResultMessage: 'SUCCESS', PlaySeqs: { Items: [] } })), + new Response( + JSON.stringify({ IsOK: true, ResultMessage: 'SUCCESS', PlaySeqs: { Items: [] } }), + ), ); const result = await fetchLotteCinemaNowShowing({ playDate: '20260310', theaterId: '1016' }); @@ -344,7 +346,9 @@ describe('fetchLotteCinemaNowShowing', () => { ), ) .mockResolvedValueOnce( - new Response(JSON.stringify({ IsOK: true, ResultMessage: 'SUCCESS', PlaySeqs: { Items: [] } })), + new Response( + JSON.stringify({ IsOK: true, ResultMessage: 'SUCCESS', PlaySeqs: { Items: [] } }), + ), ); const result = await fetchLotteCinemaNowShowing({ playDate: '2026-03-10', theaterId: '1016' }); @@ -367,3 +371,27 @@ describe('toYyyymmdd', () => { expect(toYyyymmdd(new Date('2026-03-10T00:00:00.000Z'))).toBe('20260310'); }); }); + +it.each(['private upstream content', '{invalid'])( + '비 JSON 원본 응답을 안전한 외부 오류로 분류한다: %s', + async (body) => { + mockFetch.mockResolvedValue(new Response(body, { status: 200 })); + await expect(fetchLotteCinemaTicketingPage()).rejects.toMatchObject({ + code: 'LOTTECINEMA_INVALID_RESPONSE', + status: 502, + upstreamStatus: 200, + retryable: true, + message: '롯데시네마 원본 응답을 읽을 수 없습니다. 잠시 후 다시 조회해주세요.', + }); + }, +); + +it('JSON 본문 읽기 중 취소는 원본 형식 오류 대신 기존 시간 초과로 유지한다', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => { + throw new DOMException('aborted', 'AbortError'); + }, + }); + await expect(fetchLotteCinemaTicketingPage()).rejects.toThrow('롯데시네마 API 호출 시간 초과'); +});