Skip to content

[준섭] 1116(목) 개발기록

송준섭 edited this page Nov 16, 2023 · 7 revisions

목차

  • 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

Board CRUD 완성

GET /board

모든 게시글 조회 기능

RED

기능 구현 전 테스트 코드 작성 테스트 코드는 실패 할 예정(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

테스트 코드를 통과하도록 기능 구현 이제 테스트코드는 통과(GREEN)

// board.service.ts
findAllBoards() {
    return this.boardRepository.find();
}

GET /board/by-author?author=${작성자닉네임}

특정 작성자의 게시글 조회 기능

RED

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요청으로 해당 작성자의 글을 조회하고, 조회한 글이 존재하면 테스트 통과

GREEN

// 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;
}

PATCH /board/:id/like

RED

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인 게시글의 좋아요 유저 목록과 유저의 좋아요 목록에 대한 조인 테이블에 데이터가 추가되어야 함
지금은 일단 그러한 로직을 구현하기 전에 테스트 코드를 먼저 짜는데, 조인 테이블에 대한 테스트를 지금 짜기엔 무리라고 판단
그래서 나중에 엔티티 및 조인 테이블을 구현을 하고 이 테스트를 수정을 하든, 새로운 테스트를 추가하든 하기로 함

GREEN

// 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 };
}

해당 게시글의 좋아요 수를 늘려주는 기능 구현

PATCH /board/:id/unlike

RED

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 마이너스가 잘 됐는지 확인

GREEN

@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 };
}

RED

@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 };
}

PATCH /board/:id

특정 게시글 수정 기능

RED

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);
});

새로운 게시글 생성 후, 그 게시글을 수정하고 수정된 게시글이 잘 수정되었는지 확인하는 테스트

GREEN

// 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에 덮어씌워진다.

DELETE /board/:id

특정 게시글 삭제 기능

RED

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);
});
  1. 새로운 게시글 생성
  2. 생성된 게시글 삭제 -> 200 확인
  3. 삭제된 게시글 조회 -> 삭제되어 없으므로 404 확인

GREEN

@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를 사용하였다.

Auth CRUD 완성

소셜 로그인

// #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 등의 발급 문제로)
나중에 서비스 로직에서 테스트하기 좋은 부분만 유닛 테스트를 진행하기로 함

POST /auth/signup

회원가입 기능

RED

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,
    });
});
  1. 회원 가입 요청 후 응답이 201인지
  2. 응답 바디에 id가 있는지
  3. id가 숫자인지
  4. 응답 바디의 username과 nickname이 잘 들어갔는지

GREEN

// 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;
}

POST /auth/login

로그인 기능

RED

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);
});
  1. 회원 가입 후 로그인 요청 시 응답이 200인지
  2. 응답 헤더에 set-cookie가 있는지
  3. response의 헤더에 set-cookie가 있는지
  4. set-cookie에 access_token이 있는지
  5. 잘못된 비밀번호로 로그인 요청 시 응답이 401인지

GREEN

// 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은 직접 헤더에 토큰을 담아서 인증을 하는 방식, 불편함
쿠키에 담아주는 형식으로 구현을 하였다.

GET /auth/logout

로그아웃 기능

RED

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',
    );
});
  1. 로그아웃 요청 시 응답이 200인지
  2. 응답 헤더에 set-cookie가 있는지
  3. set-cookie가 accessToken을 만료시키는지

GREEN

// 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이 있는지 확인하는 기능

RED

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);
});
  1. 중복된 username 또는 nickname이 있을 때 409인지
  2. 쿼리 스트링에 username 또는 nickname이 없을 때 400인지
  3. 중복된 username 또는 nickname이 없을 때 200인지

GREEN

@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;
}

소개

규칙

학습 기록

[공통] 개발 기록

[재하] 개발 기록

[준섭] 개발 기록

회의록

스크럼 기록

팀 회고

개인 회고

멘토링 일지

Clone this wiki locally