import { randomUUID } from 'crypto'; import { INestApplication, ValidationPipe } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Test, TestingModule } from '@nestjs/testing'; import { getStorageToken } from '@nestjs/throttler'; 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 { AiUnavailableError, COURSE_AI_CLIENT, type CourseAiClient, type CourseDraftInput, } from '@/modules/course-generation/ai/course-ai.client'; import { FakeCourseAiClient } from '@/modules/course-generation/ai/fake-course-ai.client'; import { hashDeviceId } from '@/modules/course-generation/device-id.decorator'; import { Place } from '@/modules/place/entities/place.entity'; import { WeatherService } from '@/modules/weather/weather.service'; import { kstDateString } from '@/shared/utils/kst'; import { parseOpeningHours } from '@/shared/utils/opening-hours'; /** * AI 코스 생성 API 계약 — 횟수·접수·진행 조회·결과·취소·평가·실패 코드를 실제 HTTP·DB 로 검증한다. * 시험 장소는 대구 영역 사각형(geo.ts) 남서쪽 끝(실제로는 대구 밖이라 장소 데이터가 없는 곳)에 만들고(source_id 'e2e-cg-'), * 끝나면 지운다. AI 는 가짜(조절 가능), 날씨는 null 로 바꿔 키 없이 돈다. */ const C = { lat: 35.56, lng: 128.31 }; /** 후보가 없는 곳(5km 떨어짐) */ const EMPTY = { lat: 35.6, lng: 128.36 }; const DEVICE_HEADER = 'X-Device-Id'; const TERMINAL = ['succeeded', 'failed', 'canceled']; type Mode = 'normal' | 'block' | 'unavailable'; /** 가짜 AI 를 감싸 시험마다 막아 두거나(block) 쓸 수 없게(unavailable) 한다 */ class ControllableAi implements CourseAiClient { mode: Mode = 'normal'; private readonly fake = new FakeCourseAiClient(); draftCourse(input: CourseDraftInput) { if (this.mode === 'unavailable') { return Promise.reject(new AiUnavailableError('시험: AI 장애')); } if (this.mode === 'block') { return new Promise((_, reject) => { input.signal.addEventListener('abort', () => reject(new Error('시험: 끊김')), ); }); } return this.fake.draftCourse(input); } parseConditions(input: Parameters[0]) { return this.fake.parseConditions(input); } } interface Envelope { success: boolean; data: T; error?: { code: string }; } interface GenerationBody { id: string; status: string; stage: string | null; progress: { found: number | null; kept: number | null }; remaining: number; rating: string | null; failure: { code: string; notices: string[] } | null; result: { outcome: string; window: { date: string; start: string; end: string }; totals: { stops: number }; stops: { id: string; arrive: string; leave: string; minutes: number; legToNext: { minutes: number; mode: string } | null; }[]; alternates: { forIndex: number }[]; } | null; } const body = (res: Response) => res.body as Envelope; describe('AI 코스 생성 API (e2e)', () => { let app: INestApplication; let ds: DataSource; const ai = new ControllableAi(); const devices: string[] = []; const placeIds: string[] = []; const date = kstDateString(new Date(), 1); const device = () => { const id = randomUUID(); devices.push(id); return id; }; const conditions = (overrides: Record = {}) => ({ area: { type: 'point', ...C }, date, start: '13:00', end: '19:00', party: 2, pet: null, mobility: 'walk', indoorIfRain: true, ...overrides, }); const create = (deviceId: string, overrides: Record = {}) => request(app.getHttpServer()) .post('/api/course-generations') .set(DEVICE_HEADER, deviceId) .send({ request: '', conditions: conditions(overrides) }); const get = (deviceId: string, id: string) => request(app.getHttpServer()) .get(`/api/course-generations/${id}`) .set(DEVICE_HEADER, deviceId); /** 끝날 때까지(또는 원하는 상태까지) 조회한다 */ async function waitFor( deviceId: string, id: string, until: (g: GenerationBody) => boolean = (g) => TERMINAL.includes(g.status), ): Promise { for (let i = 0; i < 100; i++) { const res = await get(deviceId, id).expect(200); const generation = body(res).data; if (until(generation)) return generation; await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error(`생성 ${id} 이 끝나지 않았다`); } async function cleanup(): Promise { if (devices.length > 0) { await ds.query( `DELETE FROM ai_course_generation WHERE device_hash = ANY($1)`, [devices.map(hashDeviceId)], ); } await ds.query(`DELETE FROM place WHERE source_id LIKE 'e2e-cg-%'`); } beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }) .overrideProvider(COURSE_AI_CLIENT) .useValue(ai) .overrideProvider(WeatherService) .useValue({ forecastWindow: () => Promise.resolve(null) }) // 시험 요청은 모두 같은 주소에서 온다 — 분당 요청 제한(생성 5회)을 세지 않는다 .overrideProvider(getStorageToken()) .useValue({ increment: () => Promise.resolve({ totalHits: 1, timeToExpire: 60, isBlocked: false, timeToBlockExpire: 0, }), }) .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 places = ds.getRepository(Place); const rows: [string, Place['category'], string, number][] = [ ['1', 'attraction', '상시 개방', 0], ['2', 'cafe', '10:00~22:00', 0.002], ['3', 'culture', '09:00~22:00', 0.004], ['4', 'food', '11:00~22:00', 0.006], ['5', 'nature', '상시 개방', -0.002], ['6', 'cafe', '10:00~22:00', -0.004], ]; for (const [key, category, hours, dLat] of rows) { const saved = await places.save( places.create({ source: 'tour', sourceId: `e2e-cg-${key}`, category, name: `이투이 코스 ${key}`, lat: C.lat + dLat, lng: C.lng, publicHoursText: hours, publicHours: parseOpeningHours(hours, '연중무휴'), }), ); placeIds.push(saved.id); } }); afterAll(async () => { // 준비가 실패해도 앱은 닫는다 — 닫지 않으면 주기 작업이 남아 jest 가 끝나지 않는다 try { await cleanup(); } finally { await app.close(); } }); afterEach(() => { ai.mode = 'normal'; }); it('X-Device-Id 가 없거나 UUID 가 아니면 VAL_001', async () => { const missing = await request(app.getHttpServer()) .get('/api/course-generations/quota') .expect(400); expect(body(missing).error?.code).toBe('VAL_001'); const wrong = await request(app.getHttpServer()) .get('/api/course-generations/quota') .set(DEVICE_HEADER, 'not-a-uuid') .expect(400); expect(body(wrong).error?.code).toBe('VAL_001'); }); it('오늘 남은 횟수 — 비로그인 하루 2번', async () => { const res = await request(app.getHttpServer()) .get('/api/course-generations/quota') .set(DEVICE_HEADER, device()) .expect(200); expect(body(res).data).toMatchObject({ limit: 2, used: 0, remaining: 2, tier: 'guest', }); }); it('조건 해석 — 비어 있는 항목은 기본값으로 채우고, 대구 밖 기준 위치는 VAL_001', async () => { const res = await request(app.getHttpServer()) .post('/api/course-conditions') .set(DEVICE_HEADER, device()) .send({ text: '토요일에 걷기 좋은 곳', baseArea: { type: 'point', ...C }, }) .expect(200); expect(body(res).data).toMatchObject({ conditions: { area: { type: 'point', ...C }, party: 2, pet: null, mobility: 'walk', indoorIfRain: true, }, inferred: [], issues: [], weather: null, }); const outside = await request(app.getHttpServer()) .post('/api/course-conditions') .set(DEVICE_HEADER, device()) .send({ text: '', baseArea: { type: 'point', lat: 37.5665, lng: 126.978 }, }) .expect(400); expect(body(outside).error?.code).toBe('VAL_001'); }); it('생성을 접수하고 진행을 조회하면 결과가 나오고, 그때 횟수를 차감한다', async () => { const me = device(); const created = await request(app.getHttpServer()) .post('/api/course-generations') .set(DEVICE_HEADER, me) .send({ request: '연락은 010-1234-5678 로, 산책하고 카페', conditions: conditions(), }) .expect(201); const { id, status, remaining } = body<{ id: string; status: string; remaining: number; }>(created).data; expect(status).toBe('queued'); expect(remaining).toBe(2); const done = await waitFor(me, id); expect(done.status).toBe('succeeded'); expect(done.remaining).toBe(1); expect(done.progress.found).toBe(6); const result = done.result!; expect(result.window).toEqual({ date, start: '13:00', end: '19:00' }); expect(result.stops.length).toBeGreaterThanOrEqual(2); expect(result.totals.stops).toBe(result.stops.length); for (const [i, stop] of result.stops.entries()) { expect(placeIds).toContain(stop.id); expect(Date.parse(stop.leave) - Date.parse(stop.arrive)).toBe( stop.minutes * 60_000, ); const next = result.stops[i + 1]; if (next) { expect(Date.parse(next.arrive)).toBe( Date.parse(stop.leave) + stop.legToNext!.minutes * 60_000, ); } } // 요청 문장의 전화번호는 가려서, 현재 위치는 약 100m 단위로 저장한다 const [row] = await ds.query< { request_text: string; counted: boolean; conditions: { area: unknown }; }[] >( `SELECT request_text, counted, conditions FROM ai_course_generation WHERE id = $1`, [id], ); expect(row.request_text).toBe('연락은 [전화번호] 로, 산책하고 카페'); expect(row.counted).toBe(true); expect(row.conditions.area).toEqual({ type: 'point', lat: 35.56, lng: 128.31, }); // 다른 기기는 볼 수 없다 const other = await get(device(), id).expect(404); expect(body(other).error?.code).toBe('RES_001'); // 평가 — 결과가 나온 생성만 const rated = await request(app.getHttpServer()) .put(`/api/course-generations/${id}/rating`) .set(DEVICE_HEADER, me) .send({ value: 'like' }) .expect(200); expect(body(rated).data.rating).toBe('like'); const wrongValue = await request(app.getHttpServer()) .put(`/api/course-generations/${id}/rating`) .set(DEVICE_HEADER, me) .send({ value: 'love' }) .expect(400); expect(body(wrongValue).error?.code).toBe('VAL_001'); }); it('만드는 중이면 AI_004, 취소하면 차감하지 않는다', async () => { const me = device(); ai.mode = 'block'; const created = await create(me).expect(201); const { id } = body<{ id: string }>(created).data; await waitFor(me, id, (g) => g.stage === 'planning'); const second = await create(me).expect(409); expect(body(second).error?.code).toBe('AI_004'); const canceled = await request(app.getHttpServer()) .post(`/api/course-generations/${id}/cancellation`) .set(DEVICE_HEADER, me) .expect(200); expect(body(canceled).data).toMatchObject({ status: 'canceled', remaining: 2, }); // 취소한 생성은 평가할 수 없다 const rate = await request(app.getHttpServer()) .put(`/api/course-generations/${id}/rating`) .set(DEVICE_HEADER, me) .send({ value: 'dislike' }) .expect(400); expect(body(rate).error?.code).toBe('VAL_001'); }); it('하루 2번을 다 쓰면 AI_001', async () => { const me = device(); for (let i = 0; i < 2; i++) { const { id } = body<{ id: string }>(await create(me).expect(201)).data; expect((await waitFor(me, id)).status).toBe('succeeded'); } const third = await create(me).expect(429); expect(body(third).error?.code).toBe('AI_001'); }); it('조건에 맞는 후보가 없으면 failed + AI_003 이고 차감하지 않는다', async () => { const me = device(); const created = await create(me, { area: { type: 'point', ...EMPTY }, }).expect(201); const done = await waitFor(me, body<{ id: string }>(created).data.id); expect(done).toMatchObject({ status: 'failed', failure: { code: 'AI_003', notices: [] }, result: null, remaining: 2, }); }); it('AI 를 쓸 수 없으면 failed + AI_002', async () => { const me = device(); ai.mode = 'unavailable'; const created = await create(me).expect(201); const done = await waitFor(me, body<{ id: string }>(created).data.id); expect(done).toMatchObject({ status: 'failed', failure: { code: 'AI_002' }, }); }); it('지난 날짜·짧은 시간·남의 이전 생성은 VAL_001', async () => { const me = device(); const past = await create(me, { date: kstDateString(new Date(), -1), }).expect(400); expect(body(past).error?.code).toBe('VAL_001'); const short = await create(me, { start: '13:00', end: '14:00' }).expect( 400, ); expect(body(short).error?.code).toBe('VAL_001'); const previous = await request(app.getHttpServer()) .post('/api/course-generations') .set(DEVICE_HEADER, me) .send({ request: '', conditions: conditions(), previousGenerationId: randomUUID(), }) .expect(400); expect(body(previous).error?.code).toBe('VAL_001'); }); });