import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import type { Response } from 'supertest'; import { App } from 'supertest/types'; import { createE2eApp, devLogin, uniqueDeviceKey } from './support/e2e-app'; /** * 세 플랫폼이 공유하는 응답 계약을 실제 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; /** 매 실행마다 새 회원으로 시작한다 */ const E2E_DEVICE_KEY = uniqueDeviceKey(); describe('응답 계약 (e2e)', () => { let app: INestApplication; let accessToken = ''; const auth = () => ({ Authorization: `Bearer ${accessToken}` }); beforeAll(async () => { // main.ts 와 동일한 전역 설정을 적용한 앱 (support/e2e-app.ts) app = await createE2eApp(); // 개발용 로그인으로 테스트 회원을 만든다 (NODE_ENV=test 에서는 열려 있다) accessToken = await devLogin(app, E2E_DEVICE_KEY); }); afterAll(async () => { // 테스트 회원과 기록을 지운다 (같은 DB 를 개발에 함께 쓰는 경우를 위해) await request(app.getHttpServer()).delete('/api/users/me').set(auth()); await app.close(); }); it('성공 응답은 { success, data } 로 감싸진다', async () => { const res = await request(app.getHttpServer()) .get('/api/users/me') .set(auth()) .expect(200); expect(body(res).success).toBe(true); expect(body(res).data).toHaveProperty('id'); }); it('목록 응답의 pageInfo 는 공유 계약 형태를 지킨다', async () => { const pet = { tint: '#FF9B6A', name: '콩이', species: '강아지', breed: '', gender: 'male', neutered: true, birthDate: '2021-09-14', weight: 3.2, weightUnit: 'kg', allergies: [], conditions: [], watchConditions: [], }; for (const name of ['콩이', '두부']) { await request(app.getHttpServer()) .post('/api/pets') .set(auth()) .send({ ...pet, name }) .expect(201); } const first = await request(app.getHttpServer()) .get('/api/pets?limit=1') .set(auth()) .expect(200); const pageInfo = body(first).data?.pageInfo; expect(body(first).data?.items).toHaveLength(1); expect(Object.keys(pageInfo ?? {}).sort()).toEqual([ 'hasNext', 'nextCursor', 'total', ]); expect(pageInfo?.hasNext).toBe(true); expect(typeof pageInfo?.nextCursor).toBe('string'); expect(pageInfo?.total).toBe(2); const last = await request(app.getHttpServer()) .get(`/api/pets?limit=1&cursor=${pageInfo?.nextCursor ?? ''}`) .set(auth()) .expect(200); expect(body(last).data?.items).toHaveLength(1); expect(body(last).data?.pageInfo?.hasNext).toBe(false); expect(body(last).data?.pageInfo?.nextCursor).toBeNull(); }); it('정의되지 않은 쿼리 파라미터는 거부된다 (forbidNonWhitelisted)', async () => { const res = await request(app.getHttpServer()) .get('/api/pets?정의되지않은파라미터=1') .set(auth()) .expect(400); expect(body(res).error?.code).toBe('VAL_001'); }); it('로그인하지 않으면 AUTH_001 과 traceId 를 반환한다', async () => { const res = await request(app.getHttpServer()) .get('/api/users/me') .expect(401); expect(body(res).success).toBe(false); expect(body(res).error?.code).toBe('AUTH_001'); expect(typeof body(res).error?.traceId).toBe('string'); }); it('없는 리소스는 RES_001 과 traceId 를 반환한다', async () => { const res = await request(app.getHttpServer()) .get('/api/files/00000000-0000-4000-8000-000000000000') .set(auth()) .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/auth/dev-sessions') .send({ provider: '없는제공자', deviceKey: '짧음' }) .expect(400); expect(body(res).error?.code).toBe('VAL_001'); // class-validator 의 개별 메시지가 그대로 새어나가면 안 된다 expect(JSON.stringify(body(res))).not.toContain( '지원하지 않는 로그인 방식', ); }); it('DTO 에 없는 필드는 거부된다 (forbidNonWhitelisted)', async () => { const res = await request(app.getHttpServer()) .patch('/api/users/me') .set(auth()) .send({ nickname: '집사', 정의되지않은필드: 1 }) .expect(400); expect(body(res).error?.code).toBe('VAL_001'); }); it('204 응답은 본문 없이 내려간다 (@SkipTransform)', async () => { const res = await request(app.getHttpServer()) .post('/api/auth/token-revocation') .send({ refreshToken: 'already-gone-token' }) .expect(204); expect(res.text).toBe(''); }); 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/health'); expect(typeof res.headers['x-request-id']).toBe('string'); }); });