Files
wandergo/backend/test/festival.e2e-spec.ts
T
ttipo 295d74f4d3 feat(backend): 축제·공연·행사 동기화와 조회 API 추가
홈 '이번 주말 축제·행사'와 장소 상세 '근처 축제·행사'에 쓸 데이터를 채운다.

- place:sync -- event: 관광공사 searchFestival2 를 오늘~90일 기간으로 받아
  festival_event 에 upsert(서버는 기간이 겹치는 행사를 준다). 지역 조건을 보내고
  응답에서도 다른 지역 행사는 거른다. 상세(소개·요금·공연 시간·장소)는 새로 생기거나
  수정된 행사만 받는다. 기간 안에서 목록에 없는 행사는 숨긴다
- GET /api/events: 기간(기본 오늘~30일, 최대 92일)·근처(좌표+반경)·분류·커서.
  60일 넘는 상시 프로그램과 숨김 행사는 뺀다. 항목에 오늘 진행 중 여부
- GET /api/events/{id}: 소개·공연 시간·요금·장소·홈페이지·출처
- 마이그레이션: festival_event 에 분류 코드·지역 코드·장소명·전화·홈페이지·
  작은 사진·원본 수정일·상세 수신 시각 추가

e2e(test/festival.e2e-spec.ts)는 2030년 날짜·동해 좌표 픽스처라 실행 날짜와 무관하다.
2026-11-03 실측: 앞으로 90일 관광공사 행사는 전국 106건, 대구 1건, 경북 7건.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 19:30:08 +09:00

227 lines
7.9 KiB
TypeScript

import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import type { Response } from 'supertest';
import { App } from 'supertest/types';
import { DataSource } from 'typeorm';
import { AppModule } from '@/app.module';
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { FestivalEvent } from '@/modules/place/entities/festival-event.entity';
/**
* 축제·공연·행사 조회 API 계약 — 기간 겹침·상시 프로그램 제외·숨김 제외·근처 거리순·분류·커서·상세를 실제 DB 로 검증한다.
* 실행 날짜와 무관하게 2030년 날짜·동해 좌표에 테스트 행사를 만든다(source_id 'e2e-ev-').
*/
const C = { lat: 36.6, lng: 130.6 };
const at = (dLat: number | null) =>
dLat === null ? { lat: null, lng: null } : { lat: C.lat + dLat, lng: C.lng };
const may = 'from=2030-05-01&to=2030-05-31';
const near = `${may}&lat=${C.lat}&lng=${C.lng}&radius=5000`;
interface ListBody {
data: {
items: Array<{
id: string;
name: string;
distanceM: number | null;
ongoing: boolean;
}>;
pageInfo: {
hasNext: boolean;
nextCursor: string | null;
total: number | null;
};
};
error?: { code: string };
}
interface DetailBody {
data: {
id: string;
name: string;
kind: string;
feeText: string | null;
playtimeText: string | null;
eventPlace: string | null;
attribution: { provider: string; asOf: string | null };
};
error?: { code: string };
}
const list = (res: Response) => res.body as ListBody;
const detail = (res: Response) => res.body as DetailBody;
const names = (res: Response) => list(res).data.items.map((i) => i.name);
describe('축제·행사 조회 API (e2e)', () => {
let app: INestApplication<App>;
let ds: DataSource;
const ids: Record<string, string> = {};
const cleanup = () =>
ds.query(`DELETE FROM festival_event WHERE source_id LIKE 'e2e-ev-%'`);
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api');
app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware()));
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
await app.init();
ds = app.get(DataSource);
await ds.runMigrations();
await cleanup();
const repo = ds.getRepository(FestivalEvent);
const save = async (key: string, e: Partial<FestivalEvent>) => {
const saved = await repo.save(
repo.create({ sourceId: `e2e-ev-${key}`, ldongRegnCd: '99', ...e }),
);
ids[key] = saved.id;
};
await save('E1', {
kind: 'festival',
name: '이투이 봄꽃축제',
startDate: '2030-05-03',
endDate: '2030-05-05',
...at(0.001),
feeText: '무료',
playtimeText: '10:00~21:00',
eventPlace: '이투이 공원 일원',
sourceModifiedAt: new Date('2030-04-01T00:00:00Z'),
});
await save('E2', {
kind: 'performance',
name: '이투이 재즈공연',
startDate: '2030-05-10',
endDate: '2030-05-10',
...at(0.002),
});
await save('E3', {
kind: 'event',
name: '이투이 상시전시',
startDate: '2030-01-01',
endDate: '2030-12-31', // 60일 넘는 상시 프로그램 — 목록에서 뺀다
...at(0.0015),
});
await save('E4', {
kind: 'festival',
name: '이투이 취소축제',
startDate: '2030-05-04',
endDate: '2030-05-04',
...at(0.0012),
status: 'hidden',
});
await save('E5', {
kind: 'festival',
name: '이투이 먼축제',
startDate: '2030-05-06',
endDate: '2030-05-07',
...at(0.2), // 약 22km — 반경 5km 밖
});
await save('E6', {
kind: 'festival',
name: '이투이 여름축제',
startDate: '2030-06-15',
endDate: '2030-06-16', // 기간 밖
...at(0.001),
});
await save('E7', {
kind: 'event',
name: '이투이 좌표없는행사',
startDate: '2030-05-20',
endDate: '2030-05-21',
...at(null),
});
});
afterAll(async () => {
await cleanup();
await app.close();
});
const get = (path: string) => request(app.getHttpServer()).get(path);
describe('GET /api/events', () => {
it('기간과 겹치는 행사를 시작일순으로 주고, 상시 프로그램·숨김·기간 밖은 뺀다', async () => {
const res = await get(`/api/events?${may}`).expect(200);
expect(names(res)).toEqual([
'이투이 봄꽃축제',
'이투이 먼축제',
'이투이 재즈공연',
'이투이 좌표없는행사',
]);
expect(list(res).data.pageInfo.total).toBe(4);
expect(list(res).data.items.every((i) => i.ongoing === false)).toBe(true);
});
it('좌표를 주면 반경 안 행사를 거리순으로 준다(좌표 없는 행사는 빠진다)', async () => {
const res = await get(`/api/events?${near}`).expect(200);
expect(names(res)).toEqual(['이투이 봄꽃축제', '이투이 재즈공연']);
expect(list(res).data.items[0].distanceM).toBeGreaterThan(100);
});
it('분류 필터', async () => {
const res = await get(`/api/events?${may}&kind=performance`).expect(200);
expect(names(res)).toEqual(['이투이 재즈공연']);
});
it('커서로 이어 받으면 겹치거나 빠지는 행사가 없다', async () => {
const p1 = await get(`/api/events?${may}&limit=2`).expect(200);
expect(names(p1)).toEqual(['이투이 봄꽃축제', '이투이 먼축제']);
const cursor = list(p1).data.pageInfo.nextCursor ?? '';
const p2 = await get(
`/api/events?${may}&limit=2&cursor=${encodeURIComponent(cursor)}`,
).expect(200);
expect(names(p2)).toEqual(['이투이 재즈공연', '이투이 좌표없는행사']);
expect(list(p2).data.pageInfo.hasNext).toBe(false);
});
it.each([
['끝이 시작보다 빠른 기간', 'from=2030-05-31&to=2030-05-01'],
['92일을 넘는 기간', 'from=2030-01-01&to=2030-06-30'],
['형식이 틀린 날짜', 'from=2030/05/01'],
['없는 날짜', 'from=2030-02-30&to=2030-03-01'],
['없는 분류', `${may}&kind=parade`],
['lat 만 보낸 경우', `${may}&lat=36.6`],
])('%s 는 VAL_001(400)', async (_, query) => {
const res = await get(`/api/events?${query}`).expect(400);
expect(list(res).error?.code).toBe('VAL_001');
});
});
describe('GET /api/events/:id', () => {
it('행사 상세 — 요금·공연 시간·장소·출처', async () => {
const res = await get(`/api/events/${ids.E1}`).expect(200);
expect(detail(res).data).toMatchObject({
name: '이투이 봄꽃축제',
kind: 'festival',
feeText: '무료',
playtimeText: '10:00~21:00',
eventPlace: '이투이 공원 일원',
attribution: { provider: '한국관광공사', asOf: '2030-04-01' },
});
});
it('숨김 행사·없는 id 는 RES_001(404), UUID 가 아니면 VAL_001(400)', async () => {
const hidden = await get(`/api/events/${ids.E4}`).expect(404);
expect(detail(hidden).error?.code).toBe('RES_001');
await get('/api/events/00000000-0000-4000-8000-000000000000').expect(404);
const bad = await get('/api/events/abc').expect(400);
expect(detail(bad).error?.code).toBe('VAL_001');
});
});
});