From e2d9e7cfd4c64e7fd706a1c7202a220c607b83e6 Mon Sep 17 00:00:00 2001 From: quiet-honey Date: Tue, 5 Dec 2023 13:05:23 +0900 Subject: [PATCH 1/3] =?UTF-8?q?test:=20=EB=A9=B4=EC=A0=91=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=91=9C=EC=B6=9C=EC=9A=A9=20=EB=8B=89=EB=84=A4?= =?UTF-8?q?=EC=9E=84=20=EB=B0=98=ED=99=98=20API=20Controller=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/member.controller.spec.ts | 145 +++++++++++++----- BE/src/member/dto/memberNicknameResponse.ts | 2 +- 2 files changed, 106 insertions(+), 41 deletions(-) diff --git a/BE/src/member/controller/member.controller.spec.ts b/BE/src/member/controller/member.controller.spec.ts index 7552be7..7e73937 100644 --- a/BE/src/member/controller/member.controller.spec.ts +++ b/BE/src/member/controller/member.controller.spec.ts @@ -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'; @@ -18,13 +22,19 @@ 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'; -describe('MemberController', () => { +describe('MemberController 단위 테스트', () => { let memberController: MemberController; - const mockMemberService = {}; + const mockMemberService = { + getNameForInterview: jest.fn(), + }; const mockTokenService = {}; beforeEach(async () => { @@ -44,29 +54,81 @@ 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; @@ -80,36 +142,39 @@ describe('MemberController (E2E Test)', () => { ); app = moduleFixture.createNestApplication(); + addAppModules(app); await app.init(); authService = moduleFixture.get(AuthService); }); - 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 상태코드가 반환된다.)', (done) => { 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) + .then(() => done()); }); }); - 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()); - }); - afterAll(async () => { await app.close(); }); diff --git a/BE/src/member/dto/memberNicknameResponse.ts b/BE/src/member/dto/memberNicknameResponse.ts index d123b18..1e57692 100644 --- a/BE/src/member/dto/memberNicknameResponse.ts +++ b/BE/src/member/dto/memberNicknameResponse.ts @@ -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; From 8c444c0261d5415a4f7eb43ca1cc3f5edb8c8370 Mon Sep 17 00:00:00 2001 From: quiet-honey Date: Tue, 5 Dec 2023 13:48:36 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test:=20=EB=A9=B4=EC=A0=91=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=91=9C=EC=B6=9C=EC=9A=A9=20=EB=8B=89=EB=84=A4?= =?UTF-8?q?=EC=9E=84=20=EB=B0=98=ED=99=98=20API=20Service=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/member.controller.spec.ts | 67 ++++++++++++++++--- BE/src/member/repository/member.repository.ts | 4 ++ 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/BE/src/member/controller/member.controller.spec.ts b/BE/src/member/controller/member.controller.spec.ts index 7e73937..c4444c8 100644 --- a/BE/src/member/controller/member.controller.spec.ts +++ b/BE/src/member/controller/member.controller.spec.ts @@ -28,6 +28,9 @@ import { } 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 단위 테스트', () => { let memberController: MemberController; @@ -80,7 +83,7 @@ describe('MemberController 단위 테스트', () => { describe('getNameForInterview', () => { const nickname = 'fakeNickname'; - it('면접 화면에 표출할 이름 반환 성공 시에는 MemberNicknameResponse 형태로 반환한다.', async () => { + it('면접 화면에 표출할 닉네임 반환 성공 시에는 MemberNicknameResponse 형태로 반환한다.', async () => { // given const mockReq = mockReqWithMemberFixture; @@ -96,7 +99,7 @@ describe('MemberController 단위 테스트', () => { expect(result.nickname).toBe(nickname); }); - it('면접 화면에 표출할 이름 반환 시 토큰이 만료되었으면 TokenExpiredException을 반환한다.', async () => { + it('면접 화면에 표출할 닉네임 반환 시 토큰이 만료되었으면 TokenExpiredException을 반환한다.', async () => { // given const mockReq = mockReqWithMemberFixture; @@ -111,7 +114,7 @@ describe('MemberController 단위 테스트', () => { ); }); - it('면접 화면에 표출할 이름 반환 시 토큰이 유효하지 않으면 InvalidTokenException을 반환한다.', async () => { + it('면접 화면에 표출할 닉네임 반환 시 토큰이 유효하지 않으면 InvalidTokenException을 반환한다.', async () => { // given const mockReq = mockReqWithMemberFixture; @@ -131,6 +134,8 @@ describe('MemberController 단위 테스트', () => { describe('MemberController 통합 테스트', () => { let app: INestApplication; let authService: AuthService; + let jwtService: JwtService; + let memberRepository: MemberRepository; beforeAll(async () => { const modules = [AuthModule, TokenModule, MemberModule]; @@ -146,6 +151,8 @@ describe('MemberController 통합 테스트', () => { await app.init(); authService = moduleFixture.get(AuthService); + jwtService = moduleFixture.get(JwtService); + memberRepository = moduleFixture.get(MemberRepository); }); describe('getMyInfo', () => { @@ -165,17 +172,61 @@ describe('MemberController 통합 테스트', () => { }); }); - it('유효하지 않은 토큰으로 회원 정보 반환을 요청하면 401 상태코드가 반환된다.)', (done) => { + it('유효하지 않은 토큰으로 회원 정보 반환을 요청하면 401 상태코드가 반환된다.)', () => { const agent = request.agent(app.getHttpServer()); agent .get('/api/member') .set('Cookie', [`accessToken=Bearer INVALID_TOKEN`]) - .expect(401) - .then(() => done()); + .expect(401); }); }); - afterAll(async () => { - await app.close(); + 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); + }); }); + + afterEach(async () => { + await memberRepository.query('delete from Member'); + await memberRepository.query('delete from Token'); + }); + + afterAll(async () => await app.close()); }); diff --git a/BE/src/member/repository/member.repository.ts b/BE/src/member/repository/member.repository.ts index 2073f0a..765aa00 100644 --- a/BE/src/member/repository/member.repository.ts +++ b/BE/src/member/repository/member.repository.ts @@ -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); + } } From b77c7a35e9fc4959b8ef979e3f224c5899dc3e2c Mon Sep 17 00:00:00 2001 From: quiet-honey Date: Tue, 5 Dec 2023 14:31:35 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C=20=EB=91=90=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EA=B0=80=EC=A7=80=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/member.controller.spec.ts | 10 ++++---- BE/src/member/exception/member.exception.ts | 2 +- BE/src/video/controller/video.controller.ts | 24 +++++++++---------- BE/src/video/exception/video.exception.ts | 16 ++++++------- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/BE/src/member/controller/member.controller.spec.ts b/BE/src/member/controller/member.controller.spec.ts index c4444c8..556dd96 100644 --- a/BE/src/member/controller/member.controller.spec.ts +++ b/BE/src/member/controller/member.controller.spec.ts @@ -156,7 +156,7 @@ describe('MemberController 통합 테스트', () => { }); describe('getMyInfo', () => { - it('쿠키를 가지고 회원 정보 반환 요청을 하면 200 상태 코드와 회원 정보가 반환된다.)', (done) => { + it('쿠키를 가지고 회원 정보 반환 요청을 하면 200 상태 코드와 회원 정보가 반환된다.', (done) => { authService.login(oauthRequestFixture).then((validToken) => { const agent = request.agent(app.getHttpServer()); agent @@ -172,7 +172,7 @@ describe('MemberController 통합 테스트', () => { }); }); - it('유효하지 않은 토큰으로 회원 정보 반환을 요청하면 401 상태코드가 반환된다.)', () => { + it('유효하지 않은 토큰으로 회원 정보 반환을 요청하면 401 상태코드가 반환된다.', () => { const agent = request.agent(app.getHttpServer()); agent .get('/api/member') @@ -182,7 +182,7 @@ describe('MemberController 통합 테스트', () => { }); describe('getNameForInterview', () => { - it('쿠키를 가지고 면접 화면에 표출할 닉네임 반환 요청을 하면 200 상태 코드와 회원 닉네임이 들어간 채로 반환된다.)', (done) => { + it('쿠키를 가지고 면접 화면에 표출할 닉네임 반환 요청을 하면 200 상태 코드와 회원 닉네임이 들어간 채로 반환된다.', (done) => { authService.login(oauthRequestFixture).then((validToken) => { const agent = request.agent(app.getHttpServer()); agent @@ -198,7 +198,7 @@ describe('MemberController 통합 테스트', () => { }); }); - it('유효하지 않은 토큰으로 면접 화면에 표출할 닉네임 반환을 요청하면 401 상태코드가 반환된다.)', () => { + it('유효하지 않은 토큰으로 면접 화면에 표출할 닉네임 반환을 요청하면 401 상태코드가 반환된다.', () => { const agent = request.agent(app.getHttpServer()); agent .get('/api/member/name') @@ -206,7 +206,7 @@ describe('MemberController 통합 테스트', () => { .expect(401); }); - it('만료된 토큰으로 면접 화면에 표출할 닉네임 반환을 요청하면 410 상태코드가 반환된다.)', async () => { + it('만료된 토큰으로 면접 화면에 표출할 닉네임 반환을 요청하면 410 상태코드가 반환된다.', async () => { const expirationTime = Math.floor(Date.now() / 1000) - 1; const expiredToken = await jwtService.signAsync( { id: memberFixture.id } as TokenPayload, diff --git a/BE/src/member/exception/member.exception.ts b/BE/src/member/exception/member.exception.ts index 3c2b496..edcc2b4 100644 --- a/BE/src/member/exception/member.exception.ts +++ b/BE/src/member/exception/member.exception.ts @@ -2,6 +2,6 @@ import { HttpNotFoundException } from 'src/util/exception.util'; export class MemberNotFoundException extends HttpNotFoundException { constructor() { - super('회원을 찾을 수 없습니다.', 'M1'); + super('회원을 찾을 수 없습니다.', 'M01'); } } diff --git a/BE/src/video/controller/video.controller.ts b/BE/src/video/controller/video.controller.ts index 1b2f340..ce227fd 100644 --- a/BE/src/video/controller/video.controller.ts +++ b/BE/src/video/controller/video.controller.ts @@ -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); } @@ -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); } @@ -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); } @@ -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, @@ -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, diff --git a/BE/src/video/exception/video.exception.ts b/BE/src/video/exception/video.exception.ts index fc76a37..d4e6b44 100644 --- a/BE/src/video/exception/video.exception.ts +++ b/BE/src/video/exception/video.exception.ts @@ -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'); } }