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

[GWL-207] 이미지 업로드 API 테스트 코드 작성 및 리팩토링 #251

Merged
merged 10 commits into from
Dec 6, 2023
43 changes: 43 additions & 0 deletions BackEnd/src/images/dto/images.response.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
55 changes: 42 additions & 13 deletions BackEnd/src/images/dto/images.response.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
55 changes: 55 additions & 0 deletions BackEnd/src/images/exceptions/images.exception.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
29 changes: 14 additions & 15 deletions BackEnd/src/images/images.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
150 changes: 150 additions & 0 deletions BackEnd/src/images/images.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(ImagesService);
configService = module.get<ConfigService>(ConfigService);
s3Client = module.get<S3Client>(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);
});
});
});
Loading
Loading