generated from ttipo/project-base
feat(backend): 소셜 로그인·JWT 인증과 회원·파일 API 추가
앱의 기록을 서버로 옮기려면 회원과 인증이 먼저 있어야 한다. - 소셜 로그인: 앱이 SDK 로 받은 토큰을 서버가 제공자에게 직접 확인한다 (카카오·네이버는 사용자 정보 API, 구글·Apple 은 id_token JWKS 검증) - 소셜 앱 키가 생기기 전까지 쓰는 개발용 로그인(기기 키) — 운영에서는 닫힌다 - 액세스 JWT(1시간) + 리프레시 토큰(해시 저장·회전·재사용 탐지) - 전역 JwtAuthGuard — 기본으로 모든 라우트가 로그인을 요구하고 @Public() 으로만 연다 - 내 정보 조회·수정·탈퇴(모든 기록과 파일 정리), 파일 소유자 기록과 내려받기, 업로드 시 매직 바이트로 내용 형식 검사 - 컬럼은 snake_case 이름 규칙, 테스트 환경은 기동 시 마이그레이션 실행 (CI e2e 는 빈 DB) - 템플릿 예제 사용자 API(in-memory)를 실제 회원 API 로 교체하고 e2e 계약 테스트를 옮겼다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,8 +34,13 @@ interface ContractBody {
|
||||
|
||||
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({
|
||||
@@ -58,42 +63,46 @@ describe('응답 계약 (e2e)', () => {
|
||||
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')
|
||||
.get('/api/users/me')
|
||||
.set(auth())
|
||||
.expect(200);
|
||||
|
||||
expect(body(res).success).toBe(true);
|
||||
expect(body(res).data).toHaveProperty('items');
|
||||
expect(body(res).data).toHaveProperty('pageInfo');
|
||||
expect(body(res).data).toHaveProperty('id');
|
||||
});
|
||||
|
||||
it('목록 응답의 pageInfo 는 공유 계약 형태를 지킨다', async () => {
|
||||
it('로그인하지 않으면 AUTH_001 과 traceId 를 반환한다', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/users')
|
||||
.expect(200);
|
||||
.get('/api/users/me')
|
||||
.expect(401);
|
||||
|
||||
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');
|
||||
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/users/999999')
|
||||
.get('/api/files/00000000-0000-4000-8000-000000000000')
|
||||
.set(auth())
|
||||
.expect(404);
|
||||
|
||||
expect(body(res).success).toBe(false);
|
||||
@@ -103,23 +112,36 @@ describe('응답 계약 (e2e)', () => {
|
||||
|
||||
it('검증 실패는 VAL_001 이며 상세 사유를 노출하지 않는다', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/users')
|
||||
.send({ email: '잘못된형식', password: '1' })
|
||||
.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('must be an email');
|
||||
expect(JSON.stringify(body(res))).not.toContain(
|
||||
'지원하지 않는 로그인 방식',
|
||||
);
|
||||
});
|
||||
|
||||
it('DTO 에 없는 필드는 거부된다 (forbidNonWhitelisted)', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/users?정의되지않은파라미터=1')
|
||||
.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');
|
||||
|
||||
@@ -129,7 +151,7 @@ describe('응답 계약 (e2e)', () => {
|
||||
});
|
||||
|
||||
it('모든 응답에 x-request-id 헤더가 붙는다', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/api/users');
|
||||
const res = await request(app.getHttpServer()).get('/api/health');
|
||||
|
||||
expect(typeof res.headers['x-request-id']).toBe('string');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user