From 40c775f4590db329dab803189d5d4804afecd3ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=B1=EC=9D=B8?= Date: Sat, 11 Nov 2023 19:38:04 +0900 Subject: [PATCH] =?UTF-8?q?[Release]=20231110=20week1=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=20(#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [NDD-103] Member API E2E 테스트 (1h / 1h) (#29) * test: Member API E2E 테스트 코드 작성 1. /api/member GET API 에 대한 성공 테스트 2. /api/member GET API에서 유효하지 않은 토큰 사용으로 인한 실패 테스트 위의 2가지의 경우에 대한 테스트 코드 작성 * style: lint 적용 * test: 인수 테스트 코드 로직 변경 완료 GET /api/member에 대한 E2E 테스트 완료 * style: Window 개행문자 관련 에러 해결을 위한 lint 설정 추가 * feat: TestingModule을 생성하는 Util 메서드 구현 * [NDD-87] Interview 페이지 camera 컴포넌트 기능 부여 (7h/8h) (#30) * feat: InterviewCamera 내부 state 정의 * feat: 현재 브라우저에서 지원가능 한 MimeType을 명시 * feat: 현재 Media에 연결 기능 구현 * feat: 마운트시 바로 stream 연결 * feat: Record 시작함수 구현 * feat: record stop 함수 구현 * feat: Record Download 함수 구현 * feat: InterviewCamera 내부 state 정의 * feat: 현재 브라우저에서 지원가능 한 MimeType을 명시 * feat: 현재 Media에 연결 기능 구현 * feat: 마운트시 바로 stream 연결 * feat: Record 시작함수 구현 * feat: record stop 함수 구현 * feat: Record Download 함수 구현 * feat: InterviewCamera 내부 state 정의 * feat: 현재 브라우저에서 지원가능 한 MimeType을 명시 * feat: 현재 Media에 연결 기능 구현 * feat: 마운트시 바로 stream 연결 * feat: Record 시작함수 구현 * feat: record stop 함수 구현 * feat: Record Download 함수 구현 * feat: InterviewCamera 내부 state 정의 * feat: 현재 브라우저에서 지원가능 한 MimeType을 명시 * feat: 현재 Media에 연결 기능 구현 * feat: 마운트시 바로 stream 연결 * feat: Record 시작함수 구현 * feat: record stop 함수 구현 * feat: Record Download 함수 구현 * feat: InterviewCamera 내부 state 정의 * feat: 현재 브라우저에서 지원가능 한 MimeType을 명시 * feat: 현재 Media에 연결 기능 구현 * feat: 마운트시 바로 stream 연결 * feat: Record 시작함수 구현 * feat: record stop 함수 구현 * feat: Record Download 함수 구현 * feat: InterviewCamera 내부 state 정의 * feat: 현재 브라우저에서 지원가능 한 MimeType을 명시 * feat: 현재 Media에 연결 기능 구현 * feat: 마운트시 바로 stream 연결 * feat: Record 시작함수 구현 * feat: record stop 함수 구현 * feat: Record Download 함수 구현 * feat: InterviewCamera 내부 state 정의 * feat: 현재 브라우저에서 지원가능 한 MimeType을 명시 * feat: 현재 Media에 연결 기능 구현 * feat: 마운트시 바로 stream 연결 * feat: Record 시작함수 구현 * feat: record stop 함수 구현 * feat: Record Download 함수 구현 * feat: InterviewCamera 내부 state 정의 * feat: 현재 브라우저에서 지원가능 한 MimeType을 명시 * feat: 현재 Media에 연결 기능 구현 * feat: 마운트시 바로 stream 연결 * feat: Record 시작함수 구현 * feat: record stop 함수 구현 * feat: Record Download 함수 구현 * feat: video UI 기능 구현 * fix: log 문제 * fix: InterviewCamera 파일시스템 문제 해결 * chore: 상대경로 수정 * chore: CameraRef 이름 변경 --------- Co-authored-by: quiet-honey <99426344+quiet-honey@users.noreply.github.com> --- BE/.eslintrc.js | 3 +- BE/src/config/cors.config.ts | 12 +- BE/src/main.ts | 2 +- .../controller/member.controller.spec.ts | 60 +++++++- BE/test/test.util.ts | 16 +++ .../interviewPage/InterviewCamera.tsx | 134 +++++++++++++++++- 6 files changed, 213 insertions(+), 14 deletions(-) create mode 100644 BE/test/test.util.ts diff --git a/BE/.eslintrc.js b/BE/.eslintrc.js index c9fe4db..c8a2138 100644 --- a/BE/.eslintrc.js +++ b/BE/.eslintrc.js @@ -15,11 +15,12 @@ module.exports = { node: true, jest: true, }, - ignorePatterns: ['/eslintrc.js'], + ignorePatterns: ['.eslintrc.js'], rules: { '@typescript-eslint/interface-name-prefix': 'off', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', + 'prettier/prettier': ['error', { endOfLine: 'auto' }], }, }; diff --git a/BE/src/config/cors.config.ts b/BE/src/config/cors.config.ts index 556e85b..87a0036 100644 --- a/BE/src/config/cors.config.ts +++ b/BE/src/config/cors.config.ts @@ -1,8 +1,8 @@ -import {CorsOptions} from "@nestjs/common/interfaces/external/cors-options.interface"; -import {CORS_HEADERS, CORS_ORIGIN} from "./cors.secure"; +import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface'; +import { CORS_HEADERS, CORS_ORIGIN } from './cors.secure'; export const CORS_CONFIG: CorsOptions = { - origin: CORS_ORIGIN, - credentials: true, - exposedHeaders: CORS_HEADERS, -} \ No newline at end of file + origin: CORS_ORIGIN, + credentials: true, + exposedHeaders: CORS_HEADERS, +}; diff --git a/BE/src/main.ts b/BE/src/main.ts index f07a92d..38c39ae 100644 --- a/BE/src/main.ts +++ b/BE/src/main.ts @@ -2,7 +2,7 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; import { setupSwagger } from './config/swagger.config'; -import {CORS_CONFIG} from "./config/cors.config"; +import { CORS_CONFIG } from './config/cors.config'; async function bootstrap() { const app = await NestFactory.create(AppModule); diff --git a/BE/src/member/controller/member.controller.spec.ts b/BE/src/member/controller/member.controller.spec.ts index f9ef649..b984df3 100644 --- a/BE/src/member/controller/member.controller.spec.ts +++ b/BE/src/member/controller/member.controller.spec.ts @@ -1,9 +1,15 @@ -import { Test } from '@nestjs/testing'; +import { Test, TestingModule } from '@nestjs/testing'; import { MemberController } from './member.controller'; import { MemberResponse } from '../dto/memberResponse'; import { Request } from 'express'; import { Member } from '../entity/member'; import { ManipulatedTokenNotFiltered } from 'src/token/exception/token.exception'; +import { INestApplication } from '@nestjs/common'; +import { AppModule } from 'src/app.module'; +import * as request from 'supertest'; +import { AuthModule } from 'src/auth/auth.module'; +import { AuthService } from 'src/auth/service/auth.service'; +import { OAuthRequest } from 'src/auth/interface/auth.interface'; describe('MemberController', () => { let memberController: MemberController; @@ -47,3 +53,55 @@ describe('MemberController', () => { ).toThrow(ManipulatedTokenNotFiltered); }); }); + +describe('MemberController (E2E Test)', () => { + let app: INestApplication; + let authService: AuthService; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule, AuthModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + + authService = moduleFixture.get(AuthService); + }); + + it('GET /api/member (회원 정보 반환 성공)', async () => { + const oauthRequestFixture = { + email: 'fixture@example.com', + name: 'fixture', + img: 'https://test.com', + } as OAuthRequest; + + const validToken = (await authService.login(oauthRequestFixture)).replace( + 'Bearer ', + '', + ); + const response = await request(app.getHttpServer()) + .get('/api/member') + .set('Authorization', `Bearer ${validToken}`) + .expect(200); + + console.log(response.body); + + expect(response.body.email).toBe(oauthRequestFixture.email); + expect(response.body.nickname).toBe(oauthRequestFixture.name); + expect(response.body.profileImg).toBe(oauthRequestFixture.img); + }); + + it('GET /api/member (유효하지 않은 토큰 사용으로 인한 회원 정보 반환 실패)', async () => { + const invalidToken = 'INVALID_TOKEN'; + + await request(app.getHttpServer()) + .get('/api/member') + .set('Authorization', `Bearer ${invalidToken}`) + .expect(401); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/BE/test/test.util.ts b/BE/test/test.util.ts new file mode 100644 index 0000000..70fd3f5 --- /dev/null +++ b/BE/test/test.util.ts @@ -0,0 +1,16 @@ +import { ModuleMetadata } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; + +export const createTestModuleFixture = async ( + imports: unknown, + controllers: unknown, + providers: unknown, +) => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: imports, + controllers: controllers, + providers: providers, + } as ModuleMetadata).compile(); + + return moduleFixture; +}; diff --git a/FE/src/components/interviewPage/InterviewCamera.tsx b/FE/src/components/interviewPage/InterviewCamera.tsx index 0e1466f..55c188f 100644 --- a/FE/src/components/interviewPage/InterviewCamera.tsx +++ b/FE/src/components/interviewPage/InterviewCamera.tsx @@ -1,19 +1,143 @@ +import React, { useState, useRef, useEffect, useLayoutEffect } from 'react'; import { css } from '@emotion/react'; const InterviewCamera: React.FC = () => { + const [stream, setStream] = useState(null); + const [recording, setRecording] = useState(false); + const [recordedBlobs, setRecordedBlobs] = useState([]); + const [selectedMimeType, setSelectedMimeType] = useState(''); + + const mirrorVideoRef = useRef(null); + const mediaRecorderRef = useRef(null); + + useLayoutEffect(() => { + const mimeTypes = getSupportedMimeTypes(); + if (mimeTypes.length > 0) { + setSelectedMimeType(mimeTypes[0]); + } + }, []); + + useEffect(() => { + if (!stream) { + void getMedia(); + } + + return () => { + if (stream) { + stream.getTracks().forEach((track) => track.stop()); + } + }; + }, [stream]); + + const getMedia = async () => { + try { + const constraints = { + audio: { + echoCancellation: { exact: true }, + }, + video: { + width: 1280, + height: 720, + }, + }; + const mediaStream = + await navigator.mediaDevices.getUserMedia(constraints); + setStream(mediaStream); + if (mirrorVideoRef.current) { + mirrorVideoRef.current.srcObject = mediaStream; + } + } catch (e) { + console.log(`현재 마이크와 카메라가 연결되지 않았습니다`); + } + }; + + const handleStartRecording = () => { + setRecordedBlobs([]); + try { + mediaRecorderRef.current = new MediaRecorder(stream as MediaStream, { + mimeType: selectedMimeType, + }); + mediaRecorderRef.current.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + setRecordedBlobs((prev) => [...prev, event.data]); + } + }; + mediaRecorderRef.current.start(); + setRecording(true); + } catch (e) { + console.log(`MediaRecorder error`); + } + }; + + const handleStopRecording = () => { + if (mediaRecorderRef.current) { + mediaRecorderRef.current.stop(); + } + setRecording(false); + }; + + const handleDownload = () => { + const blob = new Blob(recordedBlobs, { type: selectedMimeType }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = 'recorded.webm'; + document.body.appendChild(a); + a.click(); + setTimeout(() => { + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + }, 100); + }; + + const getSupportedMimeTypes = () => { + const types = [ + 'video/webm; codecs=vp8', + 'video/webm; codecs=vp9', + 'video/webm; codecs=h264', + 'video/mp4; codecs=h264', + ]; + return types.filter((type) => MediaRecorder.isTypeSupported(type)); + }; + return (
- 면접페이지의 카메라 입니다. +
); }; + export default InterviewCamera;