Files
rup/backend/test/app.e2e-spec.ts
T
ttipo 720b2cd858 feat(backend): 반려동물 API — 등록·수정·삭제·순서·추억 보관함
앱의 우리 아이 기록(PetRepository)을 서버로 옮긴다.

- GET/POST /pets, PATCH/DELETE /pets/:id, PUT /pets/order, PUT/DELETE /pets/:id/archive
- 목록은 정한 순서(sort_order) 기준 커서 페이지네이션 — 공통 keysetPaginate 로 뽑았다
- 아이를 지우거나 보관할 때 일정·다이어리·명함이 따라 정리할 수 있게 PetLifecycleListener 를 두었다
  (반려동물 모듈은 다른 도메인을 모르고, 정리는 같은 트랜잭션에서 한다)
- 사진을 바꾸면 쓰지 않는 파일만 지운다 — 다른 기록이 참조하면 FK 가 막는다
- 목록 응답 Swagger 를 실제 { items, pageInfo } 모양으로 그리는 ApiPaginatedResponse 추가
- e2e 에 목록 pageInfo 계약·미정의 쿼리 거부 검사를 되살렸다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:16:38 +09:00

217 lines
7.2 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;
/** 매 실행마다 새 회원으로 시작한다 */
const E2E_DEVICE_KEY = `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
describe('응답 계약 (e2e)', () => {
let app: INestApplication<App>;
let accessToken = '';
const auth = () => ({ Authorization: `Bearer ${accessToken}` });
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();
// 개발용 로그인으로 테스트 회원을 만든다 (NODE_ENV=test 에서는 열려 있다)
const login = await request(app.getHttpServer())
.post('/api/auth/dev-sessions')
.send({ provider: 'kakao', deviceKey: E2E_DEVICE_KEY })
.expect(201);
accessToken = (login.body as { data: { accessToken: string } }).data
.accessToken;
});
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');
});
});