feat(backend): KOPIS 공연 동기화 추가

대구 공연은 관광공사에 거의 없고(90일 1건) KOPIS 에는 153건이 있다.
place:sync -- kopis 로 대구·경북 공연을 매일 받는다.

- 목록 → 새·바뀐(afterdate)·7일 지난 공연만 상세 → 새 공연장만 시설 상세 → 시설 좌표를 공연에 복사
- 시설 좌표가 주소의 시·도 영역 밖이면 비운다(대구 중구 주소에 경주 좌표인 시설이 있었다)
- 포스터 http 주소를 https://kopis.or.kr 로 바꿔 저장, 0.6초 간격 호출(초당 10회 차단)
- 공공데이터 클라이언트에 키 파라미터 이름·호출 간격·경로를 나눠 받게 함
- XML 응답 해석에 fast-xml-parser 추가. 컨테이너 npm 11.20 이 만든 잠금 파일은
  선택 peer·@emnapi 항목을 지워 CI(npm 10)·호스트의 npm ci 가 실패해서, 새 패키지 항목만 기존 잠금에 더했다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-03 22:02:31 +09:00
parent d3d66444bc
commit 45db80d683
16 changed files with 1753 additions and 10 deletions
+235
View File
@@ -0,0 +1,235 @@
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> = {}): 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<string, KopisItem> = {
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<string, KopisItem> = {
// 주소 시·도를 모르는 곳 — 한국 안 좌표면 쓴다
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');
});
});