diff --git a/BackEnd/src/images/dto/images.response.spec.ts b/BackEnd/src/images/dto/images.response.spec.ts new file mode 100644 index 00000000..b1da79f2 --- /dev/null +++ b/BackEnd/src/images/dto/images.response.spec.ts @@ -0,0 +1,43 @@ +import { plainToClass } from 'class-transformer'; +import { validate } from 'class-validator'; +import { + ImageRequestDto, + ImageResponse, + ImageResponseDto, +} from './images.response'; + +describe('ImageDto', () => { + describe('ImageResponse', () => { + it('만약 둘 중 하나라도 문자열이 비여있다면, 오류를 발생시킨다. 둘다면 2개 하나면 1개', async () => { + const dto = plainToClass(ImageResponse, { + imageName: '', + imageUrl: '', + }); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it('정상적으로 이미지 생성이 완료되었다면, 오류는 발생하지 않는다.', async () => { + const dto = plainToClass(ImageResponse, { + imageName: 'example.png', + imageUrl: 'https://cdn.example.com/example.png', + }); + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + }); + + describe('ImageResponseDto', () => { + it('이미지 Response는 imageName과 imageUrl을 가진 배열이다.', () => { + const imageResponse = new ImageResponse( + 'test.png', + 'https://cdn.naver.com/test.png', + ); + const dto = new ImageResponseDto(); + dto.data = [imageResponse]; + + expect(dto.data).toBeInstanceOf(Array); + expect(dto.data[0]).toBeInstanceOf(ImageResponse); + }); + }); +}); diff --git a/BackEnd/src/images/dto/images.response.ts b/BackEnd/src/images/dto/images.response.ts index e67ebfd5..6568f270 100644 --- a/BackEnd/src/images/dto/images.response.ts +++ b/BackEnd/src/images/dto/images.response.ts @@ -1,14 +1,43 @@ -export const ImagesResponse = () => { - return { - example: { - code: null, - errorMessage: null, - data: [ - { - imageName: '스크린샷 2023-04-09 오후 6.46.20.png', - imageUrl: 'https://cdnAdress/uuid.png', - }, - ], +import { ApiProperty } from '@nestjs/swagger'; +import { SuccessResDto } from '../../common/dto/SuccessRes.dto'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class ImageRequestDto { + @ApiProperty({ + type: 'array', + items: { + type: 'string', + format: 'binary', + description: 'File upload', }, - }; -}; + description: '이미지 파일, 10MB 이하 .png .jpg .jpeg 파일만 가능', + }) + images: any[]; +} + +export class ImageResponse { + @ApiProperty({ + description: '이미지 이름', + example: '스크린샷 2023-04-09 오후 6.46.20.png', + }) + @IsString() + @IsNotEmpty() + imageName: string; + @ApiProperty({ + description: '저장된 이미지 URL', + example: 'https://cdnAdress/uuid.png', + }) + @IsString() + @IsNotEmpty() + imageUrl: string; + + constructor(imageName: string, imageUrl: string) { + this.imageName = imageName; + this.imageUrl = imageUrl; + } +} + +export class ImageResponseDto extends SuccessResDto { + @ApiProperty({ type: () => [ImageResponse] }) + data: ImageResponse[]; +} diff --git a/BackEnd/src/images/exceptions/images.exception.ts b/BackEnd/src/images/exceptions/images.exception.ts new file mode 100644 index 00000000..499a574e --- /dev/null +++ b/BackEnd/src/images/exceptions/images.exception.ts @@ -0,0 +1,55 @@ +import { HttpException } from '@nestjs/common'; + +export class NotAccessToNCPException extends HttpException { + constructor() { + const response = { + statusCode: 9000, + message: 'not Access', + }; + const httpCode = 404; + super(response, httpCode); + } +} +export class NotAccessToGreenEyeException extends HttpException { + constructor() { + const response = { + statusCode: 9100, + message: 'not Access', + }; + const httpCode = 404; + super(response, httpCode); + } +} + +export class FileSizeTooLargeException extends HttpException { + constructor() { + const response = { + statusCode: 9200, + message: 'file size too large', + }; + const httpCode = 400; + super(response, httpCode); + } +} + +export class InvalidFileTypeException extends HttpException { + constructor() { + const response = { + statusCode: 9300, + message: 'invalid file type', + }; + const httpCode = 400; + super(response, httpCode); + } +} + +export class InvalidFileCountOrFieldNameException extends HttpException { + constructor() { + const response = { + statusCode: 9400, + message: 'invalid file count or field name', + }; + const httpCode = 400; + super(response, httpCode); + } +} diff --git a/BackEnd/src/images/images.controller.ts b/BackEnd/src/images/images.controller.ts index dd0c83a4..a043ba07 100644 --- a/BackEnd/src/images/images.controller.ts +++ b/BackEnd/src/images/images.controller.ts @@ -2,33 +2,32 @@ import { Controller, Post, UploadedFiles, - UseGuards, UseInterceptors, } from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + ApiBody, + ApiConsumes, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { ImagesService } from './images.service'; -import { AccessTokenGuard } from '../auth/guard/bearerToken.guard'; -import { FilesInterceptor } from '@nestjs/platform-express'; import { MAX_IMAGE_SIZE } from './constant/images.constant'; import { ValidateFilesPipe } from './pipe/validate-files.pip'; -import { ImagesResponse } from './dto/images.response'; +import { ImageRequestDto, ImageResponseDto } from './dto/images.response'; +import { WetriFilesInterceptor } from './intercepters/wetri-files.interceptor'; @ApiTags('이미지 업로드 API') -@UseGuards(AccessTokenGuard) @Controller('api/v1/images') export class ImagesController { constructor(private readonly imagesService: ImagesService) {} - @ApiOperation({ - summary: '이미지를 최대 5개까지 업로드 가능하다.', - }) - @ApiResponse({ - status: 200, - description: '성공', - schema: ImagesResponse(), - }) + @ApiOperation({ summary: '이미지를 최대 5개까지 업로드 가능하다.' }) + @ApiResponse({ type: ImageResponseDto }) @Post() - @UseInterceptors(FilesInterceptor('images', 5)) + @UseInterceptors(WetriFilesInterceptor) + @ApiConsumes('multipart/form-data') + @ApiBody({ type: ImageRequestDto }) async uploadImage( @UploadedFiles( new ValidateFilesPipe({ diff --git a/BackEnd/src/images/images.service.spec.ts b/BackEnd/src/images/images.service.spec.ts new file mode 100644 index 00000000..c31f6c5b --- /dev/null +++ b/BackEnd/src/images/images.service.spec.ts @@ -0,0 +1,150 @@ +jest.mock('axios'); +jest.mock('@aws-sdk/client-s3', () => ({ + S3Client: jest.fn().mockImplementation(() => ({ + send: jest.fn(), + })), +})); + +import { Test, TestingModule } from '@nestjs/testing'; +import { ImagesService } from './images.service'; +import { ConfigService } from '@nestjs/config'; +import { S3Client } from '@aws-sdk/client-s3'; +import axios from 'axios'; +import { + NotAccessToGreenEyeException, + NotAccessToNCPException, +} from './exceptions/images.exception'; + +describe('ImagesService', () => { + let service: ImagesService; + let configService: ConfigService; + let s3Client: S3Client; + + let imageBuffer: Buffer; + let imageId: string; + let originalName: string; + + const config = { + NCP_ENDPOINT: 'https://이미지사이트.com', + NCP_REGION: '한국', + NCP_ACCESS_KEY: '엑세스키', + NCP_SECRET_KEY: '비밀키', + NCP_BUCKET_NAME: '스토리지', + NCP_CDN_URL: 'https://시디엔사이트.com/', + }; + + const mockMulterFile = (filename: string): Express.Multer.File => + ({ + fieldname: 'images', + originalname: filename, + mimetype: 'image/png', + filename: filename, + size: 1024, + buffer: Buffer.from('abc'), + }) as Express.Multer.File; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ImagesService, + { + provide: ConfigService, + useValue: { + getOrThrow: jest.fn((key: string) => { + return config[key]; + }), + }, + }, + S3Client, + ], + }).compile(); + + service = module.get(ImagesService); + configService = module.get(ConfigService); + s3Client = module.get(S3Client); + + imageBuffer = Buffer.from('test'); + imageId = 'ababab'; + originalName = 'test.png'; + + jest.clearAllMocks(); + }); + + describe('uploadImage', () => { + it('그린아이에서 오류가 발생할 때, 그린아이 오류를 보낸다.', async () => { + (axios.post as jest.Mock).mockRejectedValue( + new Error('그린아이에서 오류가 발생'), + ); + + await expect( + service.uploadImage([mockMulterFile('test.png')]), + ).rejects.toThrow(NotAccessToGreenEyeException); + }); + }); + describe('오브젝트에 저장하는 테스트 ', () => { + it('오브젝트 스토리지 저장이 오류가 발생하면 NCP에 접근할 수 없다는 오류를 던진다.', async () => { + (s3Client.send as jest.Mock).mockRejectedValue( + new Error('오브젝트 스토리지 저장 오류'), + ); + await expect( + service['sendToObjectStorage']( + imageId, + imageBuffer, + mockMulterFile('test.png'), + ), + ).rejects.toThrow(NotAccessToNCPException); + }); + }); + + describe('getExtension 메서드 테스트', () => { + it('파일명의 끝 부분만 잘라서 준다.', () => { + const filename = 'test.png'; + const extension = service['getExtension'](filename); + expect(extension).toBe('png'); + }); + it('만약 파일에 .이 많을 경우.', () => { + const filename = 'asd.asd.asd.asd.test.png'; + const extension = service['getExtension'](filename); + expect(extension).toBe('png'); + }); + }); + + describe('isGreenEye 메서드 테스트', () => { + it('그린 아이의 심의를 통과하면, true를 리턴한다.', async () => { + (axios.post as jest.Mock).mockResolvedValue({ + data: { + images: [{ result: { adult: 0, porn: 0 } }], + }, + }); + const isGreen = await service['isGreenEye']( + imageBuffer, + imageId, + originalName, + ); + expect(isGreen).toBe(true); + }); + + it('그린 아이의 심의를 통과하지 못하면, false를 리턴한다.', async () => { + (axios.post as jest.Mock).mockResolvedValue({ + data: { + images: [{ result: { adult: 0.7, porn: 0.4 } }], + }, + }); + const isGreen = await service['isGreenEye']( + imageBuffer, + imageId, + originalName, + ); + expect(isGreen).toBe(false); + }); + + it('그린아이에 엑세스 요청이 reject 된다면 그린아이 에러를 던진다.', async () => { + (axios.post as jest.Mock).mockRejectedValue( + new NotAccessToGreenEyeException(), + ); + await expect( + service['isGreenEye'](imageBuffer, imageId, originalName), + ).rejects.toThrow(NotAccessToGreenEyeException); + }); + }); +}); diff --git a/BackEnd/src/images/images.service.ts b/BackEnd/src/images/images.service.ts index cc410520..40487b3a 100644 --- a/BackEnd/src/images/images.service.ts +++ b/BackEnd/src/images/images.service.ts @@ -4,6 +4,10 @@ import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { v4 as uuidv4 } from 'uuid'; import axios from 'axios'; import { ADULT_RATIO, PORN_RATIO } from './constant/images.constant'; +import { + NotAccessToGreenEyeException, + NotAccessToNCPException, +} from './exceptions/images.exception'; @Injectable() export class ImagesService { @@ -33,6 +37,22 @@ export class ImagesService { return; } + await this.sendToObjectStorage(imageId, imageBuffer, image); + + imageURL.push({ + imageName: image.originalname, + imageUrl: this.configService.getOrThrow('NCP_CDN_URL') + imageId, + }); + } + return imageURL; + } + + private async sendToObjectStorage( + imageId: string, + imageBuffer: Buffer, + image: Express.Multer.File, + ) { + try { await this.s3Client.send( new PutObjectCommand({ Bucket: this.configService.getOrThrow('NCP_BUCKET_NAME'), @@ -41,18 +61,17 @@ export class ImagesService { ContentType: image.mimetype, }), ); - - imageURL.push({ - imageName: image.originalname, - imageUrl: this.configService.getOrThrow('NCP_CDN_URL') + imageId, - }); + } catch (error) { + Logger.log(error); + throw new NotAccessToNCPException(); } - return imageURL; } private getExtension(imageName: string) { - return imageName.split('.').pop(); + const extension = imageName.split('.'); + return extension[extension.length - 1]; } + private async isGreenEye( imageBuffer: Buffer, imageId: string, @@ -85,7 +104,7 @@ export class ImagesService { } } catch (error) { Logger.log(error); - return false; + throw new NotAccessToGreenEyeException(); } return true; } diff --git a/BackEnd/src/images/intercepters/wetri-files.interceptor.ts b/BackEnd/src/images/intercepters/wetri-files.interceptor.ts new file mode 100644 index 00000000..62c97228 --- /dev/null +++ b/BackEnd/src/images/intercepters/wetri-files.interceptor.ts @@ -0,0 +1,16 @@ +import { Injectable, ExecutionContext, CallHandler } from '@nestjs/common'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { Observable, throwError } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { InvalidFileCountOrFieldNameException } from '../exceptions/images.exception'; + +@Injectable() +export class WetriFilesInterceptor extends FilesInterceptor('images', 5) { + intercept(context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe( + catchError((err) => { + throw new InvalidFileCountOrFieldNameException(); + }), + ); + } +} diff --git a/BackEnd/src/images/pipe/validate-files.pip.spec.ts b/BackEnd/src/images/pipe/validate-files.pip.spec.ts new file mode 100644 index 00000000..35fe23f5 --- /dev/null +++ b/BackEnd/src/images/pipe/validate-files.pip.spec.ts @@ -0,0 +1,49 @@ +import { ValidateFilesPipe } from './validate-files.pip'; +import { + FileSizeTooLargeException, + InvalidFileTypeException, +} from '../exceptions/images.exception'; +import { MAX_IMAGE_SIZE } from '../constant/images.constant'; + +describe('ValidateFilesPipe', () => { + const options = { + maxSize: MAX_IMAGE_SIZE, + fileType: ['image/png', 'image/jpeg'], + }; + const pipe = new ValidateFilesPipe(options); + + const mockFile = (size: number, mimetype: string): Express.Multer.File => + ({ + originalname: 'testfile', + mimetype, + size, + buffer: Buffer.alloc(size), + }) as Express.Multer.File; + + it('파일 크기가 정상적이고, 타입이 정상이라면 에러를 던지지 않는다.', () => { + const validFile = mockFile(MAX_IMAGE_SIZE / 2, 'image/png'); + const files = [validFile]; + + expect(() => pipe.transform(files)).not.toThrow(); + }); + + it('파일 크기만 크다면, 파일 크키가 크다는 오류를 던진다.', () => { + const largeFile = mockFile(MAX_IMAGE_SIZE * 2, 'image/png'); + const files = [largeFile]; + + expect(() => pipe.transform(files)).toThrow(FileSizeTooLargeException); + }); + + it('파일 타입이 다르다면, 파일 타입이 다르다는 오류를 발생시킨다.', () => { + const invalidTypeFile = mockFile(MAX_IMAGE_SIZE / 2, 'application/pdf'); + const files = [invalidTypeFile]; + + expect(() => pipe.transform(files)).toThrow(InvalidFileTypeException); + }); + it('만약 둘 다라면, 파일 크기가 크다는 오류를 발생시킨다.', () => { + const invalidTypeFile = mockFile(MAX_IMAGE_SIZE * 2, 'application/pdf'); + const files = [invalidTypeFile]; + + expect(() => pipe.transform(files)).toThrow(FileSizeTooLargeException); + }); +}); diff --git a/BackEnd/src/images/pipe/validate-files.pip.ts b/BackEnd/src/images/pipe/validate-files.pip.ts index b55ff01e..f12f63c9 100644 --- a/BackEnd/src/images/pipe/validate-files.pip.ts +++ b/BackEnd/src/images/pipe/validate-files.pip.ts @@ -1,5 +1,9 @@ import { Injectable, BadRequestException, PipeTransform } from '@nestjs/common'; import { FileUploadOptions } from '../interface/images.interface'; +import { + FileSizeTooLargeException, + InvalidFileTypeException, +} from '../exceptions/images.exception'; @Injectable() export class ValidateFilesPipe implements PipeTransform { @@ -17,16 +21,14 @@ export class ValidateFilesPipe implements PipeTransform { private validateFileSize(file: Express.Multer.File): void { const maxSize = this.options.maxSize; if (file.size > maxSize) { - throw new BadRequestException( - `File size too large: ${file.originalname}`, - ); + throw new FileSizeTooLargeException(); } } private validateFileType(file: Express.Multer.File): void { const allowedTypes = this.options.fileType; if (!allowedTypes.includes(file.mimetype)) { - throw new BadRequestException(`Invalid file type: ${file.originalname}`); + throw new InvalidFileTypeException(); } } } diff --git a/BackEnd/src/posts/dto/paginate-post.dto.ts b/BackEnd/src/posts/dto/paginate-post.dto.ts index afab11ec..771db40c 100644 --- a/BackEnd/src/posts/dto/paginate-post.dto.ts +++ b/BackEnd/src/posts/dto/paginate-post.dto.ts @@ -1,3 +1,3 @@ -import {BasePaginationDto} from "../../common/dto/base-pagination.dto"; +import { BasePaginationDto } from '../../common/dto/base-pagination.dto'; export class PaginatePostDto extends BasePaginationDto {} diff --git a/BackEnd/src/workouts/dto/workout-response.dto.spec.ts b/BackEnd/src/workouts/dto/workout-response.dto.spec.ts index bf012f43..21ac641f 100644 --- a/BackEnd/src/workouts/dto/workout-response.dto.spec.ts +++ b/BackEnd/src/workouts/dto/workout-response.dto.spec.ts @@ -2,24 +2,24 @@ import { WorkoutResDto } from './workout-response.dto'; import { Workout } from '../entities/workout.entity'; describe('WorkoutResDto', () => { - it('WorkoutResDto는 배열을 리턴하며 내부에는 id, name, icon이 존재한다.', () => { - const workout1 = new Workout(); - workout1.id = 1; - workout1.name = '달리기'; - workout1.icon = 'running.svg'; + it('WorkoutResDto는 배열을 리턴하며 내부에는 id, name, icon이 존재한다.', () => { + const workout1 = new Workout(); + workout1.id = 1; + workout1.name = '달리기'; + workout1.icon = 'running.svg'; - const workout2 = new Workout(); - workout2.id = 2; - workout2.name = '수영'; - workout2.icon = 'swimming.svg'; + const workout2 = new Workout(); + workout2.id = 2; + workout2.name = '수영'; + workout2.icon = 'swimming.svg'; - const dto = new WorkoutResDto(); - dto.data = [workout1, workout2]; + const dto = new WorkoutResDto(); + dto.data = [workout1, workout2]; - expect(dto.data).toBeInstanceOf(Array); - expect(dto.data[0]).toBeInstanceOf(Workout); - expect(dto.data[0].name).toBe('달리기'); - expect(dto.data[1].name).toBe('수영'); - expect(dto.data[1].icon).toBe('swimming.svg'); - }); -}); \ No newline at end of file + expect(dto.data).toBeInstanceOf(Array); + expect(dto.data[0]).toBeInstanceOf(Workout); + expect(dto.data[0].name).toBe('달리기'); + expect(dto.data[1].name).toBe('수영'); + expect(dto.data[1].icon).toBe('swimming.svg'); + }); +}); diff --git a/BackEnd/src/workouts/entities/workout.entity.spec.ts b/BackEnd/src/workouts/entities/workout.entity.spec.ts index 9112fb7c..00fb7800 100644 --- a/BackEnd/src/workouts/entities/workout.entity.spec.ts +++ b/BackEnd/src/workouts/entities/workout.entity.spec.ts @@ -2,34 +2,33 @@ import { Workout } from './workout.entity'; import { validate } from 'class-validator'; describe('Workout Entity', () => { - it('Workout에서 id, name, icon이 엔티티에 정의한대로 올바르면, 에러가 발생하지 않는다.', async () => { - const workout = new Workout(); - workout.id = 1; - workout.name = '달리기'; - workout.icon = 'running'; + it('Workout에서 id, name, icon이 엔티티에 정의한대로 올바르면, 에러가 발생하지 않는다.', async () => { + const workout = new Workout(); + workout.id = 1; + workout.name = '달리기'; + workout.icon = 'running'; - const errors = await validate(workout); - expect(errors).toHaveLength(0); - }); + const errors = await validate(workout); + expect(errors).toHaveLength(0); + }); - it('Workout에서 name이 공백이면, 에러가 발생한다.', async () => { - const workout = new Workout(); - workout.id = 1; - workout.name = ''; - workout.icon = 'running'; + it('Workout에서 name이 공백이면, 에러가 발생한다.', async () => { + const workout = new Workout(); + workout.id = 1; + workout.name = ''; + workout.icon = 'running'; - const errors = await validate(workout); - expect(errors).toHaveLength(1); - }); + const errors = await validate(workout); + expect(errors).toHaveLength(1); + }); - it('Workout에서 icon이 공백이면, 에러가 발생한다.', async () => { - const workout = new Workout(); - workout.id = 1; - workout.name = '달리기'; - workout.icon = ''; + it('Workout에서 icon이 공백이면, 에러가 발생한다.', async () => { + const workout = new Workout(); + workout.id = 1; + workout.name = '달리기'; + workout.icon = ''; - const errors = await validate(workout); - expect(errors).toHaveLength(1); - }); - -}); \ No newline at end of file + const errors = await validate(workout); + expect(errors).toHaveLength(1); + }); +}); diff --git a/BackEnd/src/workouts/entities/workout.entity.ts b/BackEnd/src/workouts/entities/workout.entity.ts index 23aecdfd..36b4fad4 100644 --- a/BackEnd/src/workouts/entities/workout.entity.ts +++ b/BackEnd/src/workouts/entities/workout.entity.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; import { Record } from '../../records/entities/records.entity'; -import {IsNotEmpty, IsString} from 'class-validator'; +import { IsNotEmpty, IsString } from 'class-validator'; @Entity() export class Workout {