Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NDD-337]: 면접용 닉네임 반환 API Controller 테스트 (1h / 1h) #159

Merged
merged 3 commits into from
Dec 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 156 additions & 40 deletions BE/src/member/controller/member.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { Test, TestingModule } from '@nestjs/testing';
import { MemberController } from './member.controller';
import { MemberResponse } from '../dto/memberResponse';
import { Request } from 'express';
import { ManipulatedTokenNotFiltered } from 'src/token/exception/token.exception';
import {
InvalidTokenException,
ManipulatedTokenNotFiltered,
TokenExpiredException,
} from 'src/token/exception/token.exception';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AuthModule } from 'src/auth/auth.module';
Expand All @@ -18,13 +22,22 @@ import { TokenModule } from '../../token/token.module';
import { MemberModule } from '../member.module';
import { Member } from '../entity/member';
import { Token } from '../../token/entity/token';
import { createIntegrationTestModule } from '../../util/test.util';
import {
addAppModules,
createIntegrationTestModule,
} from '../../util/test.util';
import { Category } from '../../category/entity/category';
import { MemberNicknameResponse } from '../dto/memberNicknameResponse';
import { MemberRepository } from '../repository/member.repository';
import { JwtService } from '@nestjs/jwt';
import { TokenPayload } from 'src/token/interface/token.interface';

describe('MemberController', () => {
describe('MemberController 단위 테스트', () => {
let memberController: MemberController;

const mockMemberService = {};
const mockMemberService = {
getNameForInterview: jest.fn(),
};
const mockTokenService = {};

beforeEach(async () => {
Expand All @@ -44,31 +57,85 @@ describe('MemberController', () => {
expect(memberController).toBeDefined();
});

it('should return member information as MemberResponse type', async () => {
const result = memberController.getMyInfo(
mockReqWithMemberFixture as unknown as Request,
);
describe('getMyInfo', () => {
it('should return member information as MemberResponse type', async () => {
const result = memberController.getMyInfo(
mockReqWithMemberFixture as unknown as Request,
);

expect(result).toBeInstanceOf(MemberResponse);
expect(result).toEqual(MemberResponse.from(memberFixture));
expect(result['id']).toBe(1);
expect(result['email']).toBe('test@example.com');
expect(result['nickname']).toBe('TestUser');
expect(result['profileImg']).toBe('https://example.com');
});

expect(result).toBeInstanceOf(MemberResponse);
expect(result).toEqual(MemberResponse.from(memberFixture));
expect(result['id']).toBe(1);
expect(result['email']).toBe('test@example.com');
expect(result['nickname']).toBe('TestUser');
expect(result['profileImg']).toBe('https://example.com');
it('should handle invalid user in the request', async () => {
const mockUser = undefined;
const mockReq = { user: mockUser };
expect(() =>
memberController.getMyInfo(mockReq as unknown as Request),
).toThrow(ManipulatedTokenNotFiltered);
});
});

it('should handle invalid user in the request', async () => {
const mockUser = undefined;
const mockReq = { user: mockUser };
expect(() =>
memberController.getMyInfo(mockReq as unknown as Request),
).toThrow(ManipulatedTokenNotFiltered);
describe('getNameForInterview', () => {
const nickname = 'fakeNickname';

it('면접 화면에 표출할 닉네임 반환 성공 시에는 MemberNicknameResponse 형태로 반환한다.', async () => {
// given
const mockReq = mockReqWithMemberFixture;

// when
mockMemberService.getNameForInterview.mockResolvedValue(
new MemberNicknameResponse(nickname),
);

// then
const result = await memberController.getNameForInterview(mockReq);

expect(result).toBeInstanceOf(MemberNicknameResponse);
expect(result.nickname).toBe(nickname);
});

it('면접 화면에 표출할 닉네임 반환 시 토큰이 만료되었으면 TokenExpiredException을 반환한다.', async () => {
// given
const mockReq = mockReqWithMemberFixture;

// when
mockMemberService.getNameForInterview.mockRejectedValue(
new TokenExpiredException(),
);

// then
expect(memberController.getNameForInterview(mockReq)).rejects.toThrow(
TokenExpiredException,
);
});

it('면접 화면에 표출할 닉네임 반환 시 토큰이 유효하지 않으면 InvalidTokenException을 반환한다.', async () => {
// given
const mockReq = mockReqWithMemberFixture;

// when
mockMemberService.getNameForInterview.mockRejectedValue(
new InvalidTokenException(),
);

// then
expect(memberController.getNameForInterview(mockReq)).rejects.toThrow(
InvalidTokenException,
);
});
});
});

describe('MemberController (E2E Test)', () => {
describe('MemberController 통합 테스트', () => {
let app: INestApplication;
let authService: AuthService;
let jwtService: JwtService;
let memberRepository: MemberRepository;

beforeAll(async () => {
const modules = [AuthModule, TokenModule, MemberModule];
Expand All @@ -80,37 +147,86 @@ describe('MemberController (E2E Test)', () => {
);

app = moduleFixture.createNestApplication();
addAppModules(app);
await app.init();

authService = moduleFixture.get<AuthService>(AuthService);
jwtService = moduleFixture.get<JwtService>(JwtService);
memberRepository = moduleFixture.get<MemberRepository>(MemberRepository);
});

it('GET /api/member (회원 정보 반환 성공)', (done) => {
authService.login(oauthRequestFixture).then((validToken) => {
describe('getMyInfo', () => {
it('쿠키를 가지고 회원 정보 반환 요청을 하면 200 상태 코드와 회원 정보가 반환된다.', (done) => {
authService.login(oauthRequestFixture).then((validToken) => {
const agent = request.agent(app.getHttpServer());
agent
.get('/api/member')
.set('Cookie', [`accessToken=${validToken}`])
.expect(200)
.then((response) => {
expect(response.body.email).toBe(oauthRequestFixture.email);
expect(response.body.nickname).toBe(oauthRequestFixture.name);
expect(response.body.profileImg).toBe(oauthRequestFixture.img);
done();
});
});
});

it('유효하지 않은 토큰으로 회원 정보 반환을 요청하면 401 상태코드가 반환된다.', () => {
const agent = request.agent(app.getHttpServer());
agent
.get('/api/member')
.set('Cookie', [`accessToken=${validToken}`])
.expect(200)
.then((response) => {
expect(response.body.email).toBe(oauthRequestFixture.email);
expect(response.body.nickname).toBe(oauthRequestFixture.name);
expect(response.body.profileImg).toBe(oauthRequestFixture.img);
done();
});
.set('Cookie', [`accessToken=Bearer INVALID_TOKEN`])
.expect(401);
});
});

it('GET /api/member (유효하지 않은 토큰 사용으로 인한 회원 정보 반환 실패)', (done) => {
const agent = request.agent(app.getHttpServer());
agent
.get('/api/member')
.set('Cookie', [`accessToken=Bearer INVALID_TOKEN`])
.expect(401)
.then(() => done());
describe('getNameForInterview', () => {
it('쿠키를 가지고 면접 화면에 표출할 닉네임 반환 요청을 하면 200 상태 코드와 회원 닉네임이 들어간 채로 반환된다.', (done) => {
authService.login(oauthRequestFixture).then((validToken) => {
const agent = request.agent(app.getHttpServer());
agent
.get('/api/member/name')
.set('Cookie', [`accessToken=${validToken}`])
.expect(200)
.then((response) => {
expect(
response.body.nickname.endsWith(oauthRequestFixture.name),
).toBeTruthy();
done();
});
});
});

it('유효하지 않은 토큰으로 면접 화면에 표출할 닉네임 반환을 요청하면 401 상태코드가 반환된다.', () => {
const agent = request.agent(app.getHttpServer());
agent
.get('/api/member/name')
.set('Cookie', [`accessToken=Bearer INVALID_TOKEN`])
.expect(401);
});

it('만료된 토큰으로 면접 화면에 표출할 닉네임 반환을 요청하면 410 상태코드가 반환된다.', async () => {
const expirationTime = Math.floor(Date.now() / 1000) - 1;
const expiredToken = await jwtService.signAsync(
{ id: memberFixture.id } as TokenPayload,
{
expiresIn: expirationTime,
},
);

const agent = request.agent(app.getHttpServer());
agent
.get('/api/member/name')
.set('Cookie', [`accessToken=${expiredToken}`])
.expect(410);
});
});

afterAll(async () => {
await app.close();
afterEach(async () => {
await memberRepository.query('delete from Member');
await memberRepository.query('delete from Token');
});

afterAll(async () => await app.close());
});
2 changes: 1 addition & 1 deletion BE/src/member/dto/memberNicknameResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createPropertyOption } from 'src/util/swagger.util';

export class MemberNicknameResponse {
@ApiProperty(createPropertyOption('foobar', '회원의 닉네임', String))
private nickname: string;
nickname: string;

constructor(nickname: string) {
this.nickname = nickname;
Expand Down
2 changes: 1 addition & 1 deletion BE/src/member/exception/member.exception.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import { HttpNotFoundException } from 'src/util/exception.util';

export class MemberNotFoundException extends HttpNotFoundException {
constructor() {
super('회원을 찾을 수 없습니다.', 'M1');
super('회원을 찾을 수 없습니다.', 'M01');
}
}
4 changes: 4 additions & 0 deletions BE/src/member/repository/member.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,8 @@ export class MemberRepository {
async findByEmail(email: string) {
return await this.memberRepository.findOneBy({ email: email });
}

async query(query: string) {
return await this.memberRepository.query(query);
}
}
24 changes: 12 additions & 12 deletions BE/src/video/controller/video.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export class VideoController {
PreSignedUrlResponse,
),
)
@ApiResponse(createApiResponseOption(500, 'V1, SERVER', null))
@ApiResponse(createApiResponseOption(500, 'V01, SERVER', null))
async getPreSignedUrl(@Req() req: Request) {
return await this.videoService.getPreSignedUrl(req.user as Member);
}
Expand Down Expand Up @@ -94,9 +94,9 @@ export class VideoController {
VideoDetailResponse,
),
)
@ApiResponse(createApiResponseOption(403, 'V2', null))
@ApiResponse(createApiResponseOption(404, 'V4, M1', null))
@ApiResponse(createApiResponseOption(500, 'V6', null))
@ApiResponse(createApiResponseOption(403, 'V02', null))
@ApiResponse(createApiResponseOption(404, 'V04, M01', null))
@ApiResponse(createApiResponseOption(500, 'V06', null))
async getVideoDetailByHash(@Param('hash') hash: string) {
return await this.videoService.getVideoDetailByHash(hash);
}
Expand All @@ -114,9 +114,9 @@ export class VideoController {
VideoDetailResponse,
),
)
@ApiResponse(createApiResponseOption(403, 'V2', null))
@ApiResponse(createApiResponseOption(404, 'V3', null))
@ApiResponse(createApiResponseOption(500, 'V8, SERVER', null))
@ApiResponse(createApiResponseOption(403, 'V02', null))
@ApiResponse(createApiResponseOption(404, 'V03', null))
@ApiResponse(createApiResponseOption(500, 'V08, SERVER', null))
async getVideoDetail(@Param('videoId') videoId: number, @Req() req: Request) {
return await this.videoService.getVideoDetail(videoId, req.user as Member);
}
Expand All @@ -130,9 +130,9 @@ export class VideoController {
@ApiResponse(
createApiResponseOption(200, '비디오 상태 전환 완료', VideoHashResponse),
)
@ApiResponse(createApiResponseOption(403, 'V2', null))
@ApiResponse(createApiResponseOption(404, 'V3', null))
@ApiResponse(createApiResponseOption(500, 'V5, V6, V7, SERVER', null))
@ApiResponse(createApiResponseOption(403, 'V02', null))
@ApiResponse(createApiResponseOption(404, 'V03', null))
@ApiResponse(createApiResponseOption(500, 'V05, V06, V07, SERVER', null))
async toggleVideoStatus(
@Param('videoId') videoId: number,
@Req() req: Request,
Expand All @@ -150,8 +150,8 @@ export class VideoController {
summary: '비디오 삭제',
})
@ApiResponse(createApiResponseOption(204, '비디오 삭제 완료', null))
@ApiResponse(createApiResponseOption(403, 'V2', null))
@ApiResponse(createApiResponseOption(404, 'V3', null))
@ApiResponse(createApiResponseOption(403, 'V02', null))
@ApiResponse(createApiResponseOption(404, 'V03', null))
@ApiResponse(createApiResponseOption(500, 'SERVER', null))
async deleteVideo(
@Param('videoId') videoId: number,
Expand Down
16 changes: 8 additions & 8 deletions BE/src/video/exception/video.exception.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,48 +6,48 @@ import {

export class IDriveException extends HttpInternalServerError {
constructor() {
super('IDrive 제공 API 처리 도중 에러가 발생하였습니다.', 'V1');
super('IDrive 제공 API 처리 도중 에러가 발생하였습니다.', 'V01');
}
}

export class VideoAccessForbiddenException extends HttpForbiddenException {
constructor() {
super('해당 비디오에 접근 권한이 없습니다.', 'V2');
super('해당 비디오에 접근 권한이 없습니다.', 'V02');
}
}

export class VideoNotFoundException extends HttpNotFoundException {
constructor() {
super('존재하지 않는 비디오입니다.', 'V3');
super('존재하지 않는 비디오입니다.', 'V03');
}
}

export class VideoOfWithdrawnMemberException extends HttpNotFoundException {
constructor() {
super('탈퇴한 회원의 비디오를 조회할 수 없습니다.', 'V4');
super('탈퇴한 회원의 비디오를 조회할 수 없습니다.', 'V04');
}
}

export class RedisDeleteException extends HttpInternalServerError {
constructor() {
super('Redis에서 비디오 정보 삭제 중 오류가 발생하였습니다.', 'V5');
super('Redis에서 비디오 정보 삭제 중 오류가 발생하였습니다.', 'V05');
}
}

export class RedisRetrieveException extends HttpInternalServerError {
constructor() {
super('Redis에서 비디오 정보를 가져오는 중 오류가 발생하였습니다.', 'V6');
super('Redis에서 비디오 정보를 가져오는 중 오류가 발생하였습니다.', 'V06');
}
}

export class RedisSaveException extends HttpInternalServerError {
constructor() {
super('Redis에 비디오 정보 저장 중 오류가 발생하였습니다.', 'V7');
super('Redis에 비디오 정보 저장 중 오류가 발생하였습니다.', 'V07');
}
}

export class Md5HashException extends HttpInternalServerError {
constructor() {
super('MD5 해시 생성 중 오류가 발생했습니다.', 'V8');
super('MD5 해시 생성 중 오류가 발생했습니다.', 'V08');
}
}
Loading