generated from ttipo/project-base
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user