diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 47f60a6..bd2958b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -35,6 +35,7 @@ model UserDetail { nickname String @unique intro String? profileImg String? + userLocation String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/aws/aws.s3.ts b/src/aws/aws.s3.ts index a76e805..64ccdeb 100644 --- a/src/aws/aws.s3.ts +++ b/src/aws/aws.s3.ts @@ -1,6 +1,10 @@ // src/aws/aws.s3.ts import * as AWS from 'aws-sdk'; -import { BadRequestException, Injectable } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; @Injectable() export class AwsS3Service { @@ -15,8 +19,19 @@ export class AwsS3Service { // S3 프로필 이미지 업로드 로직 async uploadFile(file) { + // 이미지 파일 Validation 체크 + const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + const SUPPORTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/gif']; if (!file) { - throw new BadRequestException('No file uploaded'); + throw new NotFoundException('이미지 파일을 선택해주세요.'); + } + if (file.size > MAX_FILE_SIZE) { + throw new BadRequestException('파일 크기는 5MB를 초과할 수 없습니다.'); + } + if (!SUPPORTED_FILE_TYPES.includes(file.mimetype)) { + throw new BadRequestException( + '지원되는 파일 형식은 JPEG, PNG, GIF 뿐입니다.' + ); } const params = { diff --git a/src/users/dto/create-user.dto.ts b/src/users/dto/create-user.dto.ts index 83825f1..d0e9945 100644 --- a/src/users/dto/create-user.dto.ts +++ b/src/users/dto/create-user.dto.ts @@ -69,6 +69,14 @@ export class CreateUserDto { }) profileImg?: string; + @IsString() + @IsOptional() + @ApiProperty({ + description: 'userLocation', + example: '서울시 강남구 ', + }) + userLocation?: string; + @IsString() @IsOptional() @ApiProperty({ diff --git a/src/users/dto/update-user.dto.ts b/src/users/dto/update-user.dto.ts index 1425339..4c39d93 100644 --- a/src/users/dto/update-user.dto.ts +++ b/src/users/dto/update-user.dto.ts @@ -6,6 +6,7 @@ import { Matches, MaxLength, MinLength, + IsOptional, } from 'class-validator'; export class UpdateUserDto { @@ -13,8 +14,8 @@ export class UpdateUserDto { @IsNotEmpty() @MinLength(2) @MaxLength(8) - //영어 또는 한글이 포함 @Matches(/^(?=.*[A-Za-z가-힣]).*[A-Za-z가-힣0-9]*$/) + @IsOptional() @ApiProperty({ description: 'nickname', example: '닉네임', @@ -22,16 +23,26 @@ export class UpdateUserDto { nickname: string; @IsString() + @IsOptional() @ApiProperty({ description: 'intro', example: '안녕하세요', }) intro: string; + @IsString() + @IsOptional() + @ApiProperty({ + description: 'email', + example: 'email@email.com', + }) + email: string; + @IsString() @IsNotEmpty() @MinLength(8) @MaxLength(15) + @IsOptional() //알파벳 포함 , 숫자 포함 , 특수문자 포함 @Matches(/^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]+$/) @ApiProperty({ @@ -42,6 +53,7 @@ export class UpdateUserDto { @IsString() @IsNotEmpty() + @IsOptional() @ApiProperty({ description: 'password confirm', example: 'abc123456789!', @@ -53,4 +65,12 @@ export class UpdateUserDto { example: false, }) nameChanged: boolean; + + @IsString() + @IsOptional() + @ApiProperty({ + description: 'userLocation', + example: '서울시 강남구', + }) + userLocation?: string; } diff --git a/src/users/dto/update-userPassword.dto.ts b/src/users/dto/update-userPassword.dto.ts new file mode 100644 index 0000000..c3352c1 --- /dev/null +++ b/src/users/dto/update-userPassword.dto.ts @@ -0,0 +1,10 @@ +// src/users/dto/update-user.dto.ts +import { ApiProperty } from '@nestjs/swagger'; + +export class UpdateUserPasswordDto { + @ApiProperty({ + example: '1234', // 예시 값을 설정 + description: 'The password of the user for password update', + }) + password: string; +} diff --git a/src/users/users.controller.spec.ts b/src/users/users.controller.spec.ts index 4723dce..bf0b876 100644 --- a/src/users/users.controller.spec.ts +++ b/src/users/users.controller.spec.ts @@ -2,39 +2,56 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; -// import { INestApplication } from '@nestjs/common'; -import * as request from 'supertest'; import { UsersModule } from './users.module'; import { PrismaService } from '../prisma/prisma.service'; +import { AwsS3Service } from '../aws/aws.s3'; describe('UsersController unit test', () => { // let app: INestApplication; let controller: UsersController; + let service: UsersService; - // const userServiceTest = { findAll: () => ['test1', 'test2'] }; - // console.log('userServiceTest:', userServiceTest); - let requestMock = {}; - let responseMock = {}; + const mockPrismaService = { + // 여기에 필요한 메서드를 mock 구현 + }; + + const mockUsersService = { + findOne: jest.fn((id: number) => { + return { userId: id, email: 'test@test.com' }; + }), + }; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [UsersController], imports: [UsersModule], - providers: [UsersService], - }) - // .overrideProvider(UsersService) - // .useValue(userServiceTest) - .compile(); + providers: [ + { provide: UsersService, useValue: mockUsersService }, + { provide: PrismaService, useValue: mockPrismaService }, + AwsS3Service, + ], + }).compile(); controller = module.get(UsersController); - // app = module.createNestApplication(); - // await app.init(); + service = module.get(UsersService); }); // jest test - it('should be defined', () => { - expect(controller).toBeDefined(); + // it('should be defined', () => { + // expect(controller).toBeDefined(); + // }); + + describe('Unit Tests', () => { + // TC01: findOne(id: number) 테스트 + it('TC01: findOne should return a user object', async () => { + const id = '1'; + expect(await controller.findOne(id)).toEqual({ + userId: 1, + email: 'test@test.com', + }); + expect(service.findOne).toHaveBeenCalledWith(id); + }); }); describe('Unit Tests', () => { diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index cee2272..c11f38b 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,11 +1,31 @@ -/* eslint-disable prettier/prettier */ // src/users/users.controller.ts -import { Controller, Req, Get, Post, Body, Patch, Param, Delete, NotFoundException, UseGuards } from '@nestjs/common'; +import { + Controller, + Req, + Get, + Post, + Body, + Patch, + Param, + Delete, + NotFoundException, + UseGuards, +} from '@nestjs/common'; import { UsersService } from './users.service'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; +import { UpdateUserPasswordDto } from './dto/update-userPassword.dto'; import { DeleteUserDto } from './dto/delete-user.dto'; -import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiResponse, ApiTags, ApiBody, ApiConsumes, ApiProperty } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiResponse, + ApiTags, + ApiBody, + ApiConsumes, +} from '@nestjs/swagger'; import { UserEntity } from './entities/user.entity'; import { JwtAccessAuthGuard } from 'src/auth/guards/jwt-auth.guard'; // import { JwtAccessAuthGuard } from '../auth/guards/jwt-auth.guard'; @@ -15,7 +35,6 @@ import { AwsS3Service } from 'src/aws/aws.s3'; import { UploadedFile, UseInterceptors } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; - // request에 user 객체를 추가하기 위한 인터페이스 interface RequestWithUser extends Request { user: User; @@ -25,8 +44,8 @@ interface RequestWithUser extends Request { export class UsersController { constructor( private readonly usersService: UsersService, - private readonly awsS3Service: AwsS3Service, - ) {} + private readonly awsS3Service: AwsS3Service + ) {} // 1. 유저를 생성한다. (회원가입) @ApiOperation({ summary: '회원가입' }) @@ -45,19 +64,21 @@ export class UsersController { const existingUser = await this.usersService.findByEmail({ email }); if (existingUser) { return { message: '201' }; - } else{ - return { message: '200'}; + } else { + return { message: '200' }; } } - + //닉네임 중복 검증 @Post('checkNickname') @ApiBody({}) @ApiOperation({ summary: '닉네임 중복 확인' }) async checkNickname(@Body() { nickname }: { nickname: string }) { - const existingNickname = await this.usersService.findByNickname({ nickname }); + const existingNickname = await this.usersService.findByNickname({ + nickname, + }); if (existingNickname) { - return{ message: '201' }; + return { message: '201' }; } else { //return res.status(200).json({ message: 'Nickname is available.' }); return { message: '200' }; @@ -71,11 +92,10 @@ export class UsersController { @ApiOperation({ summary: '회원 조회' }) @ApiOkResponse({ type: UserEntity, isArray: true }) async findAll() { - const users = await this.usersService.findAll(); if (!users) { throw new NotFoundException('Users does not exist'); - } + } const userEntity = users.map((user) => new UserEntity(user)); console.log(userEntity); // return users.map((user) => new UserEntity(user)); @@ -88,7 +108,7 @@ export class UsersController { @ApiBearerAuth() // Swagger 문서에 Bearer 토큰 인증 추가 @ApiOperation({ summary: '유저 본인 조회' }) async findMe(@Req() req: RequestWithUser) { - const { userId } = req.user; // request에 user 객체가 추가되었고 userId에 값 할당 + const { userId } = req.user; // request에 user 객체가 추가되었고 userId에 값 할당 const user = await this.usersService.findMe(userId); if (!user) { throw new NotFoundException('User does not exist'); @@ -110,9 +130,8 @@ export class UsersController { return user; } - // 5. user 정보 수정한다. - @Patch(':id') + @Patch('update') @UseGuards(JwtAccessAuthGuard) // passport를 사용하여 인증 확인 @ApiBearerAuth() // Swagger 문서에 Bearer 토큰 인증 추가 @ApiOperation({ summary: '회원 정보 수정' }) @@ -120,14 +139,40 @@ export class UsersController { @ApiResponse({ status: 400, description: '중복된 닉네임입니다' }) @ApiResponse({ status: 401, description: '패스워드가 일치하지 않습니다' }) @ApiResponse({ status: 404, description: '유저 정보가 존재하지 않습니다' }) - async update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { - const user = await this.usersService.findOne(+id); + async update( + @Req() req: RequestWithUser, + @Body() updateUserDto: UpdateUserDto + ) { + const { userId } = req.user; // request에 user 객체가 추가되었고 userId에 값 할당 + const user = await this.usersService.findOne(userId); if (!user) { throw new NotFoundException('User does not exist'); } - await this.usersService.update(+id, updateUserDto); - return {'message' : '회원 정보가 수정되었습니다'}; + await this.usersService.update(userId, updateUserDto); + return { message: '회원 정보가 수정되었습니다' }; + } + + // 5.1 user 비밀번호 변경한다 + @Patch('updatePassword') + @UseGuards(JwtAccessAuthGuard) // passport를 사용하여 인증 확인 + @ApiBearerAuth() // Swagger 문서에 Bearer 토큰 인증 추가 + @ApiOperation({ summary: '비밀번호 변경' }) + @ApiResponse({ status: 200, description: '비밀번호가 변경되었습니다' }) + @ApiResponse({ status: 404, description: '유저 정보가 존재하지 않습니다' }) + async updatePassword( + @Req() req: RequestWithUser, + @Body() updateUserPasswordDto: UpdateUserPasswordDto + ) { + const { userId } = req.user; // request에 user 객체가 추가되었고 userId에 값 할당 + const user = await this.usersService.findOne(userId); + + if (!user) { + throw new NotFoundException('User does not exist'); + } + + await this.usersService.updatePassword(userId, updateUserPasswordDto); + return { message: '비밀번호가 변경되었습니다' }; } // 6. 회원 탈퇴를 한다. @@ -135,14 +180,17 @@ export class UsersController { @UseGuards(JwtAccessAuthGuard) // passport를 사용하여 인증 확인 @ApiBearerAuth() // Swagger 문서에 Bearer 토큰 인증 추가 @ApiOperation({ summary: '회원 탈퇴' }) - async remove(@Req() req: RequestWithUser, @Body() DeleteUserDto: DeleteUserDto) { - const { userId } = req.user; // request에 user 객체가 추가되었고 userId에 값 할당 ) + async remove( + @Req() req: RequestWithUser, + @Body() deleteUserDto: DeleteUserDto + ) { + const { userId } = req.user; // request에 user 객체가 추가되었고 userId에 값 할당 ) const user = await this.usersService.findOne(userId); if (!user) { throw new NotFoundException('User does not exist'); } - await this.usersService.remove(userId, DeleteUserDto.password); - return {'message' : '탈퇴되었습니다'}; + await this.usersService.remove(userId, deleteUserDto.password); + return { message: '탈퇴되었습니다' }; } // 7. 사용자가 생성한 모임 리스트를 조회한다. @@ -157,7 +205,6 @@ export class UsersController { @Get(':id/joinedEvents') @ApiOperation({ summary: '내가 참가한 이벤트 조회' }) findJoinedEvents(@Param('id') id: string) { - // console.log('findJoinedEvents in users.controller.ts - id:', id); const joinedEvents = this.usersService.findJoinedEvents(+id); return joinedEvents; } @@ -166,7 +213,7 @@ export class UsersController { @Get(':id/bookmarkedEvents') @ApiOperation({ summary: '내가 북마크한 이벤트 조회' }) async findBookmarkedEvents(@Param('id') id: string) { - return await this.usersService.findBookmarkedEvents(+id); + return await this.usersService.findBookmarkedEvents(+id); } // 10. 사용자 유저 프로필 이미지를 업로드 한다. @@ -174,7 +221,7 @@ export class UsersController { @UseGuards(JwtAccessAuthGuard) // passport를 사용하여 인증 확인 @ApiBearerAuth() // Swagger 문서에 Bearer 토큰 인증 추가 @ApiOperation({ summary: '프로필 이미지 업로드' }) - @ApiConsumes('multipart/form-data') + @ApiConsumes('multipart/form-data') @UseInterceptors(FileInterceptor('file')) @ApiBody({ description: 'User profile image', @@ -198,29 +245,19 @@ export class UsersController { throw new NotFoundException('User does not exist'); } - // 이미지 파일 Validation 체크 - const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB - const SUPPORTED_FILE_TYPES = ["image/jpeg", "image/png", "image/gif"]; - if (!file) { - throw new NotFoundException('이미지 파일을 선택해주세요.'); - } - if (file.size > MAX_FILE_SIZE) { - throw new NotFoundException('파일 크기는 5MB를 초과할 수 없습니다.'); - } - if (!SUPPORTED_FILE_TYPES.includes(file.mimetype)) { - throw new NotFoundException('지원되는 파일 형식은 JPEG, PNG, GIF 뿐입니다.'); - } - // 이미지를 s3에 업로드한다. - const uploadedFile = await this.awsS3Service.uploadFile(file) as { Location: string }; + const uploadedFile = (await this.awsS3Service.uploadFile(file)) as { + Location: string; + }; // s3에 업로드된 이미지 URL을 DB에 저장한다. - const s3ProfileImgURL = await this.usersService.updateProfileImage(userId, uploadedFile.Location); - + const s3ProfileImgURL = await this.usersService.updateProfileImage( + userId, + uploadedFile.Location + ); return { - 'message': '이미지가 업로드되었습니다', - 'profileImgURL' : s3ProfileImgURL, - } + message: '이미지가 업로드되었습니다', + profileImgURL: s3ProfileImgURL, + }; } } - diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 3e3e395..095d74c 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -3,6 +3,7 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; +import { UpdateUserPasswordDto } from './dto/update-userPassword.dto'; import { PrismaService } from 'src/prisma/prisma.service'; import * as bcrypt from 'bcrypt'; import { User, UserDetail } from '@prisma/client'; @@ -98,10 +99,11 @@ export class UsersService { // 3. 유저 본인 조회 async findMe(userId: number) { - return await this.prisma.user.findUnique({ + const user = await this.prisma.user.findUnique({ where: { userId }, include: { UserDetail: true }, - }); + }); + return user; } // 3. userId를 통한 유저 조회 @@ -117,15 +119,10 @@ export class UsersService { return user; } - // 4. 이메일을 통한 유저 찾기 - // findByEmail({ email }: IUsersServiceFindByEmail): Promise { - // // 이코드는 여러번 재사용 될 수 있기 떄문에 따로 빼줌 - // return this.prisma.user.findUnique({ where: { email } }); - // } // 5. user 정보 수정한다. async update(id: number, updateUserDto: UpdateUserDto) { - const { nickname, intro, confirmPassword, nameChanged } = updateUserDto; + const { nickname, intro, confirmPassword, nameChanged, userLocation } = updateUserDto; const user = await this.prisma.user.findUnique({ where: { userId: id }, @@ -133,9 +130,9 @@ export class UsersService { if (!user) { throw new BadRequestException('유저 정보가 존재하지 않습니다.'); } - if (!nameChanged) { + // 자기소개, 유저주소 업데이트 // nameChanged == false 면 닉네임에는 변화가 없다는 것임으로 닉네임을 제외한 나머지 정보만 업데이트 // 패스워드, 패스워드 확인 일치 여부 확인 const isPasswordMatching = await bcrypt.compare(confirmPassword, user.password); @@ -148,13 +145,16 @@ export class UsersService { where: { userDetailId: user.userId}, data: { intro: intro, + userLocation: userLocation, }, }); return updatedUser; } else { + // 닉네임, 자기소개 업데이트 // nameChanged = true 면 닉네임을 바꿨다는 거니까 닉네임을 포함해서 업데이트 + // 중복된 닉네임 확인 const existingNickname = await this.prisma.userDetail.findUnique({ where: { nickname }, @@ -175,13 +175,37 @@ export class UsersService { where: { userDetailId: user.userId}, data: { intro: intro, - nickname: nickname + nickname: nickname, + userLocation: userLocation, }, }); return updatedUser; } } + // 5.1 update 유저 정보 수정 - 패스워드 변경 + async updatePassword(id: number, updateUserPasswordDto: UpdateUserPasswordDto) { + const newPassword = updateUserPasswordDto.password; + + // 패스워드 암호화 + const hashedNewPassword = await bcrypt.hash(newPassword, 10); + + // 유저 존재 여부 확인 + const user = await this.prisma.user.findUnique({ + where: { userId: id }, + }); + if (!user) { + throw new BadRequestException('유저 정보가 존재하지 않습니다.'); + } + + // password 업데이트 + const updatedUserPassword = await this.prisma.user.update({ + where: { userId: id }, + data: { password: hashedNewPassword }, + }); + return updatedUserPassword; + } + // 6. 회원 탈퇴를 한다. async remove(userId: number, password: string) { const user = await this.prisma.user.findUnique({