import { Test, TestingModule } from '@nestjs/testing'; import { DataSource } from 'typeorm'; import { PlaceSyncCliModule } from '@/commands/place-sync.cli.module'; import { EventVenue } from '@/modules/place/entities/event-venue.entity'; import { FestivalEvent } from '@/modules/place/entities/festival-event.entity'; import { CallBudget } from '@/modules/place/sync/call-budget'; import type { KopisApi, KopisItem, } from '@/modules/place/sync/kopis-api.client'; import { KopisSync } from '@/modules/place/sync/kopis.sync'; import { PublicDataApiFactory } from '@/modules/place/sync/public-data.factory'; import { kstDateString } from '@/shared/utils/kst'; /** * KOPIS 공연 동기화 — 가짜 KOPIS 응답으로 목록 upsert·상세·공연시설·좌표 복사·숨김·"바뀐 공연만 상세"를 실제 DB 로 검증한다. * 실제 데이터를 건드리지 않도록 없는 시·도 코드 '99' 와 'PFE2E'·'FCE2E' id 만 쓴다. */ const REGION = '99'; const kopisDay = (addDays: number) => kstDateString(new Date(), addDays).replace(/-/g, '.'); const listItem = (id: string, extra: Partial = {}): KopisItem => ({ mt20id: id, prfnm: `시험 공연 ${id}`, prfpdfrom: kopisDay(0), prfpdto: kopisDay(5), fcltynm: '시험극장', poster: `http://www.kopis.or.kr/upload/pfmPoster/PF_${id}.gif`, genrenm: '연극', prfstate: '공연예정', ...extra, }); const DETAILS: Record = { PFE2E1: { mt20id: 'PFE2E1', mt10id: 'FCE2E1', prfnm: '시험 공연 PFE2E1', prfpdfrom: kopisDay(0), prfpdto: kopisDay(5), fcltynm: '시험극장 (소극장)', pcseguidance: '전석 20,000원', dtguidance: '금요일(19:30)', prfage: '만 7세 이상', updatedate: '2026-11-03 15:00:03.094823', relates: { relate: [{ relatenm: '예매', relateurl: 'https://ticket.test/1' }], }, }, PFE2E2: { mt20id: 'PFE2E2', mt10id: 'FCE2E2', prfnm: '시험 공연 PFE2E2', prfpdfrom: kopisDay(10), prfpdto: kopisDay(10), fcltynm: '좌표가 틀린 극장', }, }; const VENUES: Record = { // 주소 시·도를 모르는 곳 — 한국 안 좌표면 쓴다 FCE2E1: { fcltynm: '시험극장', adres: '시험시 시험구 1', la: '36.6', lo: '130.6', telno: '053-000-0000', }, // 대구 주소에 경주 좌표 — 비운다 FCE2E2: { fcltynm: '좌표가 틀린 극장', adres: '대구광역시 중구 북성로 96', la: '35.847189', lo: '129.212306', }, }; /** 실행마다 바꿔 끼우는 가짜 응답 */ const fake = { list: [] as KopisItem[], changed: [] as KopisItem[], }; function fakeApi(budget: CallBudget): KopisApi { return { listPerformances: (params) => { budget.take('pblprfr'); if (params.signgucode !== REGION || params.cpage > 1) { return Promise.resolve([]); } return Promise.resolve(params.afterdate ? fake.changed : fake.list); }, getPerformance: (id) => { budget.take('pblprfr-detail'); return Promise.resolve(DETAILS[id] ?? null); }, getVenue: (id) => { budget.take('prfplc-detail'); return Promise.resolve(VENUES[id] ?? null); }, }; } describe('KOPIS 공연 동기화 (e2e)', () => { let moduleRef: TestingModule; let ds: DataSource; let sync: KopisSync; const cleanup = async () => { await ds.query( `DELETE FROM festival_event WHERE source = 'kopis' AND source_id LIKE 'PFE2E%'`, ); await ds.query( `DELETE FROM event_venue WHERE source = 'kopis' AND source_id LIKE 'FCE2E%'`, ); await ds.query( `DELETE FROM place_sync_run WHERE job = 'kopis' AND scope = $1`, [REGION], ); }; const event = (id: string) => ds.getRepository(FestivalEvent).findOneByOrFail({ source: 'kopis', sourceId: id, }); beforeAll(async () => { moduleRef = await Test.createTestingModule({ imports: [PlaceSyncCliModule], }) .overrideProvider(PublicDataApiFactory) .useValue({ kopisDailyLimit: () => 1_000, createKopis: (budget: CallBudget) => fakeApi(budget), }) .compile(); await moduleRef.init(); ds = moduleRef.get(DataSource); await ds.runMigrations(); await cleanup(); sync = moduleRef.get(KopisSync); }); afterAll(async () => { await cleanup(); await moduleRef.close(); }); it('첫 실행: 목록·상세·공연시설을 받고 시설 좌표를 공연에 복사한다', async () => { fake.list = [ listItem('PFE2E1'), listItem('PFE2E1'), // 같은 공연이 두 번 listItem('PFE2E2', { prfpdfrom: kopisDay(10), prfpdto: kopisDay(10) }), listItem('PFE2E3', { prfstate: '공연완료' }), listItem('PFE2E4', { area: '경상북도' }), // 다른 시·도 ]; fake.changed = []; const result = await sync.run({ regionCode: REGION }); expect(result).toMatchObject({ inserted: 2, hidden: 0, detailed: 2, detailFailed: 0, detailRemaining: 0, venuesSynced: 2, venueCoordsRejected: ['좌표가 틀린 극장'], stoppedByBudget: false, skipped: { duplicate: 1, ended: 1, other_region: 1 }, calls: { pblprfr: 2, 'pblprfr-detail': 2, 'prfplc-detail': 2 }, }); const e1 = await event('PFE2E1'); expect(e1).toMatchObject({ kind: 'performance', ldongRegnCd: REGION, status: 'active', genre: '연극', eventPlace: '시험극장 (소극장)', feeText: '전석 20,000원', playtimeText: '금요일(19:30)', overview: '관람 연령: 만 7세 이상', bookingUrl: 'https://ticket.test/1', imageUrl: 'https://kopis.or.kr/upload/pfmPoster/PF_PFE2E1.gif', lat: 36.6, lng: 130.6, address: '시험시 시험구 1', phone: '053-000-0000', }); expect(e1.detailSyncedAt).not.toBeNull(); const venue = await ds .getRepository(EventVenue) .findOneByOrFail({ id: e1.venueId! }); expect(venue).toMatchObject({ sourceId: 'FCE2E1', name: '시험극장' }); // 좌표가 주소와 맞지 않는 시설의 공연은 좌표 없이 남는다(근처 조회에서만 빠진다) expect(await event('PFE2E2')).toMatchObject({ lat: null, lng: null, address: '대구광역시 중구 북성로 96', }); }); it('바뀐 것이 없으면 상세·시설을 다시 받지 않고, 장소 이름도 목록 값으로 덮지 않는다', async () => { const result = await sync.run({ regionCode: REGION }); expect(result).toMatchObject({ updated: 2, inserted: 0, detailed: 0, venuesSynced: 0, calls: { pblprfr: 2 }, }); expect((await event('PFE2E1')).eventPlace).toBe('시험극장 (소극장)'); }); it('목록에서 없어진 공연은 숨기고, 어제 이후 바뀐 공연은 상세를 다시 받는다', async () => { fake.list = [listItem('PFE2E1')]; fake.changed = [listItem('PFE2E1')]; const result = await sync.run({ regionCode: REGION }); expect(result).toMatchObject({ hidden: 1, changed: 1, detailed: 1, calls: { pblprfr: 2, 'pblprfr-detail': 1 }, }); expect((await event('PFE2E2')).status).toBe('hidden'); expect((await event('PFE2E1')).status).toBe('active'); }); // 2026-11-04 회귀 점검: "[일정변경] 데뷔 60주년 기념공연 …" 이 원래 공연과 함께 두 번 보였다 it('일정이 바뀌어 새 id 로 다시 등록된 같은 공연은 먼저 등록된 것만 남기고 나머지는 숨긴다', async () => { const renamed = '[일정변경] 시험 공연 PFE2E1'; // 이전 실행에서 이미 들어온 중복(중복 제거 전에 저장된 데이터) await ds.getRepository(FestivalEvent).save({ source: 'kopis', sourceId: 'PFE2E5', kind: 'performance', name: renamed, startDate: kstDateString(new Date(), 0), endDate: kstDateString(new Date(), 5), eventPlace: '시험극장', ldongRegnCd: REGION, status: 'active', }); fake.list = [ listItem('PFE2E1'), listItem('PFE2E5', { prfnm: renamed }), // 같은 날 같은 공연장의 다른 공연 — 앞의 행사 이름만 같으면 합치지 않는다(포항국제음악제 사례) listItem('PFE2E6', { prfnm: '시험 공연 PFE2E1, 아티스트 시리즈 Ⅱ' }), ]; fake.changed = []; const result = await sync.run({ regionCode: REGION }); expect(result).toMatchObject({ updated: 1, inserted: 1, hidden: 1, skipped: { duplicate_same_source: 1 }, }); expect((await event('PFE2E1')).status).toBe('active'); expect((await event('PFE2E5')).status).toBe('hidden'); expect((await event('PFE2E6')).status).toBe('active'); }); });