Skip to content

Commit

Permalink
[Release] 231110 week1 배포 (#33)
Browse files Browse the repository at this point in the history
* [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>
  • Loading branch information
adultlee and quiet-honey authored Nov 11, 2023
1 parent 7c05a3b commit 40c775f
Show file tree
Hide file tree
Showing 6 changed files with 213 additions and 14 deletions.
3 changes: 2 additions & 1 deletion BE/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }],
},
};
12 changes: 6 additions & 6 deletions BE/src/config/cors.config.ts
Original file line number Diff line number Diff line change
@@ -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,
}
origin: CORS_ORIGIN,
credentials: true,
exposedHeaders: CORS_HEADERS,
};
2 changes: 1 addition & 1 deletion BE/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
60 changes: 59 additions & 1 deletion BE/src/member/controller/member.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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>(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();
});
});
16 changes: 16 additions & 0 deletions BE/test/test.util.ts
Original file line number Diff line number Diff line change
@@ -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;
};
134 changes: 129 additions & 5 deletions FE/src/components/interviewPage/InterviewCamera.tsx
Original file line number Diff line number Diff line change
@@ -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<MediaStream | null>(null);
const [recording, setRecording] = useState(false);
const [recordedBlobs, setRecordedBlobs] = useState<Blob[]>([]);
const [selectedMimeType, setSelectedMimeType] = useState('');

const mirrorVideoRef = useRef<HTMLVideoElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(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 (
<div
css={css`
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
width: 100%;
height: 75%;
border: 0.0625rem solid red;
background-color: black;
`}
>
면접페이지의 카메라 입니다.
<video
ref={mirrorVideoRef}
playsInline
autoPlay
muted
css={css`
width: 100%;
height: 80%;
transform: scaleX(-1);
`}
/>

<div>
<button onClick={handleStartRecording} disabled={recording}>
시작
</button>
<button onClick={handleStopRecording} disabled={!recording}>
종료
</button>
<button
onClick={handleDownload}
disabled={recording || recordedBlobs.length === 0}
>
저장
</button>
</div>
</div>
);
};

export default InterviewCamera;

0 comments on commit 40c775f

Please sign in to comment.