From 537192b43f322f429566119637a414dec6d940ab Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Wed, 21 Oct 2026 17:11:30 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B3=B5=EC=8B=9D=20=EC=9D=B8=EA=B8=B0?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=EC=96=B4=20=EB=B9=88=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=9D=84=20=EB=B9=84=EC=A0=9C=EA=B3=B5=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/seveneleven/client.ts | 2 +- src/services/seveneleven/popwords.ts | 17 ++++++++++---- .../seveneleven/tools/getSearchPopwords.ts | 17 ++++++++++---- tests/app/app-api-seveneleven.test.ts | 2 +- tests/services/seveneleven/popwords.test.ts | 15 ++++++++++--- .../tools/getSearchPopwords.test.ts | 22 ++++++++++++++++++- 6 files changed, 61 insertions(+), 14 deletions(-) diff --git a/src/services/seveneleven/client.ts b/src/services/seveneleven/client.ts index 6bc3119b..8b289025 100644 --- a/src/services/seveneleven/client.ts +++ b/src/services/seveneleven/client.ts @@ -286,7 +286,7 @@ export async function fetchSevenElevenSearchPopwords( options, ); - return parseSevenElevenPopwords(response.data); + return parseSevenElevenPopwords(response); } export async function fetchSevenElevenStockProductMeta( diff --git a/src/services/seveneleven/popwords.ts b/src/services/seveneleven/popwords.ts index 76ed40e8..0e4a4c67 100644 --- a/src/services/seveneleven/popwords.ts +++ b/src/services/seveneleven/popwords.ts @@ -10,8 +10,12 @@ const entry = z.union([ z.object({ name: text }).transform((v) => v.name), ]); const words = z.array(entry); -const response = z.union([ +const dataSchema = z.union([ words, + z + .object({}) + .strict() + .transform(() => []), z .object({ keywords: words.optional(), @@ -23,8 +27,13 @@ const response = z.union([ .refine((lists) => lists.length > 0) .transform((lists) => lists.find((list) => list.length > 0) ?? []), ]); -export function parseSevenElevenPopwords(data: unknown): string[] { - const parsed = response.safeParse(data); +const response = z.object({ + success: z.literal(true).optional(), + code: z.number().int().min(200).max(299).optional(), + data: dataSchema, +}); +export function parseSevenElevenPopwords(envelope: unknown): string[] { + const parsed = response.safeParse(envelope); if (!parsed.success) throw new ServiceError( 'SEVENELEVEN_INVALID_RESPONSE', @@ -32,5 +41,5 @@ export function parseSevenElevenPopwords(data: unknown): string[] { 502, true, ); - return parsed.data; + return parsed.data.data; } diff --git a/src/services/seveneleven/tools/getSearchPopwords.ts b/src/services/seveneleven/tools/getSearchPopwords.ts index af823424..d46ec98b 100644 --- a/src/services/seveneleven/tools/getSearchPopwords.ts +++ b/src/services/seveneleven/tools/getSearchPopwords.ts @@ -35,9 +35,10 @@ async function getSearchPopwords( available: keywords.length > 0, count: keywords.length, keywords, + ...(keywords.length === 0 ? { reason: 'upstream_empty' } : {}), note: keywords.length === 0 - ? '현재 응답에서 인기 검색어 목록을 찾지 못했습니다.' + ? '공식 API가 빈 목록을 반환했습니다. 현재 제공되는 인기 검색어를 찾지 못했습니다.' : '홈 인기 검색어를 조회했습니다.', }, null, @@ -48,7 +49,10 @@ async function getSearchPopwords( }; } -export function createGetSearchPopwordsTool(zyteApiKey?: string, transport: ConvenienceTransportOptions = {}): ToolRegistration { +export function createGetSearchPopwordsTool( + zyteApiKey?: string, + transport: ConvenienceTransportOptions = {}, +): ToolRegistration { return { name: 'seveneleven_get_search_popwords', metadata: { @@ -56,9 +60,14 @@ export function createGetSearchPopwordsTool(zyteApiKey?: string, transport: Conv description: '세븐일레븐 인기 검색어 목록을 조회합니다.', inputSchema: { label: z.string().optional().default('home').describe('조회 라벨 (기본값: home)'), - timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), + timeoutMs: z + .number() + .optional() + .default(15000) + .describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: (args: unknown) => getSearchPopwords(args as GetSearchPopwordsArgs, zyteApiKey, transport), + handler: (args: unknown) => + getSearchPopwords(args as GetSearchPopwordsArgs, zyteApiKey, transport), }; } diff --git a/tests/app/app-api-seveneleven.test.ts b/tests/app/app-api-seveneleven.test.ts index 7c6d6356..f7815353 100644 --- a/tests/app/app-api-seveneleven.test.ts +++ b/tests/app/app-api-seveneleven.test.ts @@ -77,7 +77,7 @@ describe('GET /api/seveneleven/popwords', () => { }); it('형식이 잘못된 인기 검색어 원본은 빈 성공 대신 502를 반환한다', async () => { - mockFetch.mockResolvedValue(Response.json({ success: true, data: {} })); + mockFetch.mockResolvedValue(Response.json({ success: true, data: { unexpected: true } })); const res = await app.request('/api/seveneleven/popwords?label=home'); expect(res.status).toBe(502); expect((await res.json()).error.code).toBe('SEVENELEVEN_INVALID_RESPONSE'); diff --git a/tests/services/seveneleven/popwords.test.ts b/tests/services/seveneleven/popwords.test.ts index c7f37514..3b89278b 100644 --- a/tests/services/seveneleven/popwords.test.ts +++ b/tests/services/seveneleven/popwords.test.ts @@ -11,18 +11,27 @@ it.each([ [{ wordList: ['커피'] }, ['커피']], [{ list: ['커피'] }, ['커피']], [[], []], + [{}, []], [{ keywords: [], list: ['커피'] }, ['커피']], [{ keywords: [], popwords: [], wordList: [], list: [] }, []], ])('공개 검색어 배열/이름을 명시적으로 해석한다: %j', (input, output) => { - expect(parseSevenElevenPopwords(input)).toEqual(output); + expect(parseSevenElevenPopwords({ success: true, code: 200, data: input })).toEqual(output); }); it.each([ - {}, null, { surprise: [] }, { list: [null] }, { keywords: [''] }, { list: [{ name: 4 }] }, ])('모르는 형식을 빈 목록으로 위장하지 않는다: %j', (input) => { - expect(() => parseSevenElevenPopwords(input)).toThrow('인기 검색어 응답'); + expect(() => parseSevenElevenPopwords({ success: true, code: 200, data: input })).toThrow( + '인기 검색어 응답', + ); }); + +it.each([{ success: false, data: {} }, { success: true, code: 501, data: {} }, {}])( + '실패 envelope와 누락된 data를 거절한다: %j', + (input) => { + expect(() => parseSevenElevenPopwords(input)).toThrow('인기 검색어 응답'); + }, +); diff --git a/tests/services/seveneleven/tools/getSearchPopwords.test.ts b/tests/services/seveneleven/tools/getSearchPopwords.test.ts index aa2820a9..c857f312 100644 --- a/tests/services/seveneleven/tools/getSearchPopwords.test.ts +++ b/tests/services/seveneleven/tools/getSearchPopwords.test.ts @@ -57,7 +57,7 @@ describe('createGetSearchPopwordsTool', () => { expect(parsed.note).toContain('찾지 못했습니다'); }); - it.each([{}, { unexpected: true }, null, { keywords: [null] }])( + it.each([{ unexpected: true }, null, { keywords: [null] }])( '해석할 수 없는 원본은 빈 성공으로 바꾸지 않는다: %j', async (data) => { mockFetch.mockResolvedValue(Response.json({ success: true, data })); @@ -65,6 +65,26 @@ describe('createGetSearchPopwordsTool', () => { }, ); + it('실제 원본의 성공 code200/data{}는 제공 목록 없음으로 구분한다', async () => { + mockFetch.mockResolvedValue( + Response.json({ success: true, data: {}, message: '성공', code: 200 }), + ); + const result = await createGetSearchPopwordsTool().handler({}); + expect(JSON.parse(result.content[0].text)).toMatchObject({ + available: false, + count: 0, + keywords: [], + reason: 'upstream_empty', + }); + }); + it.each([ + { success: false, data: {} }, + { success: true, code: 501, data: {} }, + ])('원본 실패를 빈 성공으로 바꾸지 않는다: %j', async (response) => { + mockFetch.mockResolvedValue(Response.json(response)); + await expect(createGetSearchPopwordsTool().handler({})).rejects.toThrow('인기 검색어 응답'); + }); + it('키가 있어도 차단된 인기 검색어 요청에 유료 호출을 보내지 않는다', async () => { mockFetch.mockResolvedValueOnce(new Response('blocked', { status: 403 })); await expect(