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

[백한결] 휴대폰 인증 API #9

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
File renamed without changes.
Empty file modified setup.sh
100644 → 100755
Empty file.
16 changes: 14 additions & 2 deletions template/source/typeorm/app.module.ts → src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { Module } from '@nestjs/common';
import { Injectable, Logger, Module, NestMiddleware } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { addTransactionalDataSource } from 'typeorm-transactional';
import { DataSource } from 'typeorm';
import { Phone } from './modules/phone/entities/phone.entity';
import { PhoneModule } from './modules/phone/phone.module';
import { NextFunction, Request } from 'express';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { LoggingInterceptor } from './interceptors/logging.interceptor';

@Module({
imports: [
Expand All @@ -16,6 +21,7 @@ import { DataSource } from 'typeorm';
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
entities: [Phone],
synchronize: process.env.DB_SYNC === 'true',
timezone: 'Z',
};
Expand All @@ -28,8 +34,14 @@ import { DataSource } from 'typeorm';
return addTransactionalDataSource(new DataSource(options));
},
}),
PhoneModule,
],
controllers: [],
providers: [],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
],
})
export class AppModule {}
32 changes: 32 additions & 0 deletions src/interceptors/logging.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { catchError, Observable, tap, throwError } from 'rxjs';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
const now = Date.now();
const method = req.method;
const url = req.url;

return next.handle().pipe(
tap(() => {
const response = context.switchToHttp().getResponse();
const statusCode = response.statusCode;
const delay = Date.now() - now;
console.log(`${method} ${url} ${statusCode} ${delay}ms`);
}),
catchError((error) => {
const delay = Date.now() - now;
const statusCode = error.getStatus ? error.getStatus() : 500;
console.log(`${method} ${url} ${statusCode} ${delay}ms`);
return throwError(() => error);
}),
);
}
}
38 changes: 38 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { initializeTransactionalContext } from 'typeorm-transactional';
import { ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

async function bootstrap() {
initializeTransactionalContext();

const app = await NestFactory.create(AppModule);

app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
)

// TODO: 프로그램 구현
// Swagger
const config = new DocumentBuilder()
.setTitle('어쩌다Nest 과제')
.setDescription('어쩌다Nest 과제 API')
.setVersion('1.0')
.build();
const swaggerOptions = {
operationIdFactory: (controllerKey: string, methodKey: string) => methodKey,
};
const document = SwaggerModule.createDocument(app, config, swaggerOptions);
SwaggerModule.setup('api', app, document);

await app.listen(process.env.PORT || 8000);

console.log(`Application is running on: ${await app.getUrl()}`);
}

bootstrap();
15 changes: 15 additions & 0 deletions src/modules/phone/dto/send-code.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { IsNotEmpty, IsString, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class SendCodeDto {
@IsNotEmpty()
@IsString()
@ApiProperty({
example: '010-1111-1111',
description: '전화번호',
})
@Matches(/^010-\d{4}-\d{4}$/, {
message: '휴대전화 번호는 반드시 형식에 맞게 작성해야합니다.',
})
phoneNumber: string;
}
26 changes: 26 additions & 0 deletions src/modules/phone/dto/verify-code.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { IsNotEmpty, IsString, Length, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class VerifyCodeDto {
@IsNotEmpty()
@IsString()
@ApiProperty({
example: '010-1111-1111',
description: '전화번호',
})
@Matches(/^010-\d{4}-\d{4}$/, {
message: '휴대전화 번호는 반드시 형식에 맞게 작성해야합니다.',
})
phoneNumber: string;

@IsNotEmpty()
@IsString()
@ApiProperty({
example: '123456',
description: '인증번호',
})
@Length(6, 6, {
message: '코드는 여섯자리입니다.'
})
code: string;
}
16 changes: 16 additions & 0 deletions src/modules/phone/entities/phone.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class Phone {
@PrimaryGeneratedColumn()
id: number;

@Column()
phoneNumber: string;

@Column()
code: string;

@Column()
timestamp: Date;
}
36 changes: 36 additions & 0 deletions src/modules/phone/phone.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PhoneController } from './phone.controller';
import { PhoneService } from './phone.service';

describe('PhoneController', () => {
let controller: PhoneController;
let mockPhoneService: Partial<PhoneService>;

beforeEach(async () => {
mockPhoneService = {
sendCode: jest.fn((phoneNumber) => Promise.resolve({ code: '123456' })),
verifyCode: jest.fn((phoneNumber, code) => Promise.resolve({ result: true })),
};

const module: TestingModule = await Test.createTestingModule({
controllers: [PhoneController],
providers: [{ provide: PhoneService, useValue: mockPhoneService }],
}).compile();

controller = module.get<PhoneController>(PhoneController);
});

it('전화번호를 입력 후 인정번호를 받는다.', async () => {
const sendCodeDto = { phoneNumber: '01012345678' };
const result = await controller.sendCode(sendCodeDto);
expect(mockPhoneService.sendCode).toHaveBeenCalledWith(sendCodeDto.phoneNumber);
expect(result).toEqual({ code: '123456' });
});

it('전화번호와 인증번호 입력 후, 인증에 성공한다.', async () => {
const verifyCodeDto = { phoneNumber: '01012345678', code: '123456' };
const result = await controller.verifyCode(verifyCodeDto);
expect(mockPhoneService.verifyCode).toHaveBeenCalledWith(verifyCodeDto.phoneNumber, verifyCodeDto.code);
expect(result).toEqual({ result: true });
});
});
33 changes: 33 additions & 0 deletions src/modules/phone/phone.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { BadRequestException, Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { PhoneService } from './phone.service';
import { SendCodeDto } from './dto/send-code.dto';
import { VerifyCodeDto } from './dto/verify-code.dto';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';

@Controller('phone')
@ApiTags('phone')
export class PhoneController {
constructor(private readonly phoneService: PhoneService) {}

@Post('/send-code')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '인증번호 생성 API', description: '인증번호를 생성한다.'})
@ApiBody({ type: SendCodeDto })
@ApiResponse({ status: 201, description: '생성 완료', type: SendCodeDto })
@ApiResponse({ status: 400, description: '잘못된 요청' })
async sendCode(@Body() sendCodeDto: SendCodeDto) {
const { phoneNumber } = sendCodeDto;
return await this.phoneService.sendCode(phoneNumber);
}

@Post('/verify-code')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '인증번호 인증 API', description: '인증번호로 인증한다.'})
@ApiBody({ type: VerifyCodeDto })
@ApiResponse({ status: 200, description: '성공', type: VerifyCodeDto })
@ApiResponse({ status: 400, description: '잘못된 요청' })
async verifyCode(@Body() verifyCodeDto: VerifyCodeDto) {
const { phoneNumber, code } = verifyCodeDto;
return await this.phoneService.verifyCode(phoneNumber, code);
}
}
12 changes: 12 additions & 0 deletions src/modules/phone/phone.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Phone } from './entities/phone.entity';
import { PhoneController } from './phone.controller';
import { PhoneService } from './phone.service';

@Module({
imports: [TypeOrmModule.forFeature([Phone])],
controllers: [PhoneController],
providers: [PhoneService],
})
export class PhoneModule {}
67 changes: 67 additions & 0 deletions src/modules/phone/phone.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PhoneService } from './phone.service';
import { Phone } from './entities/phone.entity';
import { getRepositoryToken } from '@nestjs/typeorm';

describe('PhoneService', () => {
let service: PhoneService;
let mockRepository;

beforeEach(async () => {
mockRepository = {
delete: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
PhoneService,
{
provide: getRepositoryToken(Phone),
useValue: mockRepository,
},
],
}).compile();

service = module.get<PhoneService>(PhoneService);
});

it('전화번호를 입력 후 인정번호를 받는다.', async () => {
const phoneNumber = '01012345678';
const result = await service.sendCode(phoneNumber);

expect(mockRepository.delete).toHaveBeenCalledWith({ phoneNumber });
expect(mockRepository.save).toHaveBeenCalled();
expect(result.code).toBeDefined();
expect(result.code).toHaveLength(6);
});

it('형식에 맞지 않게 입력하면 400 BadRequest 에러를 반환한다.', async () => {
mockRepository.findOne.mockResolvedValue(null);

await expect(service.verifyCode('01012345678', '123456')).rejects.toThrow('인증번호가 일치하지 않습니다.');
});

it('인증번호가 만료된 후 인증하려하면 400 BadRequest 에러를 반환한다.', async () => {
const phoneNumber = '01012345678';
const code = '123456';
const oldTimestamp = new Date(Date.now() - 6 * 60000); // 6분 전

mockRepository.findOne.mockResolvedValue({ phoneNumber, code, timestamp: oldTimestamp });

await expect(service.verifyCode(phoneNumber, code)).rejects.toThrow('인증번호가 만료되었습니다.');
});

it('전화번호 입력 후, 인증에 성공한다.', async () => {
const phoneNumber = '01012345678';
const code = '123456';
const recentTimestamp = new Date();

mockRepository.findOne.mockResolvedValue({ phoneNumber, code, timestamp: recentTimestamp });

const result = await service.verifyCode(phoneNumber, code);

expect(result.result).toBeTruthy();
});
});
47 changes: 47 additions & 0 deletions src/modules/phone/phone.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Phone } from './entities/phone.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

@Injectable()
export class PhoneService {
constructor(
@InjectRepository(Phone)
private phoneRepository: Repository<Phone>,
) {}

async sendCode(phoneNumber: string): Promise<{ code: string }> {
const code = Math.floor(100000 + Math.random() * 900000).toString();

await this.phoneRepository.delete({ phoneNumber });

await this.phoneRepository.save({
phoneNumber,
code,
timestamp: new Date(),
});

return { code };
}

async verifyCode(phoneNumber: string, code: string): Promise<{ result: boolean }> {
const phoneRecord = await this.phoneRepository.findOne({
where: { phoneNumber, code }
});

if (!phoneRecord) {
throw new BadRequestException('인증번호가 일치하지 않습니다.');
}

const currentTime = new Date();
const codeTimestamp = new Date(phoneRecord.timestamp);
const difference = currentTime.getTime() - codeTimestamp.getTime();
const minutesDifference = difference / 60000;

if (minutesDifference > 5) {
throw new BadRequestException('인증번호가 만료되었습니다.');
}

return { result: true };
}
}
6 changes: 0 additions & 6 deletions template/environment/prisma/.env.example

This file was deleted.

Loading