-
Notifications
You must be signed in to change notification settings - Fork 2
[준섭] 1116(목) 개발기록
- Board CRUD 테스트 코드 작성 및 API 완성
- GET /board
- GET /board/by-author?author=${작성자닉네임}
- PATCH /board/:id/like
- PATCH /board/:id/unlike
- PATCH /board/:id
- DELETE /board/:id
- Auth CRUD 테스트 코드 작성 및 API 완성
- 소셜 로그인
- POST /auth/signup
모든 게시글 조회 기능
기능 구현 전 테스트 코드 작성 테스트 코드는 실패 할 예정(RED)
it('GET /board', async () => {
const response = await request(app.getHttpServer())
.get('/board')
.expect(200);
expect(response).toHaveProperty('body');
expect(response.body).toBeInstanceOf(Array);
const boards: Board[] = response.body as Board[];
if (!boards.length) {
return;
}
expect(boards[0]).toHaveProperty('id');
expect(boards[0]).toHaveProperty('title');
expect(boards[0]).toHaveProperty('content');
expect(boards[0]).toHaveProperty('author');
expect(boards[0]).toHaveProperty('created_at');
expect(boards[0]).toHaveProperty('updated_at');
});
테스트 코드를 통과하도록 기능 구현 이제 테스트코드는 통과(GREEN)
// board.service.ts
findAllBoards() {
return this.boardRepository.find();
}
특정 작성자의 게시글 조회 기능
it('GET /board/by-author', async () => {
const author: string = 'testUser';
const board = {
title: 'test',
content: 'test',
author,
};
await request(app.getHttpServer())
.post('/board')
.send(board);
const response = await request(app.getHttpServer())
.get(`/board/by-author?author=${author}`)
.expect(200);
expect(response).toHaveProperty('body');
expect(response.body).toBeInstanceOf(Array);
const boards: Board[] = response.body as Board[];
if (!boards.length) {
return;
}
expect(boards[0]).toHaveProperty('id');
expect(boards[0]).toHaveProperty('title');
expect(boards[0]).toHaveProperty('content');
expect(boards[0]).toHaveProperty('author');
expect(boards[0]).toHaveProperty('created_at');
expect(boards[0]).toHaveProperty('updated_at');
});
위에서 author를 테스트 하려면 일단 검증하고자 하는 author가 데이터베이스에 존재해야 함
그래서 어떻게 할까 하다가 테스트 코드에서 POST요청으로 먼저 예시 작성자에 대한 글 데이터를 넣어주기로 함
그리고 GET요청으로 해당 작성자의 글을 조회하고, 조회한 글이 존재하면 테스트 통과
// board.controller.ts
@Get('by-author')
findBoardByAuthor(@Query('author') author: string) {
return this.boardService.findBoardByAuthor(author);
}
// board.service.ts
async findBoardByAuthor(author: string) {
const found: Board[] = await this.boardRepository.findBy({ author });
if (!found.length) {
throw new NotFoundException(`Not found board with author: ${author}`);
}
return found;
}
it('PATCH /board/:id/like', async () => {
const board = {
title: 'test',
content: 'test',
author: 'test',
};
const newBoard = (
await request(app.getHttpServer()).post('/board').send(board)
).body;
const afterLikeCount = (
await request(app.getHttpServer())
.patch(`/board/${newBoard.id}/like`)
.expect(200)
).body.like_cnt;
expect(afterLikeCount).toBe(newBoard.like_cnt + 1);
});
위에 작성한 테스트 코드는 일단 새로운 게시글을 생성하고, 그 게시글에 대하여 좋아요 요청이 왔을 때 그 글의 좋아요 수값이 1 늘었는지 확인하는 테스트
하면서 문득 든 생각은 like라는 함수가 생각보다 조금 복잡하다는 것
like를 누가 누른다면 게시글의 like count가 증가하는 것도 있지만 ManyToMany인 게시글의 좋아요 유저 목록과 유저의 좋아요 목록에 대한 조인 테이블에 데이터가 추가되어야 함
지금은 일단 그러한 로직을 구현하기 전에 테스트 코드를 먼저 짜는데, 조인 테이블에 대한 테스트를 지금 짜기엔 무리라고 판단
그래서 나중에 엔티티 및 조인 테이블을 구현을 하고 이 테스트를 수정을 하든, 새로운 테스트를 추가하든 하기로 함
// board.controller.ts
@Patch(':id/like')
patchLike(@Param('id') id: string) {
return this.boardService.patchLike(+id);
}
// board.service.ts
async patchLike(id: number) {
const board = await this.boardRepository.findOneBy({ id });
if (!board) {
throw new NotFoundException(`Not found board with id: ${id}`);
}
board.like_cnt += 1;
await this.boardRepository.save(board);
return { like_cnt: board.like_cnt };
}
해당 게시글의 좋아요 수를 늘려주는 기능 구현
it.todo('PATCH /board/:id/unlike', async () => {
const board = {
title: 'test',
content: 'test',
author: 'test',
};
const newBoard = (
await request(app.getHttpServer()).post('/board').send(board)
).body;
const afterUnlikeCount = (
await request(app.getHttpServer())
.patch(`/board/${newBoard.id}/unlike`)
.expect(200)
).body.like_cnt;
expect(afterUnlikeCount).toBe(newBoard.like_cnt - 1);
});
위의 로직과 비슷하게 1 마이너스가 잘 됐는지 확인
@Patch(':id/unlike')
patchUnlike(@Param('id') id: string) {
return this.boardService.patchUnlike(+id);
}
async patchUnlike(number: number) {
const board = await this.boardRepository.findOneBy({ id: number });
if (!board) {
throw new NotFoundException(`Not found board with id: ${number}`);
}
board.like_cnt -= 1;
await this.boardRepository.save(board);
return { like_cnt: board.like_cnt };
}
@Patch(':id/unlike')
patchUnlike(@Param('id') id: string): Promise<Partial<Board>> {
return this.boardService.patchUnlike(+id);
}
async patchUnlike(number: number): Promise<Partial<Board>> {
const board = await this.boardRepository.findOneBy({ id: number });
if (!board) {
throw new NotFoundException(`Not found board with id: ${number}`);
}
board.like_cnt -= 1;
await this.boardRepository.save(board);
return { like_cnt: board.like_cnt };
}
특정 게시글 수정 기능
it('PATCH /board/:id', async () => {
const board = {
title: 'test',
content: 'test',
author: 'test',
};
const newBoard = (
await request(app.getHttpServer()).post('/board').send(board)
).body;
const toUpdate = {
title: 'updateTest',
content: 'updateTest',
};
const response = await request(app.getHttpServer())
.patch(`/board/${newBoard.id}`)
.send(toUpdate)
.expect(200);
expect(response).toHaveProperty('body');
const updatedBoard = response.body;
expect(updatedBoard).toHaveProperty('id');
expect(updatedBoard).toHaveProperty('title');
expect(updatedBoard.title).toBe(toUpdate.title);
expect(updatedBoard).toHaveProperty('content');
expect(updatedBoard.content).toBe(toUpdate.content);
});
새로운 게시글 생성 후, 그 게시글을 수정하고 수정된 게시글이 잘 수정되었는지 확인하는 테스트
// board.controller.ts
@Patch(':id')
updateBoard(@Param('id') id: string, @Body() updateBoardDto: UpdateBoardDto) {
return this.boardService.updateBoard(+id, updateBoardDto);
}
// board.service.ts
async updateBoard(id: number, updateBoardDto: UpdateBoardDto) {
const oldBoard = await this.boardRepository.findOneBy({ id });
if (!oldBoard) {
throw new NotFoundException(`Not found board with id: ${id}`);
}
const updated = await this.boardRepository.save({
...oldBoard,
...updateBoardDto,
});
return updated;
}
아래와 같이 객체의 속성을 병합할 수 있어 이 방법을 사용하였다.
{
...oldBoard,
...updateBoardDto,
}
이 방법을 사용하면 updateBoardDto에 있는(수정된) 내용들이 oldBoard에 덮어씌워진다.
특정 게시글 삭제 기능
it('DELETE /board/:id', async () => {
const board: CreateBoardDto = {
title: 'test',
content: 'test',
author: 'test',
};
const newBoard = (
await request(app.getHttpServer()).post('/board').send(board)
).body;
await request(app.getHttpServer())
.delete(`/board/${newBoard.id}`)
.expect(200);
await request(app.getHttpServer())
.get(`/board/${newBoard.id}`)
.expect(404);
});
- 새로운 게시글 생성
- 생성된 게시글 삭제 -> 200 확인
- 삭제된 게시글 조회 -> 삭제되어 없으므로 404 확인
@Delete(':id')
deleteBoard(@Param('id') id: string) {
return this.boardService.deleteBoard(+id);
}
async deleteBoard(id: number) {
await this.boardRepository.delete({ id });
}
remove vs delete
-> remove는 delete와 달리 삭제된 엔티티를 반환한다.
-> 만약 삭제할 리소스가 없다면 remove는 에러를 반환하지만 delete는 에러를 반환하지 않는다.
없는 리소스에 대한 삭제 요청이 여러 번 들어와도 삭제하면 상관 없다고 판단하여 delete를 사용하였다.
// #20 [03-02] 사용자가 정보제공을 허용하여 콜백 API 요청을 받으면, 백엔드 서버는 요청에 포함된 코드를 통해 해당 서비스의 인가 서버에 액세스 토큰을 요청한다.
// #21 [03-03] 액세스 토큰을 전달받으면, 백엔드 서버는 액세스 토큰을 통해 해당 서비스의 리소스 서버에 사용자 정보를 요청한다.
// #22 [03-04] 사용자 정보를 전달받으면, 필요한 속성만 추출하여 회원 정보를 데이터베이스에 저장한다.
it.todo('GET /auth/oauth/:service'); // 서비스 로그인 페이지로 리다이렉트. 쿼리 스트링으로 client_id, scope를 담아서 보냄
// 그러면 회원이 해당 서비스 사이트에 로그인을 하고 정보 공유를 허락함
it.todo('GET /auth/oauth/:service/callback'); // 리소스 서버에서 여기로 AuthorizedCode를 줌 그럼 이걸 받아서 우리는 액세스 토큰을 받음
// 그 후 받은 토큰을 이용해서 또 요청을 보내 사용자 정보를 받아옴
// 그 사용자 정보가 데이터베이스에 저장이 되어있다 -> 바로 로그인 시켜
// 만약 데이터베이스에 없다 -> 첫 로그인이시네요. 회원가입 하시겠습니까? 해서 닉네임 요청을 클라이언트에 보내고
// 클라잉언트가 닉네임 정보 보내주면 저장 후 로그인을 시키기
소셜 로그인 부분은 e2e 테스트가 어려워 과감하게 포기(client_id, AuthorizedCode 등의 발급 문제로)
나중에 서비스 로직에서 테스트하기 좋은 부분만 유닛 테스트를 진행하기로 함
회원가입 기능
it('POST /auth/signup', async () => {
const randomStr = Math.random().toString(36).slice(2, 10);
const newUser: SignupUserDto = {
username: randomStr,
password: randomStr,
nickname: randomStr,
};
const response = await request(app.getHttpServer())
.post('/auth/signup')
.send(newUser)
.expect(201);
expect(response).toHaveProperty('body');
const createUser = response.body;
expect(createUser).toHaveProperty('id');
expect(createUser).toBeInstanceOf(Number);
expect(typeof createUser.id).toBe('number');
expect(response.body).toMatchObject({
username: newUser.username,
nickname: newUser.nickname,
});
});
- 회원 가입 요청 후 응답이 201인지
- 응답 바디에 id가 있는지
- id가 숫자인지
- 응답 바디의 username과 nickname이 잘 들어갔는지
// auth.controller.ts
@Post('signup')
signUp(@Body() createeUserDto: SignupUserDto) {
return this.authService.signUp(createeUserDto);
}
$ yarn workspace server add bcrypt
// auth.service.ts
import * as bcrypt from 'bcrypt';
// 생략 ..
async signUp(createUserDto: SignupUserDto) {
const salt = await bcrypt.genSalt();
const hashedPassword = await bcrypt.hash(createUserDto.password, salt);
createUserDto.password = hashedPassword;
const user: User = this.userRepository.create({
...createUserDto,
});
const createdUser: User = await this.userRepository.save(user);
createdUser.password = undefined;
return createdUser;
}
로그인 기능
it('POST /auth/signin', async () => {
const randomStr = Math.random().toString(36).slice(2, 10);
const newUser: SignUpUserDto = {
username: randomStr,
password: randomStr,
nickname: randomStr,
};
await request(app.getHttpServer())
.post('/auth/signup')
.send(newUser)
.expect(201);
const response = await request(app.getHttpServer())
.post('/auth/signin')
.send({
username: newUser.username,
password: newUser.password,
})
.expect(200);
expect(response.headers['set-cookie']).toBeDefined();
const cookies = response.headers['set-cookie'];
expect(cookies[0]).toContain('accessToken');
await request(app.getHttpServer())
.post('/auth/signin')
.send({
username: newUser.username,
password: 'wrong password',
})
.expect(401);
});
- 회원 가입 후 로그인 요청 시 응답이 200인지
- 응답 헤더에 set-cookie가 있는지
- response의 헤더에 set-cookie가 있는지
- set-cookie에 access_token이 있는지
- 잘못된 비밀번호로 로그인 요청 시 응답이 401인지
// auth.controller.ts
@Post('signin') // NestJs에서는 기본적으로 Post 요청에 대해 201을 반환하도록 되어있음
@HttpCode(200) // 200을 반환하도록 하기 위해 HttpCode 데코레이터를 사용
async signIn(
@Body() authCredentialsDto: SignInUserDto,
@Res({ passthrough: true }) res: Response,
): Promise<void> {
const result = await this.authService.signIn(authCredentialsDto);
res.cookie('accessToken', result.token, { httpOnly: true, path: '/' });
}
// auth.service.ts
async signIn(signInUserDto: SignInUserDto): Promise<{ token: string }> {
const { username, password } = signInUserDto;
const user = await this.userRepository.findOneBy({ username });
if (user && (await bcrypt.compare(password, user.password))) {
const payload = { username };
const token = this.jwtService.sign(payload);
return { token };
} else {
throw new UnauthorizedException('login failed');
}
}
Set-Cookie vs Authorization
-> Set-Cookie는 쿠키를 통해 인증을 하는 방식, 쿠키에 담아놓기만 하면 그 이후엔 자동으로 요청에 담아서 보내줌, 편리함
-> Authorization은 직접 헤더에 토큰을 담아서 인증을 하는 방식, 불편함
쿠키에 담아주는 형식으로 구현을 하였다.
로그아웃 기능
it('GET /auth/signout', async () => {
const response = await request(app.getHttpServer())
.get('/auth/signout')
.expect(200);
expect(response.headers['set-cookie']).toBeDefined();
const cookie = response.headers['set-cookie'][0];
expect(cookie).toBe(
'accessToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly',
);
});
- 로그아웃 요청 시 응답이 200인지
- 응답 헤더에 set-cookie가 있는지
- set-cookie가 accessToken을 만료시키는지
// auth.controller.ts
signOut(@Res({ passthrough: true }) res: Response): void {
res.clearCookie('accessToken', {
path: '/',
httpOnly: true,
});
}
GET /auth/is-available-username?username=${username}, GET /auth/is-available-nickname?nickname=${nickname}
중복된 username, nickname이 있는지 확인하는 기능
it('GET /auth/is-available-username', async () => {
const randomStr = Math.random().toString(36).slice(2, 10);
const newUser: SignUpUserDto = {
username: randomStr,
password: randomStr,
nickname: randomStr,
};
await request(app.getHttpServer()).post('/auth/signup').send(newUser);
await request(app.getHttpServer())
.get(`/auth/is-available-username?username=${newUser.username}`)
.expect(409);
await request(app.getHttpServer())
.get(`/auth/is-available-username`)
.expect(400);
await request(app.getHttpServer())
.get(`/auth/is-available-username?username=${randomStr + '1'}`)
.expect(200);
});
it('GET /auth/is-available-nickname', async () => {
const randomStr = Math.random().toString(36).slice(2, 10);
const newUser: SignUpUserDto = {
username: randomStr,
password: randomStr,
nickname: randomStr,
};
await request(app.getHttpServer()).post('/auth/signup').send(newUser);
await request(app.getHttpServer())
.get(`/auth/is-available-nickname?nickname=${newUser.nickname}`)
.expect(409);
await request(app.getHttpServer())
.get(`/auth/is-available-nickname`)
.expect(400);
await request(app.getHttpServer())
.get(`/auth/is-available-nickname?nickname=${randomStr + '1'}`)
.expect(200);
});
- 중복된 username 또는 nickname이 있을 때 409인지
- 쿼리 스트링에 username 또는 nickname이 없을 때 400인지
- 중복된 username 또는 nickname이 없을 때 200인지
@Get('is-available-username')
isAvailableUsername(@Query('username') username: string) {
return this.authService.isAvailableUsername(username);
}
@Get('is-available-nickname')
isAvailableNickname(@Query('nickname') nickname: string) {
return this.authService.isAvailableNickname(nickname);
}
async isAvailableUsername(username: string) {
if (!username) {
throw new BadRequestException('username is required');
}
const user = await this.userRepository.findOneBy({ username });
if (user) {
throw new ConflictException('username is already exists');
}
return true;
}
async isAvailableNickname(nickname: string) {
if (!nickname) {
throw new BadRequestException('nickname is required');
}
const user = await this.userRepository.findOneBy({ nickname });
if (user) {
throw new ConflictException('nickname is already exists');
}
return true;
}
© 2023 debussysanjang
- 🐙 [가은] Three.js와의 설레는 첫만남
- 🐙 [가은] JS로 자전과 공전을 구현할 수 있다고?
- ⚽️ [준섭] NestJS 강의 정리본
- 🐧 [동민] R3F Material 간단 정리
- 👾 [재하] 만들면서 배우는 NestJS 기초
- 👾 [재하] GitHub Actions을 이용한 자동 배포
- ⚽️ [준섭] 테스트 코드 작성 이유
- ⚽️ [준섭] TypeScript의 type? interface?
- 🐙 [가은] 우리 팀이 Zustand를 쓰는 이유
- 👾 [재하] NestJS, TDD로 개발하기
- 👾 [재하] AWS와 NCP의 주요 서비스
- 🐰 [백범] Emotion 선택시 고려사항
- 🐧 [동민] Yarn berry로 모노레포 구성하기
- 🐧 [동민] Vite, 왜 쓰는거지?
- ⚽️ [준섭] 동시성 제어
- 👾 [재하] NestJS에 Swagger 적용하기
- 🐙 [가은] 너와의 추억을 우주의 별로 띄울게
- 🐧 [동민] React로 멋진 3D 은하 만들기(feat. R3F)
- ⚽️ [준섭] NGINX 설정
- 👾 [재하] Transaction (트랜잭션)
- 👾 [재하] SSH 보안: Key Forwarding, Tunneling, 포트 변경
- ⚽️ [준섭] MySQL의 검색 - LIKE, FULLTEXT SEARCH(전문검색)
- 👾 [재하] Kubernetes 기초(minikube), docker image 최적화(멀티스테이징)
- 👾 [재하] NestJS, 유닛 테스트 각종 mocking, e2e 테스트 폼데이터 및 파일첨부
- 2주차(화) - git, monorepo, yarn berry, TDD
- 2주차(수) - TDD, e2e 테스트
- 2주차(목) - git merge, TDD
- 2주차(일) - NCP 배포환경 구성, MySQL, nginx, docker, docker-compose
- 3주차(화) - Redis, Multer 파일 업로드, Validation
- 3주차(수) - AES 암복호화, TypeORM Entity Relation
- 3주차(목) - NCP Object Storage, HTTPS, GitHub Actions
- 3주차(토) - Sharp(이미지 최적화)
- 3주차(일) - MongoDB
- 4주차(화) - 플랫폼 종속성 문제 해결(Sharp), 쿼리 최적화
- 4주차(수) - 코드 개선, 트랜잭션 제어
- 4주차(목) - 트랜잭션 제어
- 4주차(일) - docker 이미지 최적화
- 5주차(화) - 어드민 페이지(전체 글, 시스템 정보)
- 5주차(목) - 감정분석 API, e2e 테스트
- 5주차(토) - 유닛 테스트(+ mocking), e2e 테스트(+ 파일 첨부)
- 6주차(화) - ERD
- 2주차(화) - auth, board 모듈 생성 및 테스트 코드 환경 설정
- 2주차(목) - Board, Auth 테스트 코드 작성 및 API 완성
- 3주차(월) - Redis 연결 후 RedisRepository 작성
- 3주차(화) - SignUpUserDto에 ClassValidator 적용
- 3주차(화) - SignIn시 RefreshToken 발급 및 Redis에 저장
- 3주차(화) - 커스텀 AuthGuard 작성
- 3주차(수) - SignOut시 토큰 제거
- 3주차(수) - 깃헙 로그인 구현
- 3주차(토) - OAuth 코드 통합 및 재사용
- 4주차(수) - NestJS + TypeORM으로 MySQL 전문검색 구현
- 4주차(목) - NestJS Interceptor와 로거
- [전체] 10/12(목)
- [전체] 10/15(일)
- [전체] 10/30(월)
- [FE] 11/01(수)~11/03(금)
- [전체] 11/06(월)
- [전체] 11/07(화)
- [전체] 11/09(목)
- [전체] 11/11(토)
- [전체] 11/13(월)
- [BE] 11/14(화)
- [BE] 11/15(수)
- [FE] 11/16(목)
- [FE] 11/19(일)
- [BE] 11/19(일)
- [FE] 11/20(월)
- [BE] 11/20(월)
- [BE] 11/27(월)
- [FE] 12/04(월)
- [BE] 12/04(월)
- [FE] 12/09(금)
- [전체] 12/10(일)
- [FE] 12/11(월)
- [전체] 12/11(월)
- [전체] 12/12(화)