generated from ttipo/project-base
feat(backend): 한국문화정보원 전시 동기화 추가
전시는 관광공사·KOPIS 어디에도 없다. 한국문화정보원 한눈에보는문화정보(대구 90일 전시 13건)를 place:sync -- kcisa 로 매일 받는다. - 분야가 '전시'인 것만 받는다(공연은 KOPIS 와 겹친다) - 관광공사·KOPIS 에 이름 비교 키가 같고 기간이 겹치는 행사가 있으면 넣지 않는다(event-name.ts) - 상세는 새 전시와 7일 지난 전시만(원본에 수정일이 없다), 좌표는 목록의 gpsY(위도)·gpsX(경도) - 이미지는 이용 조건(공공누리 1유형 여부)을 확인하기 전이라 저장하지 않는다 - htmlToText 가 따옴표·가운뎃점·번호 엔티티도 푼다 — 제목이 &lsquo; 처럼 이중 인코딩되어 온다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { PlaceSyncCliModule } from '@/commands/place-sync.cli.module';
|
||||
import { FestivalEvent } from '@/modules/place/entities/festival-event.entity';
|
||||
import { CallBudget } from '@/modules/place/sync/call-budget';
|
||||
import type {
|
||||
KcisaApi,
|
||||
KcisaItem,
|
||||
} from '@/modules/place/sync/kcisa-api.client';
|
||||
import { KcisaSync } from '@/modules/place/sync/kcisa.sync';
|
||||
import { PublicDataApiFactory } from '@/modules/place/sync/public-data.factory';
|
||||
import { kstDateString } from '@/shared/utils/kst';
|
||||
|
||||
/**
|
||||
* 한국문화정보원 전시 동기화 — 가짜 응답으로 전시만 받기·다른 출처 중복 건너뛰기·상세·숨김·원본 장애 판단을 실제 DB 로 검증한다.
|
||||
* 실제 데이터를 건드리지 않도록 없는 시·도 코드 '99' 와 '99000…' 일련번호만 쓴다.
|
||||
*/
|
||||
const REGION = '99';
|
||||
const ymd = (addDays: number) =>
|
||||
kstDateString(new Date(), addDays).replace(/-/g, '');
|
||||
|
||||
const item = (seq: string, extra: Partial<KcisaItem> = {}): KcisaItem => ({
|
||||
seq,
|
||||
title: `시험 전시 ${seq}`,
|
||||
startDate: ymd(0),
|
||||
endDate: ymd(30),
|
||||
realmName: '전시',
|
||||
place: '시험미술관',
|
||||
gpsX: '130.6',
|
||||
gpsY: '36.6',
|
||||
thumbnail: 'https://www.culture.go.kr/upload/thumb.jpg',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const DETAILS: Record<string, KcisaItem> = {
|
||||
'99000001': {
|
||||
price: '무료',
|
||||
contents1: '<p>시험 전시입니다</p>',
|
||||
phone: '053-000-0000',
|
||||
placeAddr: '시험시 시험구 1',
|
||||
url: 'https://museum.test/1',
|
||||
},
|
||||
'99000002': { price: '5,000원' },
|
||||
};
|
||||
|
||||
/** 실행마다 바꿔 끼우는 가짜 목록 */
|
||||
let fakeList: KcisaItem[] = [];
|
||||
|
||||
function fakeApi(budget: CallBudget): KcisaApi {
|
||||
return {
|
||||
listByArea: () => {
|
||||
budget.take('area2');
|
||||
return Promise.resolve({ items: fakeList, totalCount: fakeList.length });
|
||||
},
|
||||
getDetail: (seq) => {
|
||||
budget.take('detail2');
|
||||
return Promise.resolve(DETAILS[seq] ?? null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('한국문화정보원 전시 동기화 (e2e)', () => {
|
||||
let moduleRef: TestingModule;
|
||||
let ds: DataSource;
|
||||
let sync: KcisaSync;
|
||||
|
||||
const cleanup = async () => {
|
||||
await ds.query(
|
||||
`DELETE FROM festival_event WHERE (source = 'kcisa' AND source_id LIKE '99%') OR source_id = 'e2e-kc-tour'`,
|
||||
);
|
||||
await ds.query(
|
||||
`DELETE FROM place_sync_run WHERE job = 'kcisa' AND scope = $1`,
|
||||
[REGION],
|
||||
);
|
||||
};
|
||||
const event = (seq: string) =>
|
||||
ds.getRepository(FestivalEvent).findOneByOrFail({
|
||||
source: 'kcisa',
|
||||
sourceId: seq,
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
moduleRef = await Test.createTestingModule({
|
||||
imports: [PlaceSyncCliModule],
|
||||
})
|
||||
.overrideProvider(PublicDataApiFactory)
|
||||
.useValue({
|
||||
kcisaDailyLimit: () => 1_000,
|
||||
createKcisa: (budget: CallBudget) => fakeApi(budget),
|
||||
})
|
||||
.compile();
|
||||
await moduleRef.init();
|
||||
ds = moduleRef.get(DataSource);
|
||||
await ds.runMigrations();
|
||||
await cleanup();
|
||||
sync = moduleRef.get(KcisaSync);
|
||||
|
||||
// 관광공사에 이미 있는 행사 — 같은 이름의 전시는 넣지 않아야 한다
|
||||
const repo = ds.getRepository(FestivalEvent);
|
||||
await repo.save(
|
||||
repo.create({
|
||||
source: 'tour',
|
||||
sourceId: 'e2e-kc-tour',
|
||||
kind: 'event',
|
||||
name: '시험 비엔날레',
|
||||
startDate: kstDateString(new Date()),
|
||||
endDate: kstDateString(new Date(), 20),
|
||||
ldongRegnCd: REGION,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await moduleRef.close();
|
||||
});
|
||||
|
||||
it('첫 실행: 전시만 넣고, 다른 출처에 있는 행사는 건너뛰고, 상세를 받는다(이미지는 넣지 않음)', async () => {
|
||||
fakeList = [
|
||||
item('99000001'),
|
||||
item('99000002', { startDate: ymd(5), endDate: ymd(60) }),
|
||||
item('99000003', { realmName: '음악/콘서트' }),
|
||||
item('99000004', { title: '시험 비엔날레' }),
|
||||
];
|
||||
|
||||
const result = await sync.run({ regionCode: REGION });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
inserted: 2,
|
||||
detailed: 2,
|
||||
detailRemaining: 0,
|
||||
skipped: { not_exhibition: 1, duplicate_other_source: 1 },
|
||||
calls: { area2: 1, detail2: 2 },
|
||||
});
|
||||
expect(await event('99000001')).toMatchObject({
|
||||
kind: 'exhibition',
|
||||
genre: '전시',
|
||||
eventPlace: '시험미술관',
|
||||
lat: 36.6,
|
||||
lng: 130.6,
|
||||
feeText: '무료',
|
||||
overview: '시험 전시입니다',
|
||||
phone: '053-000-0000',
|
||||
address: '시험시 시험구 1',
|
||||
homepage: 'https://museum.test/1',
|
||||
imageUrl: null,
|
||||
imageThumbUrl: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('목록이 통째로 비면 원본 장애로 보고 숨기지 않는다', async () => {
|
||||
fakeList = [];
|
||||
|
||||
const result = await sync.run({ regionCode: REGION });
|
||||
|
||||
expect(result.hidden).toBe(0);
|
||||
expect(result.errors.join(' ')).toContain('원본 장애');
|
||||
expect((await event('99000001')).status).toBe('active');
|
||||
});
|
||||
|
||||
it('목록에서 하나가 없어지면 숨기고, 상세는 7일이 지나기 전에는 다시 받지 않는다', async () => {
|
||||
fakeList = [item('99000002', { startDate: ymd(5), endDate: ymd(60) })];
|
||||
|
||||
const result = await sync.run({ regionCode: REGION });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
hidden: 1,
|
||||
updated: 1,
|
||||
detailed: 0,
|
||||
calls: { area2: 1 },
|
||||
});
|
||||
expect((await event('99000001')).status).toBe('hidden');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user