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