bf102d758f
운영에서 조용히 문제를 일으킬 수 있는 지점들을 정리한다. - env 검증(fail-fast): 설정이 잘못되면 서버가 뜨지 않는다. 운영에서 DB_* 누락과 'change-me' 같은 예시 값 배포를 차단하고, 문제를 하나씩이 아니라 한 번에 모아 보여준다. DB_PASSWORD 가 없을 때 'password' 로 조용히 동작하던 폴백도 제거. - graceful shutdown: SIGTERM 시 요청·커넥션을 정리한 뒤 종료한다. 종료 로그는 OnApplicationShutdown 훅으로 남긴다 — process.on(signal) 로 직접 잡으면 Nest 의 종료 흐름에 간섭해 종료 코드가 달라진다. - 헬스체크 두 가지 결함 수정: pingCheck 기본 타임아웃 1초가 짧아 유휴 커넥션 재연결 시 정상 DB 를 down 으로 오판했고, 실패 응답이 SYS_001 로 덮여 어느 구성요소가 죽었는지 알 수 없었다. 모니터링이 읽는 엔드포인트이므로 표준 래퍼·에러 코드에서 제외한다. - Rate limiting 을 @nestjs/throttler 로 일원화 (express-rate-limit 제거). 두 리미터가 겹쳐 헤더의 한도와 실제 동작이 어긋나 있었다. - e2e 를 응답 계약 검증으로 교체. 기존 테스트는 없는 경로를 검사해 이미 실패 상태였다. - 로깅 규칙(logging.md) 신설 — 레벨 기준·민감정보 마스킹·traceId Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
137 lines
4.5 KiB
TypeScript
137 lines
4.5 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 { 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';
|
|
|
|
/**
|
|
* 세 플랫폼이 공유하는 응답 계약을 실제 HTTP 요청으로 검증한다.
|
|
*
|
|
* ⚠️ DB 연결이 필요하다 (AppModule 이 TypeORM 을 초기화한다).
|
|
* 로컬: docker 개발 스택을 띄운 뒤 실행. CI: postgres 서비스 컨테이너 사용.
|
|
*/
|
|
/** supertest 의 body 는 any 이므로 계약 형태로 좁혀서 읽는다 */
|
|
interface ContractBody {
|
|
success?: boolean;
|
|
data?: {
|
|
items?: unknown[];
|
|
pageInfo?: {
|
|
hasNext: boolean;
|
|
nextCursor: string | null;
|
|
total: number | null;
|
|
};
|
|
};
|
|
error?: { code?: string; message?: string; traceId?: string };
|
|
status?: string;
|
|
details?: unknown;
|
|
}
|
|
|
|
const body = (res: Response): ContractBody => res.body as ContractBody;
|
|
|
|
describe('응답 계약 (e2e)', () => {
|
|
let app: INestApplication<App>;
|
|
|
|
beforeAll(async () => {
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
}).compile();
|
|
|
|
app = moduleFixture.createNestApplication();
|
|
|
|
// main.ts 와 동일한 전역 설정을 적용해야 실제 응답과 같아진다
|
|
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();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
it('성공 응답은 { success, data } 로 감싸진다', async () => {
|
|
const res = await request(app.getHttpServer())
|
|
.get('/api/users')
|
|
.expect(200);
|
|
|
|
expect(body(res).success).toBe(true);
|
|
expect(body(res).data).toHaveProperty('items');
|
|
expect(body(res).data).toHaveProperty('pageInfo');
|
|
});
|
|
|
|
it('목록 응답의 pageInfo 는 공유 계약 형태를 지킨다', async () => {
|
|
const res = await request(app.getHttpServer())
|
|
.get('/api/users')
|
|
.expect(200);
|
|
|
|
const pageInfo = body(res).data?.pageInfo;
|
|
|
|
expect(Object.keys(pageInfo ?? {}).sort()).toEqual([
|
|
'hasNext',
|
|
'nextCursor',
|
|
'total',
|
|
]);
|
|
expect(typeof pageInfo?.hasNext).toBe('boolean');
|
|
expect(pageInfo?.nextCursor).toBeNull();
|
|
expect(typeof pageInfo?.total).toBe('number');
|
|
});
|
|
|
|
it('없는 리소스는 RES_001 과 traceId 를 반환한다', async () => {
|
|
const res = await request(app.getHttpServer())
|
|
.get('/api/users/999999')
|
|
.expect(404);
|
|
|
|
expect(body(res).success).toBe(false);
|
|
expect(body(res).error?.code).toBe('RES_001');
|
|
expect(typeof body(res).error?.traceId).toBe('string');
|
|
});
|
|
|
|
it('검증 실패는 VAL_001 이며 상세 사유를 노출하지 않는다', async () => {
|
|
const res = await request(app.getHttpServer())
|
|
.post('/api/users')
|
|
.send({ email: '잘못된형식', password: '1' })
|
|
.expect(400);
|
|
|
|
expect(body(res).error?.code).toBe('VAL_001');
|
|
// class-validator 의 개별 메시지가 그대로 새어나가면 안 된다
|
|
expect(JSON.stringify(body(res))).not.toContain('must be an email');
|
|
});
|
|
|
|
it('DTO 에 없는 필드는 거부된다 (forbidNonWhitelisted)', async () => {
|
|
const res = await request(app.getHttpServer())
|
|
.get('/api/users?정의되지않은파라미터=1')
|
|
.expect(400);
|
|
|
|
expect(body(res).error?.code).toBe('VAL_001');
|
|
});
|
|
|
|
it('헬스체크는 표준 래퍼 없이 terminus 원본 형식을 반환한다', async () => {
|
|
const res = await request(app.getHttpServer()).get('/api/health');
|
|
|
|
expect(body(res)).not.toHaveProperty('success');
|
|
expect(body(res)).toHaveProperty('status');
|
|
expect(body(res)).toHaveProperty('details');
|
|
});
|
|
|
|
it('모든 응답에 x-request-id 헤더가 붙는다', async () => {
|
|
const res = await request(app.getHttpServer()).get('/api/users');
|
|
|
|
expect(typeof res.headers['x-request-id']).toBe('string');
|
|
});
|
|
});
|