generated from ttipo/project-base
4dc5be72a4
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m37s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 11m24s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m57s
CI / Backend (lint · test · e2e · build) (push) Successful in 12m33s
CI / Frontend (lint · type-check · test) (push) Successful in 11m12s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m18s
4단계까지 서버는 가짜 AI 로 코스를 만들었다. 실제 모델(기본 Haiku 5.5)을 붙여 문장에서 조건을 읽고 후보 중 코스를 고르게 한다(설계 §14 5단계). - 구조화 출력(JSON 스키마)으로 받고, 같은 시스템 프롬프트는 캐시에 올린다 - 조건 해석은 AI 가 값만 뽑고 기본값과 되물을 것은 코드가 정한다(unknown_place 추가) - 조건 해석 비용과 응답 뒤 실패한 호출 비용도 하루 비용 상한에 넣는다 - AI_PROVIDER(fake 로 되돌림) · effort 설정, 모델은 가격표에 있어야 기동한다 - 키로 실제 호출을 확인하는 ai:check 명령 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
551 lines
18 KiB
TypeScript
551 lines
18 KiB
TypeScript
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 {
|
|
type ConditionExtraction,
|
|
emptyExtraction,
|
|
} from '@/modules/course-generation/condition-extraction';
|
|
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' | 'refused';
|
|
|
|
/** 응답을 받은 뒤 실패한 호출의 사용량(거절·형식 오류) */
|
|
const REFUSED_USAGE = {
|
|
model: 'claude-haiku-5-5',
|
|
promptVersion: 'course-v1',
|
|
inputTokens: 6_000,
|
|
outputTokens: 300,
|
|
cacheReadTokens: 0,
|
|
cacheWriteTokens: 0,
|
|
costUsd: 0.00075,
|
|
};
|
|
|
|
/**
|
|
* 가짜 AI 를 감싸 시험마다 막아 두거나(block) 쓸 수 없게(unavailable) 하거나 응답을 받은 뒤 실패하게(refused) 한다.
|
|
* 조건 해석은 extraction 을 넣으면 그 값을 뽑은 것처럼 돌려준다.
|
|
*/
|
|
class ControllableAi implements CourseAiClient {
|
|
mode: Mode = 'normal';
|
|
extraction: ConditionExtraction | null = null;
|
|
private readonly fake = new FakeCourseAiClient();
|
|
|
|
draftCourse(input: CourseDraftInput) {
|
|
if (this.mode === 'unavailable') {
|
|
return Promise.reject(new AiUnavailableError('시험: AI 장애'));
|
|
}
|
|
if (this.mode === 'refused') {
|
|
return Promise.reject(
|
|
new AiUnavailableError('시험: 거절', REFUSED_USAGE),
|
|
);
|
|
}
|
|
if (this.mode === 'block') {
|
|
return new Promise<never>((_, reject) => {
|
|
input.signal.addEventListener('abort', () =>
|
|
reject(new Error('시험: 끊김')),
|
|
);
|
|
});
|
|
}
|
|
return this.fake.draftCourse(input);
|
|
}
|
|
|
|
async parseConditions(
|
|
input: Parameters<CourseAiClient['parseConditions']>[0],
|
|
) {
|
|
const out = await this.fake.parseConditions(input);
|
|
return this.extraction ? { ...out, extraction: this.extraction } : out;
|
|
}
|
|
}
|
|
|
|
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();
|
|
// 운영처럼 마이그레이션을 기동 훅보다 먼저 돌린다 — 빈 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';
|
|
ai.extraction = null;
|
|
});
|
|
|
|
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('조건 해석 — 말한 장소를 기준 장소로 찾고, 못 찾은 장소·대구 밖·짧은 시간은 issues 로 알린다', async () => {
|
|
const parse = (extraction: Partial<ConditionExtraction>) => {
|
|
ai.extraction = { ...emptyExtraction(), ...extraction };
|
|
return request(app.getHttpServer())
|
|
.post('/api/course-conditions')
|
|
.set(DEVICE_HEADER, device())
|
|
.send({ text: '시험 문장', baseArea: { type: 'point', ...C } })
|
|
.expect(200);
|
|
};
|
|
const found = body<{
|
|
conditions: { area: unknown };
|
|
inferred: string[];
|
|
issues: string[];
|
|
}>(
|
|
await parse({
|
|
area: {
|
|
mention: 'place',
|
|
neighborhood: null,
|
|
placeName: '이투이코스 3',
|
|
},
|
|
// 오늘이면 시험 시각에 따라 지난 시간(past_date)이 될 수 있어 내일로 둔다
|
|
date,
|
|
start: '14:00',
|
|
end: '15:00',
|
|
}),
|
|
).data;
|
|
expect(found.conditions.area).toEqual({
|
|
type: 'place',
|
|
placeId: placeIds[2],
|
|
});
|
|
expect(found.inferred).toEqual(['area', 'date', 'start', 'end']);
|
|
expect(found.issues).toEqual(['too_short']);
|
|
|
|
const unknown = body<{ conditions: { area: unknown }; issues: string[] }>(
|
|
await parse({
|
|
area: {
|
|
mention: 'place',
|
|
neighborhood: null,
|
|
placeName: '이투이 없는 곳',
|
|
},
|
|
}),
|
|
).data;
|
|
expect(unknown.conditions.area).toEqual({ type: 'point', ...C });
|
|
expect(unknown.issues).toEqual(['unknown_place']);
|
|
|
|
const outside = body<{ issues: string[] }>(
|
|
await parse({
|
|
area: { mention: 'outside', neighborhood: null, placeName: '경주' },
|
|
multiDay: true,
|
|
}),
|
|
).data;
|
|
expect(outside.issues).toEqual(['unsupported_area', 'multi_day']);
|
|
});
|
|
|
|
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('AI 가 답한 뒤 실패해도 그 비용은 생성 기록에 남긴다(하루 비용 상한에 들어간다)', async () => {
|
|
const me = device();
|
|
ai.mode = 'refused';
|
|
const created = await create(me).expect(201);
|
|
const id = body<{ id: string }>(created).data.id;
|
|
const done = await waitFor(me, id);
|
|
expect(done).toMatchObject({
|
|
status: 'failed',
|
|
failure: { code: 'AI_002' },
|
|
});
|
|
const [row] = await ds.query<
|
|
{ cost_usd: string; llm_calls: number; input_tokens: number }[]
|
|
>(
|
|
`SELECT cost_usd, llm_calls, input_tokens FROM ai_course_generation WHERE id = $1`,
|
|
[id],
|
|
);
|
|
expect(Number(row.cost_usd)).toBeCloseTo(0.00075, 6);
|
|
expect(row).toMatchObject({ llm_calls: 1, input_tokens: 6_000 });
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|