generated from ttipo/project-base
688e72bf04
다이어리를 가족·지인과 함께 쓸 수 있게 한다. - diary_members 로 함께 쓰는 사람을 두고(주인 포함 최대 4명), 알림 설정을 사람별로 옮긴다 - 초대 코드(RUP-6자) 미리보기·참여, 개인↔공유 전환, 내보내기·나가기 — 내보내거나 나가면 코드를 바꾼다 - 목록·쓰기는 멤버, 수정·삭제는 쓴 사람, 다이어리 정보·삭제는 주인만 - 응답에 멤버·대상 요약·작성자를 담는다 - 파일 열람 정책 — 함께 쓰는 다이어리의 표지·기록 사진·아이 사진·멤버 프로필 사진은 멤버도 받을 수 있다 - 에러 코드 DIARY_003(코드 없음)·004(가득 참)·005(이미 참여) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
188 lines
6.1 KiB
TypeScript
188 lines
6.1 KiB
TypeScript
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<App>;
|
|
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');
|
|
});
|
|
});
|