generated from ttipo/project-base
7ed3411abb
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 21m7s
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m54s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 11m31s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m47s
CI (Flutter) / Flutter (analyze · test) (push) Successful in 20m20s
CI / Frontend (lint · type-check · test) (push) Successful in 11m31s
CI / Backend (lint · test · e2e · build) (push) Successful in 12m46s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m53s
CI 의 빈 DB 에서 e2e 가 앱을 띄운 뒤에 마이그레이션을 돌려, 실행기의 기동 정리가 아직 없는 테이블을 찾다가 기동이 실패했다. 앱을 닫지 못해 1분 주기 정리 작업이 남아 jest 가 끝나지 않았고 CI 가 러너 제한 시간까지 멈춰 있었다. - 기동 정리는 실패해도 기동을 막지 않고 error 로그만 남긴다 - 앱 전체를 띄우는 e2e 는 운영처럼 마이그레이션을 먼저 돌린다 - 준비가 실패해도 afterAll 에서 앱을 반드시 닫는다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
255 lines
9.2 KiB
TypeScript
255 lines
9.2 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();
|
|
// 운영처럼 마이그레이션을 기동 훅보다 먼저 돌린다 — 빈 DB 에서 기동 훅(코스 생성 정리)이 테이블을 찾는다
|
|
ds = moduleFixture.get(DataSource);
|
|
await ds.runMigrations();
|
|
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();
|
|
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),
|
|
});
|
|
await save('E8', {
|
|
kind: 'exhibition',
|
|
name: '이투이 기획전시',
|
|
startDate: '2030-03-01',
|
|
endDate: '2030-07-31', // 5개월 — 전시는 기간 상한이 길어 목록에 남는다
|
|
...at(0.0025),
|
|
genre: '전시',
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// 준비가 실패해도 앱은 닫는다 — 닫지 않으면 주기 작업이 남아 jest 가 끝나지 않는다
|
|
try {
|
|
await cleanup();
|
|
} finally {
|
|
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'],
|
|
// 2026-11-04 회귀 점검: 끝을 생략하면 시작일로 끝 날짜를 계산하다 500(SYS_001)이 났다
|
|
['끝을 생략한 없는 시작일(13월)', 'from=2030-13-01'],
|
|
['끝을 생략한 없는 시작일(0월)', 'from=2030-00-10'],
|
|
['끝을 생략해 끝 날짜가 네 자리 연도를 넘는 경우', 'from=9999-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');
|
|
});
|
|
});
|
|
});
|