/** * /naver-shopping, /naver-trend 순수 로직 테스트. * 실제 네이버 API 호출은 통합 영역이라 제외 — 카테고리 매칭 + intent 파서(tolerant * JSON 파싱, AI 호출 mock)만 고정. */ import { findCategoryCode, ROOT_CATEGORY_CODES } from '../src/features/naverTrend/categoryCodes'; import { parseShoppingIntent, parseSearchTrendIntent } from '../src/features/naverTrend/intentParser'; import type { IAIService, AIChatResult } from '../src/core/services'; import * as esbuild from 'esbuild'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; function mockAi(content: string, shouldThrow = false): IAIService { return { call: async () => content, chat: async (): Promise => { if (shouldThrow) throw new Error('AI 호출 실패(mock)'); return { content, engine: 'lmstudio', model: 'mock', empty: false }; }, }; } describe('findCategoryCode — 카테고리 이름/코드 매칭', () => { test('검증된 대분류 루트 코드 정확 매치', () => { const m = findCategoryCode('패션의류'); expect(m[0]).toMatchObject({ code: ROOT_CATEGORY_CODES['패션의류'], source: 'root' }); }); test('부분 일치도 루트 코드로 매치', () => { const m = findCategoryCode('화장품'); expect(m[0]).toMatchObject({ code: '50000002', source: 'root' }); }); test('숫자만 입력하면 raw cat_id 로 그대로 통과', () => { const m = findCategoryCode('50012345'); expect(m).toEqual([{ name: '50012345', path: '50012345', code: '50012345', source: 'raw' }]); }); test('트리에 있는 세부 카테고리명은 tree 매치로 코드 반환', () => { const m = findCategoryCode('원피스'); expect(m.length).toBeGreaterThan(0); expect(m[0].source).toBe('tree'); expect(m[0].code).toMatch(/^\d+$/); }); test('빈 문자열은 빈 배열', () => { expect(findCategoryCode('')).toEqual([]); }); test('매칭 안 되는 이름은 빈 배열', () => { expect(findCategoryCode('존재하지않는분야이름절대매칭안됨xyz')).toEqual([]); }); }); describe('parseShoppingIntent — tolerant JSON 파싱', () => { test('정상 JSON', async () => { const ai = mockAi('{"mode":"compare","categories":["패션의류","화장품/미용"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}'); const r = await parseShoppingIntent(ai, '패션의류랑 화장품 비교해줘', '2026-08-04'); expect(r.parsed).toBe(true); expect(r.mode).toBe('compare'); expect(r.categories).toEqual(['패션의류', '화장품/미용']); }); test('코드펜스로 감싼 JSON', async () => { const ai = mockAi('설명입니다\n```json\n{"mode":"discover","categories":["뷰티","디지털/가전"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}\n```'); const r = await parseShoppingIntent(ai, '요즘 뜨는 카테고리', '2026-08-04'); expect(r.parsed).toBe(true); expect(r.mode).toBe('discover'); expect(r.categories).toEqual(['뷰티', '디지털/가전']); }); test('완전히 깨진 응답 → compare 모드로 원문 폴백', async () => { const ai = mockAi('이건 JSON이 아닙니다 그냥 텍스트'); const r = await parseShoppingIntent(ai, '패션의류 트렌드', '2026-08-04'); expect(r.parsed).toBe(false); expect(r.mode).toBe('compare'); expect(r.categories).toEqual(['패션의류 트렌드']); }); test('AI 호출 자체가 실패해도 안전한 기본값으로 폴백(예외 전파 안 함)', async () => { const ai = mockAi('', true); const r = await parseShoppingIntent(ai, '패션의류 트렌드', '2026-08-04'); expect(r.parsed).toBe(false); expect(r.mode).toBe('compare'); expect(r.categories).toEqual(['패션의류 트렌드']); }); test('scope="keywords" — 분야 자체가 아니라 분야 "안"의 인기 검색어를 묻는 질문', async () => { const ai = mockAi('{"scope":"keywords","mode":"discover","categories":["디지털/가전"],"keywords":["무선이어폰","보조배터리","블루투스스피커"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}'); const r = await parseShoppingIntent(ai, '디지털/가전 인기 검색어', '2026-08-04'); expect(r.parsed).toBe(true); expect(r.scope).toBe('keywords'); expect(r.categories).toEqual(['디지털/가전']); expect(r.keywords).toEqual(['무선이어폰', '보조배터리', '블루투스스피커']); }); test('scope 미명시 응답은 기존 동작과 동일하게 category 로 기본값 처리', async () => { const ai = mockAi('{"mode":"compare","categories":["패션의류"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}'); const r = await parseShoppingIntent(ai, '패션의류 트렌드', '2026-08-04'); expect(r.scope).toBe('category'); expect(r.keywords).toEqual([]); }); }); describe('parseSearchTrendIntent — tolerant JSON 파싱', () => { test('정상 JSON', async () => { const ai = mockAi('{"mode":"compare","keywords":["원피스","블라우스"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}'); const r = await parseSearchTrendIntent(ai, '원피스랑 블라우스 비교', '2026-08-04'); expect(r.parsed).toBe(true); expect(r.keywords).toEqual(['원피스', '블라우스']); }); test('balanced-brace 추출 — 앞뒤에 잡텍스트가 섞여도 파싱', async () => { const ai = mockAi('물론이죠! {"mode":"discover","keywords":["여름 원피스","린넨 원피스"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]} 이상입니다.'); const r = await parseSearchTrendIntent(ai, '20대 여성 원피스 인기 키워드', '2026-08-04'); expect(r.parsed).toBe(true); expect(r.mode).toBe('discover'); expect(r.keywords).toEqual(['여름 원피스', '린넨 원피스']); }); test('빈 응답 → compare 모드로 원문 폴백', async () => { const ai = mockAi(''); const r = await parseSearchTrendIntent(ai, '원피스 키워드', '2026-08-04'); expect(r.parsed).toBe(false); expect(r.mode).toBe('compare'); expect(r.keywords).toEqual(['원피스 키워드']); }); }); // v2.2.319 회귀: `import * as x from './data.json'`(namespace import) 를 esbuild 로 // 번들하면 배열 최상위 JSON 이 `{ default: [...] }` 로 감싸져 x 자체가 배열이 아니게 // 된다. ts-jest 는 require() 를 그대로 써서 이 버그가 안 보여 통과했지만 실제 배포 // 번들(esbuild)에서 "CATEGORY_TREE is not iterable" 로 터졌다. default import 로 // 고친 뒤에도 재발 안 하도록 실제 esbuild 번들 결과를 실행해 검증한다. describe('categoryCodes.ts — esbuild 번들 회귀 방지', () => { test('esbuild 로 번들링한 결과에서도 카테고리 트리가 정상 로드됨', () => { const entry = path.join(__dirname, '../src/features/naverTrend/categoryCodes.ts'); const result = esbuild.buildSync({ entryPoints: [entry], bundle: true, platform: 'node', format: 'cjs', write: false, }); const tmpFile = path.join(os.tmpdir(), `categoryCodes.bundle.${process.pid}.${Date.now()}.js`); fs.writeFileSync(tmpFile, result.outputFiles[0].text); try { // eslint-disable-next-line @typescript-eslint/no-var-requires const mod = require(tmpFile); const names = mod.listTopLevelCategoryNames(); expect(Array.isArray(names)).toBe(true); expect(names.length).toBeGreaterThan(5); expect(mod.findCategoryCode('패션의류')[0]).toMatchObject({ code: '50000000', source: 'root' }); } finally { fs.unlinkSync(tmpFile); } }); });