generated from ttipo/project-base
45db80d683
대구 공연은 관광공사에 거의 없고(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>
236 lines
7.2 KiB
TypeScript
236 lines
7.2 KiB
TypeScript
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');
|
|
});
|
|
});
|