7897f2de20
템플릿 점검에서 나온 항목을 한 번에 반영한다. 항목별 이유: - Node 20 → 24 LTS (Dockerfile·CI): Node 20 은 2026-04-30 에 지원이 끝나 보안 패치가 없다 - backend·frontend .dockerignore 추가: `COPY . .` 가 호스트 node_modules(Windows 바이너리)·dist· .env*(시크릿)를 이미지에 넣고 있었다 - 업로드 디렉터리 쓰기 검사(fail-fast): Linux 에서 Docker 가 root 소유로 만든 바인드 마운트에 비-root 앱이 쓰지 못해 첫 업로드에서야 SYS_001 로 터졌다. 기동 시 해결 명령을 안내한다 - DB 커넥션 풀 100 → 10 (DB_POOL_MAX): PostgreSQL 기본 max_connections(100)를 앱 하나가 다 잡았다 - Swagger 운영 기본 비활성 (SWAGGER_ENABLED): API 구조 전체가 공개 도메인에 노출됐다 - 헬스체크 운영 응답에서 오류 상세 제거: 드라이버 오류 문구에 내부 주소·DB 계정명이 담긴다. 원인은 error 로그로 남긴다 - nginx: location 의 add_header 때문에 정적 자원에서 보안 헤더가 빠지던 문제와, index.html 이 캐시돼 배포 직후 빈 화면이 날 수 있던 문제를 고친다 - vite allowedHosts: true 제거 (DEV_ALLOWED_HOSTS): DNS rebinding 보호가 꺼져 있었다 - 전역 설정을 configureApp() 으로 분리해 main.ts 와 e2e 가 공유: e2e 가 설정을 복사해 재현해 실제 설정과 어긋나도 통과할 수 있었다. PORT 도 빈 값을 방어하는 파서를 거친다 - 쓰이지 않던 WEBSOCKET_PORT 제거: 별도 포트는 같은 도메인 라우팅과 맞지 않는다 - 예제 useUser 가 메시지 대신 에러 코드를 보관하도록 수정, testing.md 표 깨짐 수정, settings.json 허용 목록에서 npm install 제거 (Windows lock 파일 규칙과 충돌) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
126 lines
4.1 KiB
TypeScript
126 lines
4.1 KiB
TypeScript
import { INestApplication } from '@nestjs/common';
|
|
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 { configureApp } from '@/app.setup';
|
|
|
|
/**
|
|
* 세 플랫폼이 공유하는 응답 계약을 실제 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.setup.ts).
|
|
// 여기서 설정을 따로 흉내 내면 둘이 어긋나도 테스트는 통과해 버린다.
|
|
// 추적 ID 는 AppModule 의 RequestIdMiddleware 가 붙인다 — 운영과 같은 경로다.
|
|
configureApp(app);
|
|
|
|
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');
|
|
});
|
|
});
|