Files
wandergo/backend/test/course-generation.e2e-spec.ts
T
ttipo 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
feat(backend): AI 코스 생성에 Anthropic 클라이언트와 프롬프트 v1, 조건 해석 연결
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>
2026-11-11 17:16:00 +09:00

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');
});
});