import { Test, TestingModule } from '@nestjs/testing'; import { DataSource } from 'typeorm'; import { PlaceSyncCliModule } from '@/commands/place-sync.cli.module'; import { FestivalEvent } from '@/modules/place/entities/festival-event.entity'; import { CallBudget } from '@/modules/place/sync/call-budget'; import type { KcisaApi, KcisaItem, } from '@/modules/place/sync/kcisa-api.client'; import { KcisaSync } from '@/modules/place/sync/kcisa.sync'; import { PublicDataApiFactory } from '@/modules/place/sync/public-data.factory'; import { kstDateString } from '@/shared/utils/kst'; /** * 한국문화정보원 전시 동기화 — 가짜 응답으로 전시만 받기·다른 출처 중복 건너뛰기·상세·숨김·원본 장애 판단을 실제 DB 로 검증한다. * 실제 데이터를 건드리지 않도록 없는 시·도 코드 '99' 와 '99000…' 일련번호만 쓴다. */ const REGION = '99'; const ymd = (addDays: number) => kstDateString(new Date(), addDays).replace(/-/g, ''); const item = (seq: string, extra: Partial = {}): KcisaItem => ({ seq, title: `시험 전시 ${seq}`, startDate: ymd(0), endDate: ymd(30), realmName: '전시', place: '시험미술관', gpsX: '130.6', gpsY: '36.6', thumbnail: 'https://www.culture.go.kr/upload/thumb.jpg', ...extra, }); const DETAILS: Record = { '99000001': { price: '무료', contents1: '

시험 전시입니다

', phone: '053-000-0000', placeAddr: '시험시 시험구 1', url: 'https://museum.test/1', }, '99000002': { price: '5,000원' }, }; /** 실행마다 바꿔 끼우는 가짜 목록 */ let fakeList: KcisaItem[] = []; function fakeApi(budget: CallBudget): KcisaApi { return { listByArea: () => { budget.take('area2'); return Promise.resolve({ items: fakeList, totalCount: fakeList.length }); }, getDetail: (seq) => { budget.take('detail2'); return Promise.resolve(DETAILS[seq] ?? null); }, }; } describe('한국문화정보원 전시 동기화 (e2e)', () => { let moduleRef: TestingModule; let ds: DataSource; let sync: KcisaSync; const cleanup = async () => { await ds.query( `DELETE FROM festival_event WHERE (source = 'kcisa' AND source_id LIKE '99%') OR source_id = 'e2e-kc-tour'`, ); await ds.query( `DELETE FROM place_sync_run WHERE job = 'kcisa' AND scope = $1`, [REGION], ); }; const event = (seq: string) => ds.getRepository(FestivalEvent).findOneByOrFail({ source: 'kcisa', sourceId: seq, }); beforeAll(async () => { moduleRef = await Test.createTestingModule({ imports: [PlaceSyncCliModule], }) .overrideProvider(PublicDataApiFactory) .useValue({ kcisaDailyLimit: () => 1_000, createKcisa: (budget: CallBudget) => fakeApi(budget), }) .compile(); await moduleRef.init(); ds = moduleRef.get(DataSource); await ds.runMigrations(); await cleanup(); sync = moduleRef.get(KcisaSync); // 관광공사에 이미 있는 행사 — 같은 이름의 전시는 넣지 않아야 한다 const repo = ds.getRepository(FestivalEvent); await repo.save( repo.create({ source: 'tour', sourceId: 'e2e-kc-tour', kind: 'event', name: '시험 비엔날레', startDate: kstDateString(new Date()), endDate: kstDateString(new Date(), 20), ldongRegnCd: REGION, }), ); }); afterAll(async () => { await cleanup(); await moduleRef.close(); }); it('첫 실행: 전시만 넣고, 다른 출처·같은 출처 중복은 건너뛰고, 상세를 받는다(이미지는 넣지 않음)', async () => { fakeList = [ item('99000001'), item('99000002', { startDate: ymd(5), endDate: ymd(60) }), item('99000003', { realmName: '음악/콘서트' }), item('99000004', { title: '시험 비엔날레' }), // 99000001 과 같은 장소·기간, 제목만 다르게 다시 등록된 전시 item('99000005', { title: '시험 전시 《99000001》 특별전' }), ]; const result = await sync.run({ regionCode: REGION }); expect(result).toMatchObject({ inserted: 2, detailed: 2, detailRemaining: 0, skipped: { not_exhibition: 1, duplicate_other_source: 1, duplicate_same_source: 1, }, calls: { area2: 1, detail2: 2 }, }); expect(await event('99000001')).toMatchObject({ kind: 'exhibition', genre: '전시', eventPlace: '시험미술관', lat: 36.6, lng: 130.6, feeText: '무료', overview: '시험 전시입니다', phone: '053-000-0000', address: '시험시 시험구 1', homepage: 'https://museum.test/1', imageUrl: null, imageThumbUrl: null, }); }); it('목록이 통째로 비면 원본 장애로 보고 숨기지 않는다', async () => { fakeList = []; const result = await sync.run({ regionCode: REGION }); expect(result.hidden).toBe(0); expect(result.errors.join(' ')).toContain('원본 장애'); expect((await event('99000001')).status).toBe('active'); }); it('목록에서 하나가 없어지면 숨기고, 상세는 7일이 지나기 전에는 다시 받지 않는다', async () => { fakeList = [item('99000002', { startDate: ymd(5), endDate: ymd(60) })]; const result = await sync.run({ regionCode: REGION }); expect(result).toMatchObject({ hidden: 1, updated: 1, detailed: 0, calls: { area2: 1 }, }); expect((await event('99000001')).status).toBe('hidden'); }); });