Files
wandergo/backend/test/kopis-sync.e2e-spec.ts
T
ttipo 90d41f0e6d
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m7s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 10m52s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m21s
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
fix(backend): KOPIS 에 일정변경으로 다시 등록된 같은 공연을 하나로 합침
KOPIS 는 일정이 바뀐 공연 제목에 "[일정변경]" 을 붙이고 새 id 로 다시 등록한다. 그래서 같은 공연장·같은 날
같은 공연이 두 번 보였다(2026-11-04 회귀 점검: 고령 남진 콘서트).
- 제목 앞의 "[일정변경]" 은 뗀다(화면에는 바뀐 날짜가 이미 보인다)
- 같은 공연장·같은 기간·같은 제목이면 먼저 등록된 것만 남기고 나머지는 숨긴다

문화포털 전시에 쓰는 "제목 6글자 이상 겹침" 기준은 공연에 쓰지 않는다. 처음 적용했을 때 음악제 이름을 앞에 붙인
같은 날 다른 공연("포항국제음악제, …" 두 공연)을 합쳐 버려서, 제목이 같을 때만 합치게 했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:37:16 +09:00

272 lines
8.7 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');
});
// 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');
});
});