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; let ds: DataSource; const ids: Record = {}; 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) => { 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), }); await save('E8', { kind: 'exhibition', name: '이투이 기획전시', startDate: '2030-03-01', endDate: '2030-07-31', // 5개월 — 전시는 기간 상한이 길어 목록에 남는다 ...at(0.0025), genre: '전시', }); }); 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(5); 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(['이투이 재즈공연']); const ex = await get(`/api/events?${may}&kind=exhibition`).expect(200); expect(names(ex)).toEqual(['이투이 기획전시']); }); it('커서로 이어 받으면 겹치거나 빠지는 행사가 없다', async () => { const p1 = await get(`/api/events?${may}&limit=3`).expect(200); expect(names(p1)).toEqual([ '이투이 기획전시', '이투이 봄꽃축제', '이투이 먼축제', ]); const cursor = list(p1).data.pageInfo.nextCursor ?? ''; const p2 = await get( `/api/events?${may}&limit=3&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'], ['해석이 안 되는 날짜(13월)', 'from=2030-13-01&to=2030-12-31'], ['없는 분류', `${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'); }); }); });