generated from ttipo/project-base
feat(backend): AI 코스 생성 API 골격과 생성 기록 테이블 추가
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 20m59s
CI / Frontend (lint · type-check · test) (pull_request) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Has been cancelled
CI / Backend (lint · test · e2e · build) (pull_request) Has been cancelled
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 20m59s
CI / Frontend (lint · type-check · test) (pull_request) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Has been cancelled
CI / Backend (lint · test · e2e · build) (pull_request) Has been cancelled
설계 4단계 — 키 없이 생성 흐름을 끝까지 돌려 볼 수 있게 API·테이블·에러 코드를 먼저 세우고, AI 자리는 가짜 클라이언트로 채웠다(5단계에서 실제 모델로 바꾼다). - 조건 해석·생성 시작·진행 조회·취소·평가·오늘 남은 횟수 API 6개 - ai_course_generation 테이블(마이그레이션)과 에러 코드 AI_001~004 - 기기 단위 하루 횟수(결과가 나온 생성만 차감)·시도 상한·하루 비용 상한·동시 생성 막기 - 프로세스 안 실행기: 동시 5건, 작업당 45초, 취소 시 AI 호출 중단, 기동·종료 때 끝나지 못한 작업은 실패(차감 없음)로 정리 - 후보 찾기 → 거르기·줄이기 → AI 초안 → 시간표 검증·고치기 → 결과 조립 - 요청 문장의 연락처는 저장 전에 가리고, 현재 위치는 약 100m 단위로만 남긴다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
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<never>((_, reject) => {
|
||||
input.signal.addEventListener('abort', () =>
|
||||
reject(new Error('시험: 끊김')),
|
||||
);
|
||||
});
|
||||
}
|
||||
return this.fake.draftCourse(input);
|
||||
}
|
||||
|
||||
parseConditions(input: Parameters<CourseAiClient['parseConditions']>[0]) {
|
||||
return this.fake.parseConditions(input);
|
||||
}
|
||||
}
|
||||
|
||||
interface Envelope<T> {
|
||||
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 = <T>(res: Response) => res.body as Envelope<T>;
|
||||
|
||||
describe('AI 코스 생성 API (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
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<string, unknown> = {}) => ({
|
||||
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<string, unknown> = {}) =>
|
||||
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<GenerationBody> {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const res = await get(deviceId, id).expect(200);
|
||||
const generation = body<GenerationBody>(res).data;
|
||||
if (until(generation)) return generation;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`생성 ${id} 이 끝나지 않았다`);
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
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();
|
||||
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 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 () => {
|
||||
await cleanup();
|
||||
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<GenerationBody>(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<GenerationBody>(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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user