generated from ttipo/project-base
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
KOPIS 는 일정이 바뀐 공연 제목에 "[일정변경]" 을 붙이고 새 id 로 다시 등록한다. 그래서 같은 공연장·같은 날
같은 공연이 두 번 보였다(2026-11-04 회귀 점검: 고령 남진 콘서트).
- 제목 앞의 "[일정변경]" 은 뗀다(화면에는 바뀐 날짜가 이미 보인다)
- 같은 공연장·같은 기간·같은 제목이면 먼저 등록된 것만 남기고 나머지는 숨긴다
문화포털 전시에 쓰는 "제목 6글자 이상 겹침" 기준은 공연에 쓰지 않는다. 처음 적용했을 때 음악제 이름을 앞에 붙인
같은 날 다른 공연("포항국제음악제, …" 두 공연)을 합쳐 버려서, 제목이 같을 때만 합치게 했다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
272 lines
8.7 KiB
TypeScript
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');
|
|
});
|
|
});
|