diff --git a/api/libs/auth/src/auth.controller.ts b/api/libs/auth/src/auth.controller.ts index 8ac7dce..c31360a 100644 --- a/api/libs/auth/src/auth.controller.ts +++ b/api/libs/auth/src/auth.controller.ts @@ -14,9 +14,8 @@ import { ApiGet, ApiPostOk, } from '@app/common/decorators/http-method.decorator'; -import { User } from '@app/user/domain/mongo.user.entity'; import { ReqUser } from '@app/common/decorators/req-user.decorator'; -import { CreateUserApiDto, CreateUserDto } from '@app/user/dto/modify-user.dto'; +import { CreateUserApiDto, CreateUserDto } from '@app/user/dto/create-user.dto'; import { Auth, RegisterAuth } from '@app/common/decorators/auth.decorator'; import { LogoutResponseDto, @@ -25,6 +24,8 @@ import { } from './dto/auth-response.dto'; import { FindOneUserResponseDto } from '@app/user/dto/user-response.dto'; import { UserInfo } from './types/user-info.type'; +import { UserDto } from '@app/user/dto/user.dto'; +import { UserEntity } from '@app/user/domain/user.entity'; @ApiTags('Auth') @Controller('auth') @@ -41,8 +42,8 @@ export class AuthController { @ApiGet(FindOneUserResponseDto) @Auth() @Get('me') - async me(@ReqUser() user: User) { - return user; + async me(@ReqUser() user: UserEntity) { + return UserDto.fromEntity(user); } /** diff --git a/api/libs/auth/src/auth.service.ts b/api/libs/auth/src/auth.service.ts index 796bd31..c4b98d8 100644 --- a/api/libs/auth/src/auth.service.ts +++ b/api/libs/auth/src/auth.service.ts @@ -1,8 +1,12 @@ import { UserService } from '@app/user/user.service'; -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { AuthRepository } from './repositories/auth.repository'; -import { CreateUserDto } from '@app/user/dto/modify-user.dto'; +import { CreateUserDto } from '@app/user/dto/create-user.dto'; import { HttpService } from '@nestjs/axios'; import { GoogleLoginDto, @@ -81,15 +85,21 @@ export class AuthService { googleLoginDto: GoogleLoginDto, ): Promise { const { accessToken } = googleLoginDto; - const { data }: { data: GoogleApiResponseType } = - await this.httpService.axiosRef.get( - 'https://www.googleapis.com/oauth2/v2/userinfo', - { - headers: { - Authorization: `Bearer ${accessToken}`, + let data: GoogleApiResponseType; + try { + data = ( + await this.httpService.axiosRef.get( + 'https://www.googleapis.com/oauth2/v2/userinfo', + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, }, - }, - ); + ) + ).data; + } catch (err) { + throw new UnauthorizedException(); + } const { email, name: username } = data; return await this.OAuthLoginByEmail({ email, username } as UserInfo); } @@ -99,12 +109,21 @@ export class AuthService { kakaoLoginDto: KakaoLoginDto, ): Promise { const { accessToken } = kakaoLoginDto; - const { data }: { data: KakaoApiResponseType } = - await this.httpService.axiosRef.get('https://kapi.kakao.com/v2/user/me', { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); + let data: KakaoApiResponseType; + try { + data = ( + await this.httpService.axiosRef.get( + 'https://kapi.kakao.com/v2/user/me', + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ) + ).data; + } catch (err) { + throw new UnauthorizedException(); + } const { email } = data.kakao_account; const { nickname: username } = data.kakao_account.profile; return await this.OAuthLoginByEmail({ email, username } as UserInfo); diff --git a/api/libs/auth/src/dto/oauth.dto.ts b/api/libs/auth/src/dto/oauth.dto.ts index d24e028..4a23a60 100644 --- a/api/libs/auth/src/dto/oauth.dto.ts +++ b/api/libs/auth/src/dto/oauth.dto.ts @@ -1,38 +1,53 @@ import { IsNotEmpty, IsString } from 'class-validator'; -import { Expose } from 'class-transformer'; -import { ApiProperty } from '@nestjs/swagger'; import { UserDto } from '@app/user/dto/user.dto'; +import { ApiExpose } from '@app/common/decorators/api-expose.decorator'; +import { Type } from 'class-transformer'; export class GoogleLoginDto { - @ApiProperty({ name: 'access_token' }) - @Expose({ name: 'access_token' }) + @ApiExpose({ name: 'access_token' }) @IsString() @IsNotEmpty() readonly accessToken: string; + + constructor(accessToken: string) { + this.accessToken = accessToken; + } } export class KakaoLoginDto { - @ApiProperty({ name: 'access_token' }) - @Expose({ name: 'access_token' }) + @ApiExpose({ name: 'access_token' }) @IsString() @IsNotEmpty() readonly accessToken: string; + + constructor(accessToken: string) { + this.accessToken = accessToken; + } } export class OAuthLoginSessionDto { - @ApiProperty({ name: 'is_exist' }) - @Expose({ name: 'is_exist' }) + @ApiExpose({ name: 'is_exist' }) readonly isExist: boolean; - @ApiProperty({ name: 'session_token' }) - @Expose({ name: 'session_token' }) + @ApiExpose({ name: 'session_token' }) readonly sessionToken?: string; - @ApiProperty({ name: 'user' }) - @Expose({ name: 'user' }) + @Type(() => UserDto) + @ApiExpose({ name: 'user' }) readonly user?: UserDto; - @ApiProperty({ name: 'register_token' }) - @Expose({ name: 'register_token' }) + @ApiExpose({ name: 'register_token' }) readonly registerToken?: string; + + constructor(props: { + isExist: boolean; + sessionToken?: string; + user?: UserDto; + registerToken?: string; + }) { + this.isExist = props.isExist; + this.sessionToken = props.sessionToken; + this.user = props.user; + this.registerToken = props.registerToken; + } } diff --git a/api/libs/auth/src/dto/session.dto.ts b/api/libs/auth/src/dto/session.dto.ts index 653acd4..54a7f46 100644 --- a/api/libs/auth/src/dto/session.dto.ts +++ b/api/libs/auth/src/dto/session.dto.ts @@ -1,4 +1,4 @@ -import { Expose } from 'class-transformer'; +import { Expose, Transform } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; import { SessionEntity } from '@app/auth/domain/session.entity'; @@ -6,15 +6,23 @@ export class SessionDto { @ApiProperty({ name: 'id' }) @Expose({ name: 'id' }) readonly id: number; + @ApiProperty({ name: 'session_token' }) @Expose({ name: 'session_token' }) readonly sessionToken: string; + @ApiProperty({ name: 'user_id' }) @Expose({ name: 'user_id' }) readonly userId: number; + + @Transform(({ value }) => new Date(value), { toClassOnly: true }) + @Transform(({ value }) => value.toISOString(), { toPlainOnly: true }) @ApiProperty({ name: 'created_at' }) @Expose({ name: 'created_at' }) readonly createdAt: Date; + + @Transform(({ value }) => new Date(value), { toClassOnly: true }) + @Transform(({ value }) => value.toISOString(), { toPlainOnly: true }) @ApiProperty({ name: 'updated_at' }) @Expose({ name: 'updated_at' }) readonly updatedAt: Date; diff --git a/api/libs/auth/src/guards/register-auth.guard.ts b/api/libs/auth/src/guards/register-auth.guard.ts index 4d08619..cf3f430 100644 --- a/api/libs/auth/src/guards/register-auth.guard.ts +++ b/api/libs/auth/src/guards/register-auth.guard.ts @@ -1,5 +1,4 @@ import { - BadRequestException, CanActivate, ExecutionContext, Injectable, @@ -7,14 +6,10 @@ import { } from '@nestjs/common'; import { Request } from 'express'; import { AuthService } from '../auth.service'; -import { UserService } from '@app/user/user.service'; @Injectable() export class RegisterAuthGuard implements CanActivate { - constructor( - private readonly authService: AuthService, - private readonly userService: UserService, - ) {} + constructor(private readonly authService: AuthService) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); @@ -23,14 +18,9 @@ export class RegisterAuthGuard implements CanActivate { throw new UnauthorizedException(); } try { - const userInfo = await this.authService.verifyRegisterToken( + request['user'] = await this.authService.verifyRegisterToken( registerToken, ); - const user = await this.userService.findByEmail(userInfo.email); - if (user) { - throw new BadRequestException(); - } - request['user'] = userInfo; } catch { throw new UnauthorizedException(); } diff --git a/api/libs/auth/test/fixture/auth.fixture.ts b/api/libs/auth/test/fixture/auth.fixture.ts new file mode 100644 index 0000000..a4ae7a8 --- /dev/null +++ b/api/libs/auth/test/fixture/auth.fixture.ts @@ -0,0 +1,39 @@ +import { SessionEntity } from '@app/auth/domain/session.entity'; +import { UserInfo } from '@app/auth/types/user-info.type'; +import { LoginSessionDto } from '@app/auth/dto/token.dto'; +import { + GoogleLoginDto, + KakaoLoginDto, + OAuthLoginSessionDto, +} from '@app/auth/dto/oauth.dto'; +import { userDto, userEntity } from '../../../user/test/fixture/user.fixture'; + +export const sessionEntity: SessionEntity = { + id: 1, + sessionToken: 'token', + userId: 1, + user: userEntity, + createdAt: new Date(), + updatedAt: new Date(), +}; + +export const userInfo: UserInfo = { + email: 'test@test.com', + username: 'test', +}; + +export const loginSessionDto: LoginSessionDto = { + sessionToken: 'sessToken', + user: userDto, +}; + +export const googleLoginDto: GoogleLoginDto = new GoogleLoginDto('accessToken'); + +export const kakaoLoginDto: KakaoLoginDto = new KakaoLoginDto('accessToken'); + +export const oAuthLoginSessionDto: OAuthLoginSessionDto = + new OAuthLoginSessionDto({ + isExist: true, + sessionToken: loginSessionDto.sessionToken, + user: loginSessionDto.user, + }); diff --git a/api/libs/auth/test/fixture/mocked-provider.ts b/api/libs/auth/test/fixture/mocked-provider.ts new file mode 100644 index 0000000..fafdf65 --- /dev/null +++ b/api/libs/auth/test/fixture/mocked-provider.ts @@ -0,0 +1,31 @@ +import { AuthService } from '@app/auth/auth.service'; +import { SessionAuthGuard } from '@app/auth/guards/session-auth.guard'; +import { RegisterAuthGuard } from '@app/auth/guards/register-auth.guard'; + +export const MockedAuthService = { + provide: AuthService, + useValue: { + register: jest.fn(), + verifyRegisterToken: jest.fn(), + findBySessionToken: jest.fn(), + login: jest.fn(), + OAuthLoginByEmail: jest.fn(), + googleLogin: jest.fn(), + kakaoLogin: jest.fn(), + logout: jest.fn(), + }, +}; + +export const MockedSessionAuthGuard = { + provide: SessionAuthGuard, + useValue: { + canActivate: jest.fn(), + }, +}; + +export const MockedRegisterAuthGuard = { + provide: RegisterAuthGuard, + useValue: { + canActivate: jest.fn(), + }, +}; diff --git a/api/libs/auth/test/integration/auth.controller.spec.ts b/api/libs/auth/test/integration/auth.controller.spec.ts deleted file mode 100644 index ba7e71a..0000000 --- a/api/libs/auth/test/integration/auth.controller.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AuthService } from '@app/auth/auth.service'; -import { AuthController } from '@app/auth/auth.controller'; -import { AuthRepository } from '@app/auth/repositories/mongo.auth.repository'; -import { UserService } from '@app/user/user.service'; - -describe('AuthController', () => { - let controller: AuthController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [AuthController], - providers: [ - { provide: AuthService, useValue: {} }, - { provide: AuthRepository, useValue: {} }, - { provide: UserService, useValue: {} }, - ], - }).compile(); - - controller = module.get(AuthController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/api/libs/auth/test/unit/auth.controller.spec.ts b/api/libs/auth/test/unit/auth.controller.spec.ts new file mode 100644 index 0000000..9432370 --- /dev/null +++ b/api/libs/auth/test/unit/auth.controller.spec.ts @@ -0,0 +1,176 @@ +import { + HttpStatus, + INestApplication, + NotFoundException, +} from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { AuthController } from '@app/auth/auth.controller'; +import { AuthService } from '@app/auth/auth.service'; +import request from 'supertest'; +import { instanceToPlain } from 'class-transformer'; +import { MockedAuthService } from '../fixture/mocked-provider'; +import { globalInhancers } from '@app/common/global-inhancers'; +import { + googleLoginDto, + kakaoLoginDto, + loginSessionDto, + oAuthLoginSessionDto, + sessionEntity, + userInfo, +} from '../fixture/auth.fixture'; +import { + createUserApiDto, + userDto, +} from '../../../user/test/fixture/user.fixture'; + +describe('AuthController', () => { + let app: INestApplication; + let authService: AuthService; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [AuthController], + providers: [MockedAuthService, ...globalInhancers], + }).compile(); + app = moduleRef.createNestApplication(); + await app.init(); + authService = moduleRef.get(AuthService); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('/auth/me (GET)', () => { + it('should return login User dto', async () => { + jest + .spyOn(authService, 'findBySessionToken') + .mockResolvedValue(sessionEntity); + + const res = ( + await request(app.getHttpServer()) + .get('/auth/me') + .set('Authorization', 'Bearer token') + .expect(HttpStatus.OK) + ).body; + + expect(res.success).toBe(true); + expect(res.data).toEqual(instanceToPlain(userDto)); + }); + + it('should return 401 if session not found', async () => { + jest + .spyOn(authService, 'findBySessionToken') + .mockRejectedValue(NotFoundException); + + const res = ( + await request(app.getHttpServer()) + .get('/auth/me') + .set('Authorization', 'Bearer token') + .expect(HttpStatus.UNAUTHORIZED) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'UnauthorizedException', + message: 'Unauthorized', + }), + ); + }); + }); + + describe('/auth/register (POST)', () => { + it('should return login response', async () => { + jest + .spyOn(authService, 'verifyRegisterToken') + .mockResolvedValue(userInfo); + jest.spyOn(authService, 'register').mockResolvedValue(loginSessionDto); + + const res = ( + await request(app.getHttpServer()) + .post('/auth/register') + .set('Authorization', 'Bearer token') + .send(createUserApiDto) + .expect(HttpStatus.OK) + ).body; + + expect(res.success).toBe(true); + expect(res.data).toEqual(instanceToPlain(loginSessionDto)); + }); + + it('should return 401 if register token is invalid', async () => { + jest.spyOn(authService, 'verifyRegisterToken').mockRejectedValue(Error); + + const res = ( + await request(app.getHttpServer()) + .post('/auth/register') + .set('Authorization', 'Bearer token') + .send(createUserApiDto) + .expect(HttpStatus.UNAUTHORIZED) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'UnauthorizedException', + message: 'Unauthorized', + }), + ); + }); + }); + + describe('/auth/google (POST)', () => { + it('should return login response', async () => { + jest + .spyOn(authService, 'googleLogin') + .mockResolvedValue(oAuthLoginSessionDto); + + const res = ( + await request(app.getHttpServer()) + .post('/auth/google') + .send(instanceToPlain(googleLoginDto)) + .expect(HttpStatus.OK) + ).body; + + expect(res.success).toBe(true); + expect(res.data).toEqual(instanceToPlain(oAuthLoginSessionDto)); + }); + }); + + describe('/auth/kakao (POST)', () => { + it('should return login response', async () => { + jest + .spyOn(authService, 'kakaoLogin') + .mockResolvedValue(oAuthLoginSessionDto); + + const res = ( + await request(app.getHttpServer()) + .post('/auth/kakao') + .send(instanceToPlain(kakaoLoginDto)) + .expect(HttpStatus.OK) + ).body; + + expect(res.success).toBe(true); + expect(res.data).toEqual(instanceToPlain(oAuthLoginSessionDto)); + }); + }); + + describe('/auth/logout (POST)', () => { + it('should return logout response', async () => { + jest + .spyOn(authService, 'findBySessionToken') + .mockResolvedValue(sessionEntity); + jest.spyOn(authService, 'logout').mockResolvedValue(undefined); + + const res = ( + await request(app.getHttpServer()) + .post('/auth/logout') + .set('Authorization', 'Bearer token') + .expect(HttpStatus.OK) + ).body; + + expect(res.success).toBe(true); + }); + }); +}); diff --git a/api/libs/auth/test/unit/auth.service.spec.ts b/api/libs/auth/test/unit/auth.service.spec.ts index 8083fae..2037948 100644 --- a/api/libs/auth/test/unit/auth.service.spec.ts +++ b/api/libs/auth/test/unit/auth.service.spec.ts @@ -4,7 +4,7 @@ import { AuthRepository } from '@app/auth/repositories/auth.repository'; import { UserService } from '@app/user/user.service'; import { JwtService } from '@nestjs/jwt'; import { UserEntity } from '@app/user/domain/user.entity'; -import { CreateUserDto } from '@app/user/dto/modify-user.dto'; +import { CreateUserDto } from '@app/user/dto/create-user.dto'; import { LoginSessionDto } from '@app/auth/dto/token.dto'; import { OAuthLoginSessionDto } from '@app/auth/dto/oauth.dto'; import { UserDto } from '@app/user/dto/user.dto'; diff --git a/api/libs/common/src/decorators/http-method.decorator.ts b/api/libs/common/src/decorators/http-method.decorator.ts index 6d2b17a..0b07ca5 100644 --- a/api/libs/common/src/decorators/http-method.decorator.ts +++ b/api/libs/common/src/decorators/http-method.decorator.ts @@ -8,22 +8,53 @@ import { } from '@nestjs/swagger'; import { BadRequestResponse, + ErrorResponse, NotFoundResponse, } from '../dto/error-response.dto'; -export const ApiPostCreated = (responseType: any) => { +export const ApiPostCreated = ( + responseType: any, + ...domainExceptions: (typeof ErrorResponse)[] +) => { return applyDecorators( - ApiBadRequestResponse({ type: BadRequestResponse }), + ApiBadRequestResponse({ + content: { + 'application/json': { + examples: domainExceptions.reduce( + (list, schema) => { + list[schema.name] = { value: new schema() }; + return list; + }, + { BadRequestResponse: { value: new BadRequestResponse() } }, + ), + }, + }, + }), ApiCreatedResponse({ type: responseType }), HttpCode(201), ); }; -export const ApiPostOk = (responseType: any) => { +export const ApiPostOk = ( + responseType: any, + ...domainExceptions: (typeof ErrorResponse)[] +) => { return applyDecorators( + ApiBadRequestResponse({ + content: { + 'application/json': { + examples: domainExceptions.reduce( + (list, schema) => { + list[schema.name] = { value: new schema() }; + return list; + }, + { BadRequestResponse: { value: new BadRequestResponse() } }, + ), + }, + }, + }), HttpCode(200), ApiOkResponse({ type: responseType }), - ApiBadRequestResponse({ type: BadRequestResponse }), ); }; @@ -36,25 +67,71 @@ export const ApiGet = (responseType: any) => { ); }; -export const ApiPatch = (responseType: any) => { +export const ApiPatch = ( + responseType: any, + ...domainExceptions: (typeof ErrorResponse)[] +) => { return applyDecorators( + ApiBadRequestResponse({ + content: { + 'application/json': { + examples: domainExceptions.reduce( + (list, schema) => { + list[schema.name] = { value: new schema() }; + return list; + }, + { BadRequestResponse: { value: new BadRequestResponse() } }, + ), + }, + }, + }), HttpCode(200), ApiNotFoundResponse({ type: NotFoundResponse }), - ApiBadRequestResponse({ type: BadRequestResponse }), ApiOkResponse({ type: responseType }), ); }; -export const ApiDeleteOk = (responseType: any) => { +export const ApiDeleteOk = ( + responseType: any, + ...domainExceptions: (typeof ErrorResponse)[] +) => { return applyDecorators( + ApiBadRequestResponse({ + content: { + 'application/json': { + examples: domainExceptions.reduce( + (list, schema) => { + list[schema.name] = { value: new schema() }; + return list; + }, + { BadRequestResponse: { value: new BadRequestResponse() } }, + ), + }, + }, + }), HttpCode(200), ApiNotFoundResponse({ type: NotFoundResponse }), ApiOkResponse(responseType), ); }; -export const ApiDeleteNoContent = () => { +export const ApiDeleteNoContent = ( + ...domainExceptions: (typeof ErrorResponse)[] +) => { return applyDecorators( + ApiBadRequestResponse({ + content: { + 'application/json': { + examples: domainExceptions.reduce( + (list, schema) => { + list[schema.name] = { value: new schema() }; + return list; + }, + { BadRequestResponse: { value: new BadRequestResponse() } }, + ), + }, + }, + }), HttpCode(204), ApiNotFoundResponse({ type: NotFoundResponse }), ApiNoContentResponse(), diff --git a/api/libs/common/src/dto/error-response.dto.ts b/api/libs/common/src/dto/error-response.dto.ts index 8d207bf..14438ca 100644 --- a/api/libs/common/src/dto/error-response.dto.ts +++ b/api/libs/common/src/dto/error-response.dto.ts @@ -6,7 +6,7 @@ import { UnauthorizedException, } from '@nestjs/common'; -class ErrorResponse { +export class ErrorResponse { success = false; timestamp = new Date().toISOString(); path = '/path/of/the/route/handler'; diff --git a/api/libs/common/src/exception-filters/all-exception.filter.ts b/api/libs/common/src/exception-filters/all-exception.filter.ts index 462b026..dc049f2 100644 --- a/api/libs/common/src/exception-filters/all-exception.filter.ts +++ b/api/libs/common/src/exception-filters/all-exception.filter.ts @@ -28,7 +28,10 @@ export class AllExceptionsFilter implements ExceptionFilter { let error: string | object; if (exception instanceof HttpException) { - error = exception.getResponse(); + error = { + name: exception.name, + message: exception.message, + }; } else if (exception instanceof DomainException) { error = exception.getResponse(); } else { @@ -39,7 +42,6 @@ export class AllExceptionsFilter implements ExceptionFilter { const responseBody = { success: false, - statusCode: httpStatus, error, timestamp: new Date().toISOString(), path: httpAdapter.getRequestUrl(ctx.getRequest()), diff --git a/api/libs/common/src/exception-filters/exception.ts b/api/libs/common/src/exception-filters/exception.ts index 7cfab6f..0149c94 100644 --- a/api/libs/common/src/exception-filters/exception.ts +++ b/api/libs/common/src/exception-filters/exception.ts @@ -1,5 +1,5 @@ export class DomainException extends Error { - getResponse(): string | object { + getResponse() { return { name: this.constructor.name, message: this.message, diff --git a/api/libs/common/src/global-inhancers.ts b/api/libs/common/src/global-inhancers.ts new file mode 100644 index 0000000..aba65f9 --- /dev/null +++ b/api/libs/common/src/global-inhancers.ts @@ -0,0 +1,29 @@ +import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; +import { TransformInterceptor } from '@app/common/interceptors/transform.interceptor'; +import { + ClassSerializerInterceptor, + Provider, + ValidationPipe, +} from '@nestjs/common'; +import { AllExceptionsFilter } from '@app/common/exception-filters/all-exception.filter'; + +export const globalInhancers: Provider[] = [ + { + provide: APP_INTERCEPTOR, + useClass: TransformInterceptor, + }, + { + provide: APP_INTERCEPTOR, + useClass: ClassSerializerInterceptor, + }, + { + provide: APP_FILTER, + useClass: AllExceptionsFilter, + }, + { + provide: APP_PIPE, + useValue: new ValidationPipe({ + transform: true, + }), + }, +]; diff --git a/api/libs/recipe/src/controllers/recipe-bookmark.controller.ts b/api/libs/recipe/src/controllers/recipe-bookmark.controller.ts index acfe8fe..5d8b0b8 100644 --- a/api/libs/recipe/src/controllers/recipe-bookmark.controller.ts +++ b/api/libs/recipe/src/controllers/recipe-bookmark.controller.ts @@ -19,6 +19,9 @@ import { Auth } from '@app/common/decorators/auth.decorator'; import { CreateRecipeBookmarkResponseDto, FindRecipeBookmarksResponseDto, + RecipeBookmarkDuplicateResponseDto, + RecipeNotExistsResponseDto, + UserNotExistsResponseDto, } from '../dto/recipe-bookmark/recipe-bookmark-response.dto'; import { CreateRecipeBookmarkDto } from '../dto/recipe-bookmark/create-recipe-bookmark.dto'; import { ReqUser } from '@app/common/decorators/req-user.decorator'; @@ -32,7 +35,12 @@ export class RecipeBookmarkController { constructor(private readonly recipeBookmarkService: RecipeBookmarkService) {} @Auth() - @ApiPostCreated(CreateRecipeBookmarkResponseDto) + @ApiPostCreated( + CreateRecipeBookmarkResponseDto, + RecipeNotExistsResponseDto, + UserNotExistsResponseDto, + RecipeBookmarkDuplicateResponseDto, + ) @Post() async create( @Body() createRecipeBookmarkDto: CreateRecipeBookmarkDto, @@ -46,7 +54,7 @@ export class RecipeBookmarkController { } /** - * ## Find All Bookmarked Recipe by User with pagenation + * ## Find All Bookmarked Recipe by User with pagination * * Response DTO's id is recipe id. * Recipe Bookmark id is recipe_bookmark_id. @@ -67,7 +75,7 @@ export class RecipeBookmarkController { @Auth() @ApiDeleteNoContent() @Delete(':id') - async remove(@Param('id', ParseIntPipe) id: number): Promise { + async remove(@Param('id', ParseIntPipe) id: number) { return await this.recipeBookmarkService.deleteOne(id); } } diff --git a/api/libs/recipe/src/controllers/recipe.controller.ts b/api/libs/recipe/src/controllers/recipe.controller.ts index 1f0a751..24843a7 100644 --- a/api/libs/recipe/src/controllers/recipe.controller.ts +++ b/api/libs/recipe/src/controllers/recipe.controller.ts @@ -37,6 +37,7 @@ import { RecipeViewerIdentifier } from '../dto/recipe-view-log/recipe-viewer-ide import { CreateMongoRecipeDto } from '@app/recipe/dto/recipe/create-mongo-recipe.dto'; import { UpdateRecipeDto } from '@app/recipe/dto/recipe/update-recipe.dto'; import { TextSearchRecipeDto } from '@app/recipe/dto/recipe/text-search.dto'; +import { RecipeDto } from '@app/recipe/dto/recipe/recipe.dto'; @ApiTags('Recipe') @Controller('recipe') @@ -52,7 +53,8 @@ export class RecipeController { @ApiPostCreated(CreateRecipeResponseDto) @Post() async create(@Body() createRecipeDto: CreateMongoRecipeDto) { - return this.recipeService.create(createRecipeDto); + const recipe = await this.recipeService.create(createRecipeDto); + return RecipeDto.fromEntity(recipe); } /** @@ -139,7 +141,8 @@ export class RecipeController { @Param('id', ParseIntPipe) id: number, @Body() updateRecipeDto: UpdateRecipeDto, ) { - return this.recipeService.update(id, updateRecipeDto); + const recipe = await this.recipeService.update(id, updateRecipeDto); + return RecipeDto.fromEntity(recipe); } /** @@ -152,6 +155,7 @@ export class RecipeController { @HttpCode(HttpStatus.NO_CONTENT) @Delete(':id') async delete(@Param('id', ParseIntPipe) id: number) { - return this.recipeService.deleteOne(id); + const recipe = await this.recipeService.deleteOne(id); + return RecipeDto.fromEntity(recipe); } } diff --git a/api/libs/recipe/src/dto/recipe-bookmark/create-recipe-bookmark.dto.ts b/api/libs/recipe/src/dto/recipe-bookmark/create-recipe-bookmark.dto.ts index bfb8096..946586d 100644 --- a/api/libs/recipe/src/dto/recipe-bookmark/create-recipe-bookmark.dto.ts +++ b/api/libs/recipe/src/dto/recipe-bookmark/create-recipe-bookmark.dto.ts @@ -13,4 +13,9 @@ export class CreateRecipeBookmarkDto { @ApiExpose({ name: 'recipe_id' }) @IsInt() recipeId: number; + + constructor(userId: number, recipeId: number) { + this.userId = userId; + this.recipeId = recipeId; + } } diff --git a/api/libs/recipe/src/dto/recipe-bookmark/recipe-bookmark-response.dto.ts b/api/libs/recipe/src/dto/recipe-bookmark/recipe-bookmark-response.dto.ts index cb0a0d4..a203e3b 100644 --- a/api/libs/recipe/src/dto/recipe-bookmark/recipe-bookmark-response.dto.ts +++ b/api/libs/recipe/src/dto/recipe-bookmark/recipe-bookmark-response.dto.ts @@ -2,13 +2,40 @@ import { CreatedResponse, OkResponse, } from '@app/common/dto/success-response.dto'; -import { RecipeBookmark } from '@app/recipe/domain/recipe-bookmark.entity'; import { RecipeBookmarksResponseDto } from '@app/recipe/dto/recipe-bookmark/recipe-bookmarks-response.dto'; +import { ErrorResponse } from '@app/common/dto/error-response.dto'; +import { + RecipeBookmarkDuplicateException, + RecipeNotExistsException, + UserNotExistsException, +} from '@app/recipe/exception/domain.exception'; +import { RecipeBookmarkDto } from '@app/recipe/dto/recipe-bookmark/recipe-bookmark.dto'; export class CreateRecipeBookmarkResponseDto extends CreatedResponse { - data: RecipeBookmark; + data: RecipeBookmarkDto; } export class FindRecipeBookmarksResponseDto extends OkResponse { data: RecipeBookmarksResponseDto; } + +export class RecipeNotExistsResponseDto extends ErrorResponse { + error = { + name: RecipeNotExistsException.name, + message: RecipeNotExistsException.message, + }; +} + +export class UserNotExistsResponseDto extends ErrorResponse { + error = { + name: UserNotExistsException.name, + message: UserNotExistsException.message, + }; +} + +export class RecipeBookmarkDuplicateResponseDto extends ErrorResponse { + error = { + name: RecipeBookmarkDuplicateException.name, + message: RecipeBookmarkDuplicateException.message, + }; +} diff --git a/api/libs/recipe/src/dto/recipe-bookmark/recipe-bookmark.dto.ts b/api/libs/recipe/src/dto/recipe-bookmark/recipe-bookmark.dto.ts index 48250e2..46ee6b7 100644 --- a/api/libs/recipe/src/dto/recipe-bookmark/recipe-bookmark.dto.ts +++ b/api/libs/recipe/src/dto/recipe-bookmark/recipe-bookmark.dto.ts @@ -2,6 +2,7 @@ import { RecipeDto } from '@app/recipe/dto/recipe/recipe.dto'; import { UserDto } from '@app/user/dto/user.dto'; import { ApiExpose } from '@app/common/decorators/api-expose.decorator'; import { RecipeBookmarkEntity } from '@app/recipe/domain/recipe-bookmark.entity'; +import { Transform } from 'class-transformer'; export class RecipeBookmarkDto { id: number; @@ -16,9 +17,13 @@ export class RecipeBookmarkDto { user?: UserDto; + @Transform(({ value }) => new Date(value), { toClassOnly: true }) + @Transform(({ value }) => value.toISOString(), { toPlainOnly: true }) @ApiExpose({ name: 'created_at' }) createdAt: Date; + @Transform(({ value }) => new Date(value), { toClassOnly: true }) + @Transform(({ value }) => value.toISOString(), { toPlainOnly: true }) @ApiExpose({ name: 'updated_at' }) updatedAt: Date; diff --git a/api/libs/recipe/src/dto/recipe-bookmark/recipe-bookmarks-item.dto.ts b/api/libs/recipe/src/dto/recipe-bookmark/recipe-bookmarks-item.dto.ts index 0261adb..ecd6fc6 100644 --- a/api/libs/recipe/src/dto/recipe-bookmark/recipe-bookmarks-item.dto.ts +++ b/api/libs/recipe/src/dto/recipe-bookmark/recipe-bookmarks-item.dto.ts @@ -4,4 +4,26 @@ import { RecipesItemDto } from '@app/recipe/dto/recipe/recipes-item.dto'; export class RecipeBookmarksItemDto extends RecipesItemDto { @ApiExpose({ name: 'recipe_bookmark_id' }) recipeBookmarkId: number; + + constructor(props: { + id?: number; + name?: string; + thumbnail?: string; + description?: string; + viewCount?: number; + createdAt?: Date; + updatedAt?: Date; + recipeBookmarkId?: number; + }) { + super( + props.id, + props.name, + props.thumbnail, + props.description, + props.viewCount, + props.createdAt, + props.updatedAt, + ); + this.recipeBookmarkId = props.recipeBookmarkId; + } } diff --git a/api/libs/recipe/src/dto/recipe/recipe-detail.dto.ts b/api/libs/recipe/src/dto/recipe/recipe-detail.dto.ts index bebcc5c..064ab50 100644 --- a/api/libs/recipe/src/dto/recipe/recipe-detail.dto.ts +++ b/api/libs/recipe/src/dto/recipe/recipe-detail.dto.ts @@ -1,7 +1,7 @@ import { IngredientRequirementDto } from '@app/recipe/dto/recipe/ingredient-requirement.dto'; import { RecipeStepDto } from '@app/recipe/dto/recipe/recipe-step.dto'; import { ApiExpose } from '@app/common/decorators/api-expose.decorator'; -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; export class RecipeDetailDto { public readonly id: number; @@ -29,9 +29,39 @@ export class RecipeDetailDto { @ApiExpose({ name: 'view_count' }) public readonly viewCount: number; + @Transform(({ value }) => new Date(value), { toClassOnly: true }) + @Transform(({ value }) => value.toISOString(), { toPlainOnly: true }) @ApiExpose({ name: 'created_at' }) public readonly createdAt: Date; + @Transform(({ value }) => new Date(value), { toClassOnly: true }) + @Transform(({ value }) => value.toISOString(), { toPlainOnly: true }) @ApiExpose({ name: 'updated_at' }) public readonly updatedAt: Date; + + constructor(props: { + id: number; + mongoId: string; + name: string; + description: string; + ownerId: number; + ingredientRequirements: Array; + recipeSteps: Array; + thumbnail: string; + viewCount: number; + createdAt: Date; + updatedAt: Date; + }) { + this.id = props.id; + this.mongoId = props.mongoId; + this.name = props.name; + this.description = props.description; + this.ownerId = props.ownerId; + this.ingredientRequirements = props.ingredientRequirements; + this.recipeSteps = props.recipeSteps; + this.thumbnail = props.thumbnail; + this.viewCount = props.viewCount; + this.createdAt = props.createdAt; + this.updatedAt = props.updatedAt; + } } diff --git a/api/libs/recipe/src/dto/recipe/recipe.dto.ts b/api/libs/recipe/src/dto/recipe/recipe.dto.ts index 726a839..e1e3bfc 100644 --- a/api/libs/recipe/src/dto/recipe/recipe.dto.ts +++ b/api/libs/recipe/src/dto/recipe/recipe.dto.ts @@ -1,6 +1,7 @@ import { RecipeEntity } from '@app/recipe/domain/recipe.entity'; import { ApiExpose } from '@app/common/decorators/api-expose.decorator'; import { UserDto } from '@app/user/dto/user.dto'; +import { Transform } from 'class-transformer'; export class RecipeDto { id: number; @@ -25,9 +26,13 @@ export class RecipeDto { @ApiExpose({ name: 'view_count' }) viewCount: number; + @Transform(({ value }) => new Date(value), { toClassOnly: true }) + @Transform(({ value }) => value.toISOString(), { toPlainOnly: true }) @ApiExpose({ name: 'created_at' }) createdAt: Date; + @Transform(({ value }) => new Date(value), { toClassOnly: true }) + @Transform(({ value }) => value.toISOString(), { toPlainOnly: true }) @ApiExpose({ name: 'updated_at' }) updatedAt: Date; @@ -49,7 +54,7 @@ export class RecipeDto { this.name = props.name; this.description = props.description; this.ownerId = props.ownerId; - this.owner = props.owner; + this.owner = props.owner ?? null; this.thumbnail = props.thumbnail; this.originUrl = props.originUrl; this.viewCount = props.viewCount; @@ -64,7 +69,7 @@ export class RecipeDto { name: recipe.name, description: recipe.description, ownerId: recipe.ownerId, - owner: UserDto.fromEntity(recipe.owner), + owner: !!recipe.owner ? UserDto.fromEntity(recipe.owner) : null, thumbnail: recipe.thumbnail, originUrl: recipe.originUrl, viewCount: recipe.viewCount, diff --git a/api/libs/recipe/src/dto/recipe/recipes-item.dto.ts b/api/libs/recipe/src/dto/recipe/recipes-item.dto.ts index 0afcd42..ae415de 100644 --- a/api/libs/recipe/src/dto/recipe/recipes-item.dto.ts +++ b/api/libs/recipe/src/dto/recipe/recipes-item.dto.ts @@ -1,4 +1,5 @@ import { ApiExpose } from '@app/common/decorators/api-expose.decorator'; +import { Transform } from 'class-transformer'; export class RecipesItemDto { public readonly id: number; @@ -12,9 +13,13 @@ export class RecipesItemDto { @ApiExpose({ name: 'view_count' }) public readonly viewCount: number; + @Transform(({ value }) => new Date(value), { toClassOnly: true }) + @Transform(({ value }) => value.toISOString(), { toPlainOnly: true }) @ApiExpose({ name: 'created_at' }) public readonly createdAt: Date; + @Transform(({ value }) => new Date(value), { toClassOnly: true }) + @Transform(({ value }) => value.toISOString(), { toPlainOnly: true }) @ApiExpose({ name: 'updated_at' }) public readonly updatedAt: Date; diff --git a/api/libs/recipe/src/dto/recipe/update-recipe.dto.ts b/api/libs/recipe/src/dto/recipe/update-recipe.dto.ts index e42e9ed..8bc1a0e 100644 --- a/api/libs/recipe/src/dto/recipe/update-recipe.dto.ts +++ b/api/libs/recipe/src/dto/recipe/update-recipe.dto.ts @@ -1,5 +1,3 @@ -import { OmitType, PartialType } from '@nestjs/swagger'; -import { CreateRecipeDto } from './create-recipe.dto'; import { IsInt, IsMongoId, @@ -10,9 +8,7 @@ import { } from 'class-validator'; import { ApiExpose } from '@app/common/decorators/api-expose.decorator'; -export class UpdateRecipeDto extends PartialType( - OmitType(CreateRecipeDto, ['ownerId'] as const), -) { +export class UpdateRecipeDto { @IsString() @IsOptional() @IsNotEmpty() diff --git a/api/libs/recipe/src/exception/domain.exception.ts b/api/libs/recipe/src/exception/domain.exception.ts index 76c58ff..08d22ea 100644 --- a/api/libs/recipe/src/exception/domain.exception.ts +++ b/api/libs/recipe/src/exception/domain.exception.ts @@ -1,13 +1,16 @@ import { DomainException } from '@app/common/exception-filters/exception'; export class RecipeNotExistsException extends DomainException { + static message = 'Recipe not exists'; message = 'Recipe not exists'; } export class UserNotExistsException extends DomainException { + static message = 'User not exists'; message = 'User not exists'; } export class RecipeBookmarkDuplicateException extends DomainException { + static message = 'Recipe bookmark already exists'; message = 'Recipe bookmark already exists'; } diff --git a/api/libs/recipe/src/services/recipe-bookmark.service.ts b/api/libs/recipe/src/services/recipe-bookmark.service.ts index 0ddfba1..9c3b9b4 100644 --- a/api/libs/recipe/src/services/recipe-bookmark.service.ts +++ b/api/libs/recipe/src/services/recipe-bookmark.service.ts @@ -54,6 +54,6 @@ export class RecipeBookmarkService { } async deleteOne(id: number) { - await this.recipeBookmarkRespository.deleteOne(id); + return await this.recipeBookmarkRespository.deleteOne(id); } } diff --git a/api/libs/recipe/test/fixture/mocked-provider.ts b/api/libs/recipe/test/fixture/mocked-provider.ts new file mode 100644 index 0000000..79ceab9 --- /dev/null +++ b/api/libs/recipe/test/fixture/mocked-provider.ts @@ -0,0 +1,27 @@ +import { RecipeBookmarkService } from '@app/recipe/services/recipe-bookmark.service'; +import { RecipeService } from '@app/recipe/services/recipe.service'; + +export const MockedRecipeBookmarkService = { + provide: RecipeBookmarkService, + useValue: { + create: jest.fn(), + findAllRecipeBookmarked: jest.fn(), + deleteOne: jest.fn(), + }, +}; + +export const MockedRecipeService = { + provide: RecipeService, + useValue: { + create: jest.fn(), + findAll: jest.fn(), + findAllByFullTextSearch: jest.fn(), + findAllRecentViewed: jest.fn(), + findOne: jest.fn(), + findTopViewed: jest.fn(), + viewRecipe: jest.fn(), + setAllViewedRecipesInPast1Month: jest.fn(), + update: jest.fn(), + deleteOne: jest.fn(), + }, +}; diff --git a/api/libs/recipe/test/fixture/recipe-bookmark.fixture.ts b/api/libs/recipe/test/fixture/recipe-bookmark.fixture.ts new file mode 100644 index 0000000..05075c9 --- /dev/null +++ b/api/libs/recipe/test/fixture/recipe-bookmark.fixture.ts @@ -0,0 +1,74 @@ +import { CreateRecipeBookmarkDto } from '@app/recipe/dto/recipe-bookmark/create-recipe-bookmark.dto'; +import { RecipeBookmarkEntity } from '@app/recipe/domain/recipe-bookmark.entity'; +import { RecipeBookmarkDto } from '@app/recipe/dto/recipe-bookmark/recipe-bookmark.dto'; +import { FilterRecipeBookmarkDto } from '@app/recipe/dto/recipe-bookmark/filter-recipe-bookmark.dto'; +import { RecipeBookmarksAndCountDto } from '@app/recipe/dto/recipe-bookmark/recipe-bookmarks-count.dto'; +import { RecipeBookmarksItemDto } from '@app/recipe/dto/recipe-bookmark/recipe-bookmarks-item.dto'; +import { RecipeBookmarksResponseDto } from '@app/recipe/dto/recipe-bookmark/recipe-bookmarks-response.dto'; +import { recipeEntity } from './recipe.fixture'; + +export const createRecipeBookmarkDto = new CreateRecipeBookmarkDto(1, 1); + +export const recipeBookmarkEntity: RecipeBookmarkEntity = { + id: 1, + recipeId: 1, + userId: 1, + createdAt: new Date(), + updatedAt: new Date(), +}; + +export const recipeBookmarkDto = new RecipeBookmarkDto({ + id: recipeBookmarkEntity.id, + recipeId: recipeBookmarkEntity.recipeId, + userId: recipeBookmarkEntity.userId, + createdAt: recipeBookmarkEntity.createdAt, + updatedAt: recipeBookmarkEntity.updatedAt, +}); + +export const filterRecipeBookmarkDto: FilterRecipeBookmarkDto = { + page: 1, + limit: 2, +}; + +export const recipeBookmarksItemDto: RecipeBookmarksItemDto = + new RecipeBookmarksItemDto({ + id: recipeBookmarkEntity.id, + name: recipeEntity.name, + thumbnail: recipeEntity.thumbnail, + description: recipeEntity.description, + viewCount: recipeEntity.viewCount, + recipeBookmarkId: 1, + createdAt: recipeBookmarkEntity.createdAt, + updatedAt: recipeBookmarkEntity.updatedAt, + }); + +export const recipeBookmarksResponseDto: RecipeBookmarksResponseDto = + new RecipeBookmarksResponseDto([recipeBookmarksItemDto], 1, 1, true); + +export const recipeBookmarksAndCountLast: jest.Mocked = + { + recipes: [recipeBookmarksItemDto, recipeBookmarksItemDto], + count: 2, + toRecipeBookmarksResponseDto: jest.fn(), + }; + +export const recipeBookmarksAndCountNotLast: jest.Mocked = + { + recipes: [recipeBookmarksItemDto, recipeBookmarksItemDto], + count: 15, + toRecipeBookmarksResponseDto: jest.fn(), + }; + +export const recipeBookmarksResponseDtoLast: RecipeBookmarksResponseDto = { + results: recipeBookmarksAndCountLast.recipes, + page: 1, + count: 2, + hasNext: true, +}; + +export const recipeBookmarksResponseDtoNotLast: RecipeBookmarksResponseDto = { + results: recipeBookmarksAndCountNotLast.recipes, + page: 1, + count: 2, + hasNext: false, +}; diff --git a/api/libs/recipe/test/fixture/recipe-view-log.fixture.ts b/api/libs/recipe/test/fixture/recipe-view-log.fixture.ts new file mode 100644 index 0000000..6eb81b5 --- /dev/null +++ b/api/libs/recipe/test/fixture/recipe-view-log.fixture.ts @@ -0,0 +1,14 @@ +import { RecipeViewLogEntity } from '@app/recipe/domain/recipe-view-log.entity'; +import { recipeEntity } from './recipe.fixture'; +import { userEntity } from '../../../user/test/fixture/user.fixture'; + +export const recipeViewLogEntity: RecipeViewLogEntity = { + id: 1, + recipeId: recipeEntity.id, + recipe: recipeEntity, + userId: userEntity.id, + user: userEntity, + userIp: '::1', + createdAt: new Date(), + updatedAt: new Date(), +}; diff --git a/api/libs/recipe/test/fixture/recipe.fixture.ts b/api/libs/recipe/test/fixture/recipe.fixture.ts new file mode 100644 index 0000000..8e543b3 --- /dev/null +++ b/api/libs/recipe/test/fixture/recipe.fixture.ts @@ -0,0 +1,129 @@ +import { CreateMongoRecipeDto } from '@app/recipe/dto/recipe/create-mongo-recipe.dto'; +import { RecipeEntity } from '@app/recipe/domain/recipe.entity'; +import { FilterRecipeDto } from '@app/recipe/dto/recipe/filter-recipe.dto'; +import { RecipesItemDto } from '@app/recipe/dto/recipe/recipes-item.dto'; +import { RecipesResponseDto } from '@app/recipe/dto/recipe/recipes-response.dto'; +import { RecipeDetailDto } from '@app/recipe/dto/recipe/recipe-detail.dto'; +import { UpdateRecipeDto } from '@app/recipe/dto/recipe/update-recipe.dto'; +import { RecipeDto } from '@app/recipe/dto/recipe/recipe.dto'; +import { TextSearchRecipeDto } from '@app/recipe/dto/recipe/text-search.dto'; +import { RecipesAndCountDto } from '@app/recipe/dto/recipe/recipes-count.dto'; + +export const createRecipeDto = new CreateMongoRecipeDto(); +createRecipeDto.name = 'test'; +createRecipeDto.description = 'test'; +createRecipeDto.ingredientRequirements = [ + { + name: 'test', + amount: 'test', + }, +]; +createRecipeDto.recipeSteps = [ + { + description: 'test', + ingredients: [ + { + name: 'test', + amount: 'test', + }, + ], + images: ['https://image.url'], + }, +]; +createRecipeDto.thumbnail = 'https://image.url'; +createRecipeDto.recipeRawText = 'test'; +createRecipeDto.originUrl = 'https://image.url'; + +export const recipeEntity: RecipeEntity = { + id: 1, + name: 'test', + mongoId: 'test_id', + ownerId: 1, + description: 'test', + thumbnail: 'test', + viewCount: 0, + originUrl: 'test', + createdAt: new Date(), + updatedAt: new Date(), +}; + +export const recipeDto: RecipeDto = new RecipeDto({ + id: recipeEntity.id, + name: recipeEntity.name, + mongoId: recipeEntity.mongoId, + ownerId: recipeEntity.ownerId, + description: recipeEntity.description, + thumbnail: recipeEntity.thumbnail, + viewCount: recipeEntity.viewCount, + originUrl: recipeEntity.originUrl, + createdAt: recipeEntity.createdAt, + updatedAt: recipeEntity.updatedAt, +}); + +export const filterRecipeDto: FilterRecipeDto = new FilterRecipeDto(1, 1); + +export const recipesItemDto = new RecipesItemDto( + 1, + 'test', + 'https://image.url', + 'test', + 0, + new Date(), + new Date(), +); + +export const recipesResponseDto = new RecipesResponseDto( + [recipesItemDto], + 1, + 1, + true, +); + +export const recipeDetailDto: RecipeDetailDto = new RecipeDetailDto({ + id: recipeEntity.id, + name: recipeEntity.name, + mongoId: recipeEntity.mongoId, + description: recipeEntity.description, + thumbnail: recipeEntity.thumbnail, + ownerId: recipeEntity.ownerId, + ingredientRequirements: createRecipeDto.ingredientRequirements, + recipeSteps: createRecipeDto.recipeSteps, + viewCount: recipeEntity.viewCount, + createdAt: recipeEntity.createdAt, + updatedAt: recipeEntity.updatedAt, +}); + +export const updateRecipeDto: UpdateRecipeDto = { + name: 'test', + description: 'test', +}; + +export const textSearchRecipeDto: TextSearchRecipeDto = { + searchQuery: 'test', + page: 1, + limit: 10, +}; + +export const recipesAndCountLast: jest.Mocked = { + recipes: [recipesItemDto, recipesItemDto], + count: 2, + toRecipesResponseDto: jest.fn(), +}; + +export const recipesAndCountNotLast: jest.Mocked = { + recipes: [recipesItemDto, recipesItemDto], + count: 15, + toRecipesResponseDto: jest.fn(), +}; +export const recipesResponseDtoLast: RecipesResponseDto = { + results: recipesAndCountLast.recipes, + page: 1, + count: 2, + hasNext: true, +}; +export const recipesResponseDtoNotLast: RecipesResponseDto = { + results: recipesAndCountNotLast.recipes, + page: 1, + count: 2, + hasNext: false, +}; diff --git a/api/libs/recipe/test/integration/recipe-bookmark.controller.spec.ts b/api/libs/recipe/test/integration/recipe-bookmark.controller.spec.ts deleted file mode 100644 index 3fc8611..0000000 --- a/api/libs/recipe/test/integration/recipe-bookmark.controller.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { TestBed } from '@automock/jest'; -import { RecipeBookmarkController } from '@app/recipe/controllers/recipe-bookmark.controller'; - -describe('RecipeBookmarkController', () => { - let controller: RecipeBookmarkController; - - beforeEach(async () => { - const { unit, unitRef } = TestBed.create( - RecipeBookmarkController, - ).compile(); - - controller = unit; - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/api/libs/recipe/test/integration/recipe.controller.spec.ts b/api/libs/recipe/test/integration/recipe.controller.spec.ts deleted file mode 100644 index 35d99a6..0000000 --- a/api/libs/recipe/test/integration/recipe.controller.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TestBed } from '@automock/jest'; -import { RecipeController } from '@app/recipe/controllers/recipe.controller'; - -describe('RecipeController', () => { - let controller: RecipeController; - - beforeEach(async () => { - const { unit, unitRef } = TestBed.create(RecipeController).compile(); - - controller = unit; - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/api/libs/recipe/test/unit/controller/recipe-bookmark.controller.spec.ts b/api/libs/recipe/test/unit/controller/recipe-bookmark.controller.spec.ts new file mode 100644 index 0000000..c3cd056 --- /dev/null +++ b/api/libs/recipe/test/unit/controller/recipe-bookmark.controller.spec.ts @@ -0,0 +1,212 @@ +import { RecipeBookmarkController } from '@app/recipe/controllers/recipe-bookmark.controller'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { RecipeBookmarkService } from '@app/recipe/services/recipe-bookmark.service'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { instanceToPlain } from 'class-transformer'; +import { AuthService } from '@app/auth/auth.service'; +import { MockedRecipeBookmarkService } from '../../fixture/mocked-provider'; +import { MockedAuthService } from '../../../../auth/test/fixture/mocked-provider'; +import { globalInhancers } from '@app/common/global-inhancers'; +import { + createRecipeBookmarkDto, + filterRecipeBookmarkDto, + recipeBookmarkDto, + recipeBookmarkEntity, + recipeBookmarksResponseDto, +} from '../../fixture/recipe-bookmark.fixture'; +import { sessionEntity } from '../../../../auth/test/fixture/auth.fixture'; + +describe('RecipeBookmarkController', () => { + let app: INestApplication; + let recipeBookmarkService: RecipeBookmarkService; + let authService: AuthService; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [RecipeBookmarkController], + providers: [ + MockedRecipeBookmarkService, + MockedAuthService, + ...globalInhancers, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + + recipeBookmarkService = moduleRef.get( + RecipeBookmarkService, + ); + authService = moduleRef.get(AuthService); + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest + .spyOn(authService, 'findBySessionToken') + .mockResolvedValue(sessionEntity); + }); + + describe('/recipe-bookmark (POST)', () => { + it('should return 201 and created RecipeBookmark', async () => { + jest + .spyOn(recipeBookmarkService, 'create') + .mockResolvedValue(recipeBookmarkEntity); + + const res = ( + await request(app.getHttpServer()) + .post('/recipe-bookmark') + .set('Authorization', 'Bearer token') + .send(instanceToPlain(createRecipeBookmarkDto)) + .expect(HttpStatus.CREATED) + ).body; + + expect(res.success).toBe(true); + expect(res.data).toEqual(instanceToPlain(recipeBookmarkDto)); + }); + + it('should return 400 if dto is invalid', async () => { + const res = ( + await request(app.getHttpServer()) + .post('/recipe-bookmark') + .set('Authorization', 'Bearer token') + .send(instanceToPlain({ ...createRecipeBookmarkDto, recipeId: null })) + .expect(HttpStatus.BAD_REQUEST) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'BadRequestException', + }), + ); + }); + + it('should return 401 if session is invalid', async () => { + jest.spyOn(authService, 'findBySessionToken').mockResolvedValue(null); + + const res = ( + await request(app.getHttpServer()) + .post('/recipe-bookmark') + .set('Authorization', 'Bearer token') + .send(instanceToPlain(createRecipeBookmarkDto)) + .expect(HttpStatus.UNAUTHORIZED) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'UnauthorizedException', + }), + ); + }); + }); + + describe('/recipe-bookmark (GET)', () => { + it('should return 200 and RecipeBookmarks', async () => { + jest + .spyOn(recipeBookmarkService, 'findAllRecipeBookmarked') + .mockResolvedValue(recipeBookmarksResponseDto); + + const res = ( + await request(app.getHttpServer()) + .get('/recipe-bookmark') + .query(filterRecipeBookmarkDto) + .set('Authorization', 'Bearer token') + .expect(HttpStatus.OK) + ).body; + + expect(res.success).toBe(true); + expect(res.data).toEqual(instanceToPlain(recipeBookmarksResponseDto)); + }); + + it('should return 400 if dto is invalid', async () => { + const res = ( + await request(app.getHttpServer()) + .get('/recipe-bookmark') + .query({ + ...filterRecipeBookmarkDto, + page: 'invalid-page', + }) + .set('Authorization', 'Bearer token') + .expect(HttpStatus.BAD_REQUEST) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'BadRequestException', + }), + ); + }); + + it('should return 401 if session is invalid', async () => { + jest.spyOn(authService, 'findBySessionToken').mockResolvedValue(null); + + const res = ( + await request(app.getHttpServer()) + .get('/recipe-bookmark') + .query(filterRecipeBookmarkDto) + .set('Authorization', 'Bearer token') + .expect(HttpStatus.UNAUTHORIZED) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'UnauthorizedException', + }), + ); + }); + }); + + describe('/recipe-bookmark/:id (DELETE)', () => { + it('should return 204 and deleted RecipeBookmark', async () => { + jest + .spyOn(recipeBookmarkService, 'deleteOne') + .mockResolvedValue(recipeBookmarkEntity); + + const res = ( + await request(app.getHttpServer()) + .delete('/recipe-bookmark/1') + .set('Authorization', 'Bearer token') + .expect(HttpStatus.NO_CONTENT) + ).body; + }); + + it('should return 400 if id is invalid', async () => { + const res = ( + await request(app.getHttpServer()) + .delete('/recipe-bookmark/invalid-id') + .set('Authorization', 'Bearer token') + .expect(HttpStatus.BAD_REQUEST) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'BadRequestException', + }), + ); + }); + + it('should return 401 if session is invalid', async () => { + jest.spyOn(authService, 'findBySessionToken').mockResolvedValue(null); + + const res = ( + await request(app.getHttpServer()) + .delete('/recipe-bookmark/1') + .set('Authorization', 'Bearer token') + .expect(HttpStatus.UNAUTHORIZED) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'UnauthorizedException', + }), + ); + }); + }); +}); diff --git a/api/libs/recipe/test/unit/controller/recipe.controller.spec.ts b/api/libs/recipe/test/unit/controller/recipe.controller.spec.ts new file mode 100644 index 0000000..a2f38b8 --- /dev/null +++ b/api/libs/recipe/test/unit/controller/recipe.controller.spec.ts @@ -0,0 +1,367 @@ +import { RecipeController } from '@app/recipe/controllers/recipe.controller'; +import { + HttpStatus, + INestApplication, + UnauthorizedException, +} from '@nestjs/common'; +import { RecipeService } from '@app/recipe/services/recipe.service'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { SessionAuthGuard } from '@app/auth/guards/session-auth.guard'; +import { instanceToPlain } from 'class-transformer'; +import { MockedRecipeService } from '../../fixture/mocked-provider'; +import { globalInhancers } from '@app/common/global-inhancers'; +import { + createRecipeDto, + filterRecipeDto, + recipeDetailDto, + recipeDto, + recipeEntity, + recipesItemDto, + recipesResponseDto, + updateRecipeDto, +} from '../../fixture/recipe.fixture'; + +describe('RecipeController', () => { + let app: INestApplication; + let recipeService: RecipeService; + let authGuard: SessionAuthGuard; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [RecipeController], + providers: [MockedRecipeService, ...globalInhancers], + }) + .overrideGuard(SessionAuthGuard) + .useValue({ + canActivate: jest.fn().mockReturnValue(true), + }) + .compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + + recipeService = moduleRef.get(RecipeService); + authGuard = moduleRef.get(SessionAuthGuard); + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(authGuard, 'canActivate').mockResolvedValue(true); + }); + + describe('/recipe (POST)', () => { + it('should return 201 and created recipe', async () => { + jest.spyOn(recipeService, 'create').mockResolvedValue(recipeEntity); + + const res = ( + await request(app.getHttpServer()) + .post('/recipe') + .send(instanceToPlain(createRecipeDto)) + .expect(HttpStatus.CREATED) + ).body; + + expect(res.success).toBe(true); + expect(res.data).toEqual(instanceToPlain(recipeDto)); + }); + + it('should return 400 if dto is invalid', async () => { + const res = ( + await request(app.getHttpServer()) + .post('/recipe') + .send( + instanceToPlain({ + ...createRecipeDto, + name: 1, + }), + ) + .expect(HttpStatus.BAD_REQUEST) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'BadRequestException', + }), + ); + }); + + it('should return 401 if session not found', async () => { + jest.spyOn(authGuard, 'canActivate').mockImplementation(() => { + throw new UnauthorizedException(); + }); + + const res = ( + await request(app.getHttpServer()) + .post('/recipe') + .send(instanceToPlain(createRecipeDto)) + .expect(HttpStatus.UNAUTHORIZED) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'UnauthorizedException', + }), + ); + }); + }); + + describe('/recipe (GET)', () => { + it('should return 200 and recipes with pagination', async () => { + jest + .spyOn(recipeService, 'findAll') + .mockResolvedValue(recipesResponseDto); + + const res = ( + await request(app.getHttpServer()) + .get('/recipe') + .query(filterRecipeDto) + .expect(HttpStatus.OK) + ).body; + + expect(res.success).toBe(true); + expect(res.data).toEqual(instanceToPlain(recipesResponseDto)); + }); + + it('should return 400 if query is invalid', async () => { + const res = ( + await request(app.getHttpServer()) + .get('/recipe') + .query({ page: 'test' }) + .expect(HttpStatus.BAD_REQUEST) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'BadRequestException', + }), + ); + }); + + it('should return 401 if session not found', async () => { + jest.spyOn(authGuard, 'canActivate').mockImplementation(() => { + throw new UnauthorizedException(); + }); + + const res = ( + await request(app.getHttpServer()) + .get('/recipe') + .query(filterRecipeDto) + .expect(HttpStatus.UNAUTHORIZED) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'UnauthorizedException', + }), + ); + }); + }); + + describe('/recipe/search (GET)', () => { + it('should return 200 and recipes with pagination', async () => { + jest + .spyOn(recipeService, 'findAllByFullTextSearch') + .mockResolvedValue(recipesResponseDto); + + const res = ( + await request(app.getHttpServer()) + .get('/recipe/search') + .query(filterRecipeDto) + .expect(HttpStatus.OK) + ).body; + + expect(res.success).toBe(true); + expect(res.data).toEqual(instanceToPlain(recipesResponseDto)); + }); + + it('should return 400 if query is invalid', async () => { + const res = ( + await request(app.getHttpServer()) + .get('/recipe/search') + .query({ searchQuery: '' }) + .expect(HttpStatus.BAD_REQUEST) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'BadRequestException', + }), + ); + }); + }); + + describe('/recipe/top-viewed (GET)', () => { + it('should return 200 and recipes with pagination', async () => { + jest + .spyOn(recipeService, 'findTopViewed') + .mockResolvedValue([recipesItemDto]); + + const res = ( + await request(app.getHttpServer()) + .get('/recipe/top-viewed') + .expect(HttpStatus.OK) + ).body; + + expect(res.success).toBe(true); + expect(res.data).toEqual([instanceToPlain(recipesItemDto)]); + }); + }); + + describe('/recipe/recent-viewed (GET)', () => { + it('should return 200 and recipes with pagination', async () => { + jest + .spyOn(recipeService, 'findAllRecentViewed') + .mockResolvedValue(recipesResponseDto); + + const res = ( + await request(app.getHttpServer()) + .get('/recipe/recent-viewed') + .query(filterRecipeDto) + .expect(HttpStatus.OK) + ).body; + + expect(res.success).toBe(true); + expect(res.data).toEqual(instanceToPlain(recipesResponseDto)); + }); + + it('should return 401 if session not found', async () => { + jest.spyOn(authGuard, 'canActivate').mockImplementation(() => { + throw new UnauthorizedException(); + }); + + const res = ( + await request(app.getHttpServer()) + .get('/recipe/recent-viewed') + .expect(HttpStatus.UNAUTHORIZED) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'UnauthorizedException', + }), + ); + }); + }); + + describe('/recipe/:id (GET)', () => { + it('should return 200 and recipe', async () => { + jest.spyOn(recipeService, 'findOne').mockResolvedValue(recipeDetailDto); + + const res = ( + await request(app.getHttpServer()) + .get('/recipe/1') + .expect(HttpStatus.OK) + ).body; + + expect(res.success).toBe(true); + expect(res.data).toEqual(instanceToPlain(recipeDetailDto)); + }); + }); + + describe('/recipe/:id (PATCH)', () => { + it('should return 200 and updated recipe', async () => { + jest.spyOn(recipeService, 'update').mockResolvedValue(recipeEntity); + + const res = ( + await request(app.getHttpServer()) + .patch('/recipe/1') + .send(instanceToPlain(updateRecipeDto)) + .expect(HttpStatus.OK) + ).body; + + expect(res.success).toBe(true); + expect(res.data).toEqual(instanceToPlain(recipeDto)); + }); + + it('should return 400 if dto is invalid', async () => { + const res = ( + await request(app.getHttpServer()) + .patch('/recipe/1') + .send( + instanceToPlain({ + ...updateRecipeDto, + name: 1, + }), + ) + .expect(HttpStatus.BAD_REQUEST) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'BadRequestException', + }), + ); + }); + + it('should return 401 if session not found', async () => { + jest.spyOn(authGuard, 'canActivate').mockImplementation(() => { + throw new UnauthorizedException(); + }); + + const res = ( + await request(app.getHttpServer()) + .patch('/recipe/1') + .send(instanceToPlain(updateRecipeDto)) + .expect(HttpStatus.UNAUTHORIZED) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'UnauthorizedException', + }), + ); + }); + }); + + describe('/recipe/:id (DELETE)', () => { + it('should return 200 and deleted recipe', async () => { + jest.spyOn(recipeService, 'deleteOne').mockResolvedValue(recipeEntity); + + await request(app.getHttpServer()) + .delete('/recipe/1') + .expect(HttpStatus.NO_CONTENT); + }); + + it('should return 400 if id is invalid', async () => { + const res = ( + await request(app.getHttpServer()) + .delete('/recipe/test') + .expect(HttpStatus.BAD_REQUEST) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'BadRequestException', + }), + ); + }); + + it('should return 401 if session not found', async () => { + jest.spyOn(authGuard, 'canActivate').mockImplementation(() => { + throw new UnauthorizedException(); + }); + + const res = ( + await request(app.getHttpServer()) + .delete('/recipe/1') + .expect(HttpStatus.UNAUTHORIZED) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'UnauthorizedException', + }), + ); + }); + }); +}); diff --git a/api/libs/recipe/test/unit/recipe-bookmark.spec.ts b/api/libs/recipe/test/unit/domain/recipe-bookmark.spec.ts similarity index 100% rename from api/libs/recipe/test/unit/recipe-bookmark.spec.ts rename to api/libs/recipe/test/unit/domain/recipe-bookmark.spec.ts diff --git a/api/libs/recipe/test/unit/recipe-view-log.spec.ts b/api/libs/recipe/test/unit/domain/recipe-view-log.spec.ts similarity index 100% rename from api/libs/recipe/test/unit/recipe-view-log.spec.ts rename to api/libs/recipe/test/unit/domain/recipe-view-log.spec.ts diff --git a/api/libs/recipe/test/unit/recipe-bookmark.service.spec.ts b/api/libs/recipe/test/unit/service/recipe-bookmark.service.spec.ts similarity index 65% rename from api/libs/recipe/test/unit/recipe-bookmark.service.spec.ts rename to api/libs/recipe/test/unit/service/recipe-bookmark.service.spec.ts index 83a6561..1e1862c 100644 --- a/api/libs/recipe/test/unit/recipe-bookmark.service.spec.ts +++ b/api/libs/recipe/test/unit/service/recipe-bookmark.service.spec.ts @@ -1,17 +1,19 @@ import { TestBed } from '@automock/jest'; import { RecipeBookmarkRepository } from '@app/recipe/repositories/recipe-bookmark/recipe-bookmark.repository'; import { RecipeBookmarkService } from '@app/recipe/services/recipe-bookmark.service'; -import { FilterRecipeBookmarkDto } from '@app/recipe/dto/recipe-bookmark/filter-recipe-bookmark.dto'; -import { RecipeBookmarksItemDto } from '@app/recipe/dto/recipe-bookmark/recipe-bookmarks-item.dto'; -import { RecipeBookmarksResponseDto } from '@app/recipe/dto/recipe-bookmark/recipe-bookmarks-response.dto'; -import { RecipeBookmarksAndCountDto } from '@app/recipe/dto/recipe-bookmark/recipe-bookmarks-count.dto'; import { RecipeRepository } from '@app/recipe/repositories/recipe/recipe.repository'; import { UserRepository } from '@app/user/repositories/user.repository'; -import { RecipeEntity } from '@app/recipe/domain/recipe.entity'; -import { UserEntity } from '@app/user/domain/user.entity'; -import { RecipeBookmarkEntity } from '@app/recipe/domain/recipe-bookmark.entity'; import { RecipeBookmarkDuplicateException } from '@app/recipe/exception/domain.exception'; -import { Diet } from '@prisma/client'; +import { recipeEntity } from '../../fixture/recipe.fixture'; +import { userEntity } from '../../../../user/test/fixture/user.fixture'; +import { + filterRecipeBookmarkDto, + recipeBookmarkEntity, + recipeBookmarksAndCountLast, + recipeBookmarksAndCountNotLast, + recipeBookmarksResponseDtoLast, + recipeBookmarksResponseDtoNotLast, +} from '../../fixture/recipe-bookmark.fixture'; describe('RecipeBookmarkService', () => { let service: RecipeBookmarkService; @@ -19,64 +21,6 @@ describe('RecipeBookmarkService', () => { let recipeRepository: jest.Mocked; let userRepository: jest.Mocked; - const recipeEntity: RecipeEntity = { - id: 1, - name: 'recipe', - mongoId: 'mongoId', - ownerId: 1, - description: 'description', - thumbnail: 'thumbnail', - viewCount: 0, - originUrl: 'originUrl', - createdAt: new Date(), - updatedAt: new Date(), - }; - const userEntity: UserEntity = { - id: 1, - email: 'email', - username: 'name', - introduction: 'introduction', - diet: Diet.NORMAL, - thumbnail: 'thumbnail', - createdAt: new Date(), - updatedAt: new Date(), - }; - const recipeBookmarkEntity: RecipeBookmarkEntity = { - id: 1, - recipeId: 1, - userId: 1, - createdAt: new Date(), - updatedAt: new Date(), - }; - - const filterRecipeBookmarkDto: FilterRecipeBookmarkDto = { - page: 1, - limit: 2, - }; - const recipeBookmarksAndCountLast: jest.Mocked = { - recipes: [new RecipeBookmarksItemDto(), new RecipeBookmarksItemDto()], - count: 2, - toRecipeBookmarksResponseDto: jest.fn(), - }; - const recipeBookmarksAndCountNotLast: jest.Mocked = - { - recipes: [new RecipeBookmarksItemDto(), new RecipeBookmarksItemDto()], - count: 15, - toRecipeBookmarksResponseDto: jest.fn(), - }; - const recipeBookmarksResponseDtoLast: RecipeBookmarksResponseDto = { - results: recipeBookmarksAndCountLast.recipes, - page: 1, - count: 2, - hasNext: true, - }; - const recipeBookmarksResponseDtoNotLast: RecipeBookmarksResponseDto = { - results: recipeBookmarksAndCountNotLast.recipes, - page: 1, - count: 2, - hasNext: false, - }; - beforeEach(async () => { const { unit, unitRef } = TestBed.create(RecipeBookmarkService).compile(); diff --git a/api/libs/recipe/test/unit/recipe.service.spec.ts b/api/libs/recipe/test/unit/service/recipe.service.spec.ts similarity index 84% rename from api/libs/recipe/test/unit/recipe.service.spec.ts rename to api/libs/recipe/test/unit/service/recipe.service.spec.ts index 044d67d..7f6cbf6 100644 --- a/api/libs/recipe/test/unit/recipe.service.spec.ts +++ b/api/libs/recipe/test/unit/service/recipe.service.spec.ts @@ -1,23 +1,27 @@ -import { FilterRecipeDto } from '@app/recipe/dto/recipe/filter-recipe.dto'; import { MongoRecipeRepository } from '@app/recipe/repositories/recipe/mongo.recipe.repository'; import { RecipeRepository } from '@app/recipe/repositories/recipe/recipe.repository'; import { RecipeService } from '@app/recipe/services/recipe.service'; import { TestBed } from '@automock/jest'; import { RecipeViewLogRepository } from '@app/recipe/repositories/recipe-view-log/recipe-view-log.repository'; -import { RecipeViewLogEntity } from '@app/recipe/domain/recipe-view-log.entity'; import { UserEntity } from '@app/user/domain/user.entity'; -import { RecipeEntity } from '@app/recipe/domain/recipe.entity'; import { Recipe as MongoRecipe } from '@app/recipe/domain/mongo/mongo.recipe.entity'; import { RecipeViewerIdentifier } from '@app/recipe/dto/recipe-view-log/recipe-viewer-identifier'; import { Types } from 'mongoose'; import { CreateMongoRecipeDto } from '@app/recipe/dto/recipe/create-mongo-recipe.dto'; import { UpdateRecipeDto } from '@app/recipe/dto/recipe/update-recipe.dto'; -import { TextSearchRecipeDto } from '@app/recipe/dto/recipe/text-search.dto'; -import { RecipeDetailDto } from '@app/recipe/dto/recipe/recipe-detail.dto'; import { RecipesItemDto } from '@app/recipe/dto/recipe/recipes-item.dto'; -import { RecipesResponseDto } from '@app/recipe/dto/recipe/recipes-response.dto'; -import { RecipesAndCountDto } from '@app/recipe/dto/recipe/recipes-count.dto'; import { Diet } from '@prisma/client'; +import { + filterRecipeDto, + recipeDetailDto, + recipeEntity, + recipesAndCountLast, + recipesAndCountNotLast, + recipesResponseDtoLast, + recipesResponseDtoNotLast, + textSearchRecipeDto, +} from '../../fixture/recipe.fixture'; +import { recipeViewLogEntity } from '../../fixture/recipe-view-log.fixture'; describe('RecipeService', () => { let service: RecipeService; @@ -25,71 +29,6 @@ describe('RecipeService', () => { let recipeRepository: jest.Mocked; let recipeViewLogRepository: jest.Mocked; - const filterRecipeDto: FilterRecipeDto = { - page: 1, - limit: 2, - }; - const textSearchRecipeDto: TextSearchRecipeDto = { - searchQuery: 'test', - page: 1, - limit: 10, - }; - const recipesAndCountLast: jest.Mocked = { - recipes: [new RecipesItemDto(), new RecipesItemDto()], - count: 2, - toRecipesResponseDto: jest.fn(), - }; - const recipesAndCountNotLast: jest.Mocked = { - recipes: [new RecipesItemDto(), new RecipesItemDto()], - count: 15, - toRecipesResponseDto: jest.fn(), - }; - const recipesResponseDtoLast: RecipesResponseDto = { - results: recipesAndCountLast.recipes, - page: 1, - count: 2, - hasNext: true, - }; - const recipesResponseDtoNotLast: RecipesResponseDto = { - results: recipesAndCountNotLast.recipes, - page: 1, - count: 2, - hasNext: false, - }; - const user: UserEntity = { - id: 1, - email: 'test@test.com', - username: 'test', - introduction: '', - diet: Diet.NORMAL, - thumbnail: '', - createdAt: new Date(), - updatedAt: new Date(), - }; - const recipe: RecipeEntity = { - id: 1, - name: 'test', - mongoId: 'test', - description: 'test', - ownerId: user.id, - owner: user, - thumbnail: 'test', - originUrl: 'test', - viewCount: 1, - createdAt: new Date(), - updatedAt: new Date(), - }; - const recipeViewLog: RecipeViewLogEntity = { - id: 1, - recipeId: recipe.id, - recipe, - userId: user.id, - user: user, - userIp: '::1', - createdAt: new Date(), - updatedAt: new Date(), - }; - beforeEach(async () => { const { unit, unitRef } = TestBed.create(RecipeService).compile(); @@ -116,7 +55,7 @@ describe('RecipeService', () => { ...new MongoRecipe(), id: mongoId, }; - recipeRepository.create.mockResolvedValue(recipe); + recipeRepository.create.mockResolvedValue(recipeEntity); mongoRecipeRepository.create.mockResolvedValue(mongoRecipe); const result = await service.create(createMongoRecipeDto); @@ -125,7 +64,7 @@ describe('RecipeService', () => { createMongoRecipeDto, ); expect(recipeRepository.create).toHaveBeenCalledWith(createRecipeDto); - expect(result).toEqual(recipe); + expect(result).toEqual(recipeEntity); }); }); @@ -253,13 +192,12 @@ describe('RecipeService', () => { describe('findOne', () => { it('should return a recipe', async () => { - const recipeDto = new RecipeDetailDto(); - mongoRecipeRepository.findOneByMysqlId.mockResolvedValue(recipeDto); - recipeRepository.increaseViewCount.mockResolvedValue(recipe); + mongoRecipeRepository.findOneByMysqlId.mockResolvedValue(recipeDetailDto); + recipeRepository.increaseViewCount.mockResolvedValue(recipeEntity); mongoRecipeRepository.increaseViewCountByMySqlId.mockResolvedValue( new MongoRecipe(), ); - recipeViewLogRepository.create.mockResolvedValue(recipeViewLog); + recipeViewLogRepository.create.mockResolvedValue(recipeViewLogEntity); const result = await service.findOne(1, { ip: '::1', @@ -275,7 +213,7 @@ describe('RecipeService', () => { }), }); - expect(result).toEqual(recipeDto); + expect(result).toEqual(recipeDetailDto); }); }); @@ -371,11 +309,11 @@ describe('RecipeService', () => { describe('viewRecipe', () => { it('should return true and create RecipeViewLog', async () => { - recipeRepository.increaseViewCount.mockResolvedValue(recipe); + recipeRepository.increaseViewCount.mockResolvedValue(recipeEntity); mongoRecipeRepository.increaseViewCountByMySqlId.mockResolvedValue( new MongoRecipe(), ); - recipeViewLogRepository.create.mockResolvedValue(recipeViewLog); + recipeViewLogRepository.create.mockResolvedValue(recipeViewLogEntity); const result = await service.viewRecipe(1, { ip: '::1', @@ -456,12 +394,12 @@ describe('RecipeService', () => { describe('update', () => { it('should update well', async () => { - recipeRepository.update.mockResolvedValue(recipe); + recipeRepository.update.mockResolvedValue(recipeEntity); const result = await service.update(1, {} as UpdateRecipeDto); expect(recipeRepository.update).toHaveBeenCalledWith(1, {}); - expect(result).toEqual(recipe); + expect(result).toEqual(recipeEntity); }); it('should throw NotFoundException', async () => { @@ -475,7 +413,7 @@ describe('RecipeService', () => { describe('deleteOne', () => { it('should delete well', async () => { - recipeRepository.deleteOne.mockResolvedValue(recipe); + recipeRepository.deleteOne.mockResolvedValue(recipeEntity); mongoRecipeRepository.deleteOneByMysqlId.mockResolvedValue( new MongoRecipe(), ); @@ -484,7 +422,7 @@ describe('RecipeService', () => { expect(recipeRepository.deleteOne).toHaveBeenCalledWith(1); expect(mongoRecipeRepository.deleteOneByMysqlId).toHaveBeenCalledWith(1); - expect(result).toEqual(recipe); + expect(result).toEqual(recipeEntity); }); it('should throw NotFoundException', async () => { diff --git a/api/libs/user/src/domain/user.entity.ts b/api/libs/user/src/domain/user.entity.ts index e321a7c..1149c54 100644 --- a/api/libs/user/src/domain/user.entity.ts +++ b/api/libs/user/src/domain/user.entity.ts @@ -1,6 +1,6 @@ import { Diet } from '@app/user/domain/diet.enum'; import { $Enums, User as UserType } from '@prisma/client'; -import { CreateUserDto } from '@app/user/dto/modify-user.dto'; +import { CreateUserDto } from '@app/user/dto/create-user.dto'; export class UserEntity implements UserType { public readonly id: number; diff --git a/api/libs/user/src/dto/create-user.dto.ts b/api/libs/user/src/dto/create-user.dto.ts new file mode 100644 index 0000000..972b643 --- /dev/null +++ b/api/libs/user/src/dto/create-user.dto.ts @@ -0,0 +1,15 @@ +import { IsEmail, IsOptional, IsString, IsUrl } from 'class-validator'; + +export class CreateUserApiDto { + @IsString() + username: string; +} + +export class CreateUserDto extends CreateUserApiDto { + @IsEmail() + email: string; + + @IsOptional() + @IsUrl() + thumbnail?: string; +} diff --git a/api/libs/user/src/dto/modify-user.dto.ts b/api/libs/user/src/dto/modify-user.dto.ts deleted file mode 100644 index b659de3..0000000 --- a/api/libs/user/src/dto/modify-user.dto.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { IntersectionType, OmitType, PartialType } from '@nestjs/swagger'; -import { IsEmail, IsEnum, IsOptional, IsString, IsUrl } from 'class-validator'; -import { Diet } from '../domain/diet.enum'; - -export class CreateUserApiDto { - @IsString() - username: string; -} - -export class CreateUserDto extends CreateUserApiDto { - @IsEmail() - email: string; - - @IsOptional() - @IsUrl() - thumbnail?: string; -} - -class UpdateAdditionalDto { - @IsString() - introduction: string; - - @IsEnum(Diet) - diet: Diet; -} - -export class UpdateUserDto extends PartialType( - OmitType(IntersectionType(CreateUserDto, UpdateAdditionalDto), [ - 'email', - ] as const), -) {} diff --git a/api/libs/user/src/dto/update-user.dto.ts b/api/libs/user/src/dto/update-user.dto.ts new file mode 100644 index 0000000..0ccda75 --- /dev/null +++ b/api/libs/user/src/dto/update-user.dto.ts @@ -0,0 +1,20 @@ +import { IsEnum, IsOptional, IsString, IsUrl } from 'class-validator'; +import { Diet } from '@app/user/domain/diet.enum'; + +export class UpdateUserDto { + @IsOptional() + @IsString() + username?: string; + + @IsOptional() + @IsUrl() + thumbnail?: string; + + @IsOptional() + @IsString() + introduction?: string; + + @IsOptional() + @IsEnum(Diet) + diet?: Diet; +} diff --git a/api/libs/user/src/dto/user-response.dto.ts b/api/libs/user/src/dto/user-response.dto.ts index e138747..ab03e0e 100644 --- a/api/libs/user/src/dto/user-response.dto.ts +++ b/api/libs/user/src/dto/user-response.dto.ts @@ -1,5 +1,10 @@ import { SuccessResponse } from '@app/common/dto/success-response.dto'; import { UserDto } from '@app/user/dto/user.dto'; +import { ErrorResponse } from '@app/common/dto/error-response.dto'; +import { + UserEmailDuplicateException, + UserNameDuplicateException, +} from '@app/user/exception/domain.exception'; export class CreateUserResponseDto extends SuccessResponse { data: UserDto; @@ -16,3 +21,17 @@ export class FindAllUserResponseDto extends SuccessResponse { export class FindOneUserResponseDto extends SuccessResponse { data: UserDto; } + +export class UserEmailDuplicateResponseDto extends ErrorResponse { + error = { + name: UserEmailDuplicateException.name, + message: UserEmailDuplicateException.message, + }; +} + +export class UserNameDuplicateResponseDto extends ErrorResponse { + error = { + name: UserNameDuplicateException.name, + message: UserNameDuplicateException.message, + }; +} diff --git a/api/libs/user/src/dto/user.dto.ts b/api/libs/user/src/dto/user.dto.ts index 3f13689..4c6d423 100644 --- a/api/libs/user/src/dto/user.dto.ts +++ b/api/libs/user/src/dto/user.dto.ts @@ -1,6 +1,7 @@ import { UserEntity } from '@app/user/domain/user.entity'; import { ApiExpose } from '@app/common/decorators/api-expose.decorator'; import { Diet } from '@app/user/domain/diet.enum'; +import { Transform } from 'class-transformer'; export class UserDto { @ApiExpose({ name: 'id' }) @@ -17,42 +18,46 @@ export class UserDto { readonly thumbnail: string; + @Transform(({ value }) => new Date(value), { toClassOnly: true }) + @Transform(({ value }) => value.toISOString(), { toPlainOnly: true }) @ApiExpose({ name: 'created_at' }) readonly createdAt: Date; + @Transform(({ value }) => new Date(value), { toClassOnly: true }) + @Transform(({ value }) => value.toISOString(), { toPlainOnly: true }) @ApiExpose({ name: 'updated_at' }) readonly updatedAt: Date; - constructor( - id: number, - username: string, - email: string, - introduction: string, - diet: Diet, - thumbnail: string, - createdAt: Date, - updatedAt: Date, - ) { - this.id = id; - this.username = username; - this.email = email; - this.introduction = introduction; - this.diet = diet; - this.thumbnail = thumbnail; - this.createdAt = createdAt; - this.updatedAt = updatedAt; + constructor(props: { + id: number; + username: string; + email: string; + introduction: string; + diet: Diet; + thumbnail: string; + createdAt: Date; + updatedAt: Date; + }) { + this.id = props.id; + this.username = props.username; + this.email = props.email; + this.introduction = props.introduction; + this.diet = props.diet; + this.thumbnail = props.thumbnail; + this.createdAt = props.createdAt; + this.updatedAt = props.updatedAt; } static fromEntity(user: UserEntity): UserDto { - return new UserDto( - user.id, - user.username, - user.email, - user.introduction, - user.diet, - user.thumbnail, - user.createdAt, - user.updatedAt, - ); + return new UserDto({ + id: user.id, + username: user.username, + email: user.email, + introduction: user.introduction, + diet: user.diet, + thumbnail: user.thumbnail, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }); } } diff --git a/api/libs/user/src/exception/domain.exception.ts b/api/libs/user/src/exception/domain.exception.ts index 0cf6ee9..79fe922 100644 --- a/api/libs/user/src/exception/domain.exception.ts +++ b/api/libs/user/src/exception/domain.exception.ts @@ -1,9 +1,11 @@ import { DomainException } from '@app/common/exception-filters/exception'; export class UserEmailDuplicateException extends DomainException { + static message = 'User email already exists'; message = 'User email already exists'; } export class UserNameDuplicateException extends DomainException { + static message = 'User username already exists'; message = 'User username already exists'; } diff --git a/api/libs/user/src/repositories/mongo.user.repository.ts b/api/libs/user/src/repositories/mongo.user.repository.ts index b5c8572..3643432 100644 --- a/api/libs/user/src/repositories/mongo.user.repository.ts +++ b/api/libs/user/src/repositories/mongo.user.repository.ts @@ -1,12 +1,13 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; -import { CreateUserDto, UpdateUserDto } from '../dto/modify-user.dto'; +import { CreateUserDto } from '../dto/create-user.dto'; import { FilterUserDto } from '../dto/filter-user.dto'; import { User, UserDocument } from '@app/user/domain/mongo.user.entity'; import { Logable } from '@app/common/log/log.decorator'; import { Cacheable } from '@app/common/cache/cache.service'; import { IUserRepository } from './user.repository.interface'; +import { UpdateUserDto } from '@app/user/dto/update-user.dto'; @Injectable() export class UserRepository implements IUserRepository { diff --git a/api/libs/user/src/repositories/user.repository.interface.ts b/api/libs/user/src/repositories/user.repository.interface.ts index 5c63bee..9713a72 100644 --- a/api/libs/user/src/repositories/user.repository.interface.ts +++ b/api/libs/user/src/repositories/user.repository.interface.ts @@ -1,7 +1,8 @@ import { ICrudRepository } from '@app/common/repository/crud.repository'; import { User as MongoUser } from '@app/user/domain/mongo.user.entity'; import { UserEntity as PrismaUser } from '@app/user/domain/user.entity'; -import { CreateUserDto, UpdateUserDto } from '../dto/modify-user.dto'; +import { CreateUserDto } from '../dto/create-user.dto'; +import { UpdateUserDto } from '@app/user/dto/update-user.dto'; type User = MongoUser | PrismaUser; diff --git a/api/libs/user/src/repositories/user.repository.ts b/api/libs/user/src/repositories/user.repository.ts index be25728..5ced41a 100644 --- a/api/libs/user/src/repositories/user.repository.ts +++ b/api/libs/user/src/repositories/user.repository.ts @@ -4,8 +4,8 @@ import { IUserRepository } from './user.repository.interface'; import { UserEntity } from '@app/user/domain/user.entity'; import { Logable } from '@app/common/log/log.decorator'; import { Cacheable } from '@app/common/cache/cache.service'; -import { UpdateUserDto } from '@app/user/dto/modify-user.dto'; import { Diet } from '@prisma/client'; +import { UpdateUserDto } from '@app/user/dto/update-user.dto'; @Injectable() export class UserRepository implements IUserRepository { diff --git a/api/libs/user/src/user.controller.ts b/api/libs/user/src/user.controller.ts index acd8261..08568b9 100644 --- a/api/libs/user/src/user.controller.ts +++ b/api/libs/user/src/user.controller.ts @@ -9,13 +9,15 @@ import { Post, } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { CreateUserDto, UpdateUserDto } from './dto/modify-user.dto'; +import { CreateUserDto } from './dto/create-user.dto'; import { UserService } from './user.service'; import { CreateUserResponseDto, FindAllUserResponseDto, FindOneUserResponseDto, UpdateUserResponseDto, + UserEmailDuplicateResponseDto, + UserNameDuplicateResponseDto, } from './dto/user-response.dto'; import { ApiDeleteNoContent, @@ -25,6 +27,7 @@ import { } from '@app/common/decorators/http-method.decorator'; import { Auth } from '@app/common/decorators/auth.decorator'; import { UserDto } from '@app/user/dto/user.dto'; +import { UpdateUserDto } from '@app/user/dto/update-user.dto'; @ApiTags('User') @Controller('user') @@ -36,7 +39,11 @@ export class UserController { * * Don't use this API endpoint in production. Only for development and testing. */ - @ApiPostCreated(CreateUserResponseDto) + @ApiPostCreated( + CreateUserResponseDto, + UserEmailDuplicateResponseDto, + UserNameDuplicateResponseDto, + ) @Auth() @Post() async create(@Body() createUserDto: CreateUserDto) { @@ -76,7 +83,7 @@ export class UserController { * Update user by `id` and return updated user info. */ @Auth() - @ApiPatch(UpdateUserResponseDto) + @ApiPatch(UpdateUserResponseDto, UserNameDuplicateResponseDto) @Patch(':id') async update( @Param('id', ParseIntPipe) id: number, diff --git a/api/libs/user/src/user.service.ts b/api/libs/user/src/user.service.ts index d60976b..31afc5e 100644 --- a/api/libs/user/src/user.service.ts +++ b/api/libs/user/src/user.service.ts @@ -1,5 +1,5 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { CreateUserDto, UpdateUserDto } from './dto/modify-user.dto'; +import { CreateUserDto } from './dto/create-user.dto'; import { UserRepository } from './repositories/user.repository'; import { Logable } from '@app/common/log/log.decorator'; import { ConfigService } from '@nestjs/config'; @@ -8,6 +8,7 @@ import { UserEmailDuplicateException, UserNameDuplicateException, } from '@app/user/exception/domain.exception'; +import { UpdateUserDto } from '@app/user/dto/update-user.dto'; @Injectable() export class UserService { diff --git a/api/libs/user/test/fixture/mocked-provider.ts b/api/libs/user/test/fixture/mocked-provider.ts new file mode 100644 index 0000000..c332552 --- /dev/null +++ b/api/libs/user/test/fixture/mocked-provider.ts @@ -0,0 +1,13 @@ +import { UserService } from '@app/user/user.service'; + +export const MockedUserService = { + provide: UserService, + useValue: { + create: jest.fn(), + findAll: jest.fn(), + findOne: jest.fn(), + findByEmail: jest.fn(), + update: jest.fn(), + deleteOne: jest.fn(), + }, +}; diff --git a/api/libs/user/test/fixture/user.fixture.ts b/api/libs/user/test/fixture/user.fixture.ts new file mode 100644 index 0000000..635512e --- /dev/null +++ b/api/libs/user/test/fixture/user.fixture.ts @@ -0,0 +1,43 @@ +import { UserEntity } from '@app/user/domain/user.entity'; +import { Diet } from '@prisma/client'; +import { CreateUserApiDto, CreateUserDto } from '@app/user/dto/create-user.dto'; +import { UserDto } from '@app/user/dto/user.dto'; +import { UpdateUserDto } from '@app/user/dto/update-user.dto'; + +export const userEntity: UserEntity = { + id: 1, + email: 'test@test.com', + username: 'test', + introduction: '', + diet: Diet.NORMAL, + thumbnail: '', + createdAt: new Date(), + updatedAt: new Date(), +}; + +export const userDto: UserDto = new UserDto({ + id: userEntity.id, + email: userEntity.email, + username: userEntity.username, + introduction: userEntity.introduction, + diet: userEntity.diet, + thumbnail: userEntity.thumbnail, + createdAt: userEntity.createdAt, + updatedAt: userEntity.updatedAt, +}); + +export const createUserDto: CreateUserDto = { + username: 'test', + email: 'test@test.com', +}; + +export const createUserApiDto: CreateUserApiDto = { + username: 'test', +}; + +export const updateUserDto: UpdateUserDto = { + username: 'test', + thumbnail: 'https://test.com/test.jpg', + introduction: 'test', + diet: Diet.NORMAL, +}; diff --git a/api/libs/user/test/integration/user.controller.spec.ts b/api/libs/user/test/integration/user.controller.spec.ts deleted file mode 100644 index 9f2a0b6..0000000 --- a/api/libs/user/test/integration/user.controller.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { UserService } from '@app/user/user.service'; -import { UserController } from '@app/user/user.controller'; -import { UserRepository } from '@app/user/repositories/user.repository'; -import { AuthService } from '@app/auth/auth.service'; -import { ConfigService } from '@nestjs/config'; - -describe('UserController', () => { - let controller: UserController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [UserController], - providers: [ - UserService, - { provide: UserRepository, useValue: {} }, - { provide: 'UserModel', useValue: {} }, - { provide: AuthService, useValue: {} }, - { provide: ConfigService, useValue: {} }, - ], - }).compile(); - - controller = module.get(UserController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/api/libs/user/test/unit/user.controller.spec.ts b/api/libs/user/test/unit/user.controller.spec.ts new file mode 100644 index 0000000..38602cd --- /dev/null +++ b/api/libs/user/test/unit/user.controller.spec.ts @@ -0,0 +1,306 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UserController } from '@app/user/user.controller'; +import { MockedUserService } from '../fixture/mocked-provider'; +import { globalInhancers } from '@app/common/global-inhancers'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { UserService } from '@app/user/user.service'; +import { + createUserDto, + updateUserDto, + userDto, + userEntity, +} from '../fixture/user.fixture'; +import request from 'supertest'; +import { instanceToPlain } from 'class-transformer'; +import { AuthService } from '@app/auth/auth.service'; +import { MockedAuthService } from '../../../auth/test/fixture/mocked-provider'; +import { sessionEntity } from '../../../auth/test/fixture/auth.fixture'; + +describe('UserController', () => { + let app: INestApplication; + let userService: UserService; + let authService: AuthService; + + beforeAll(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + controllers: [UserController], + providers: [MockedUserService, MockedAuthService, ...globalInhancers], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + userService = moduleRef.get(UserService); + authService = moduleRef.get(AuthService); + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest + .spyOn(authService, 'findBySessionToken') + .mockResolvedValue(sessionEntity); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('/user (POST)', () => { + it('should return HttpStatus.CREATED and the created user', async () => { + jest.spyOn(userService, 'create').mockResolvedValue(userEntity); + + const res = ( + await request(app.getHttpServer()) + .post('/user') + .set('Authorization', 'Bearer token') + .send(createUserDto) + .expect(HttpStatus.CREATED) + ).body; + + expect(res.success).toBe(true); + expect(res.data).toEqual(instanceToPlain(userDto)); + }); + + it('should return HttpStatus.BAD_REQUEST if dto is not valid', async () => { + const res = ( + await request(app.getHttpServer()) + .post('/user') + .set('Authorization', 'Bearer token') + .send({ + ...createUserDto, + email: 'invalid-email', + }) + .expect(HttpStatus.BAD_REQUEST) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'BadRequestException', + }), + ); + }); + + it('should return HttpStatus.UNAUTHORIZED if session is not valid', async () => { + jest.spyOn(authService, 'findBySessionToken').mockResolvedValue(null); + + const res = ( + await request(app.getHttpServer()) + .post('/user') + .set('Authorization', 'Bearer token') + .send(createUserDto) + .expect(HttpStatus.UNAUTHORIZED) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'UnauthorizedException', + }), + ); + }); + }); + + describe('/user (GET)', () => { + it('should return HttpStatus.OK and the users', async () => { + jest.spyOn(userService, 'findAll').mockResolvedValue([userEntity]); + + const res = ( + await request(app.getHttpServer()) + .get('/user') + .set('Authorization', 'Bearer token') + .expect(HttpStatus.OK) + ).body; + + expect(res.success).toBe(true); + expect(res.data).toEqual([instanceToPlain(userDto)]); + }); + + it('should return HttpStatus.UNAUTHORIZED if session is not valid', async () => { + jest.spyOn(authService, 'findBySessionToken').mockResolvedValue(null); + + const res = ( + await request(app.getHttpServer()) + .get('/user') + .set('Authorization', 'Bearer token') + .expect(HttpStatus.UNAUTHORIZED) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'UnauthorizedException', + }), + ); + }); + }); + + describe('/user/:id (GET)', () => { + it('should return HttpStatus.OK and the user', async () => { + jest.spyOn(userService, 'findOne').mockResolvedValue(userEntity); + + const res = ( + await request(app.getHttpServer()) + .get('/user/1') + .set('Authorization', 'Bearer token') + .expect(HttpStatus.OK) + ).body; + + expect(res.success).toBe(true); + expect(res.data).toEqual(instanceToPlain(userDto)); + }); + + it('should return HttpStatus.BAD_REQUEST if id is not valid', async () => { + const res = ( + await request(app.getHttpServer()) + .get('/user/invalid-id') + .set('Authorization', 'Bearer token') + .expect(HttpStatus.BAD_REQUEST) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'BadRequestException', + }), + ); + }); + + it('should return HttpStatus.UNAUTHORIZED if session is not valid', async () => { + jest.spyOn(authService, 'findBySessionToken').mockResolvedValue(null); + + const res = ( + await request(app.getHttpServer()) + .get('/user/1') + .set('Authorization', 'Bearer token') + .expect(HttpStatus.UNAUTHORIZED) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'UnauthorizedException', + }), + ); + }); + }); + + describe('/user/:id (PATCH)', () => { + it('should return HttpStatus.OK and the updated user', async () => { + jest.spyOn(userService, 'update').mockResolvedValue(userEntity); + + const res = ( + await request(app.getHttpServer()) + .patch('/user/1') + .set('Authorization', 'Bearer token') + .send(updateUserDto) + .expect(HttpStatus.OK) + ).body; + + expect(res.success).toBe(true); + expect(res.data).toEqual(instanceToPlain(userDto)); + }); + + it('should return HttpStatus.BAD_REQUEST if id is not valid', async () => { + const res = ( + await request(app.getHttpServer()) + .patch('/user/invalid-id') + .set('Authorization', 'Bearer token') + .send(updateUserDto) + .expect(HttpStatus.BAD_REQUEST) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'BadRequestException', + }), + ); + }); + + it('should return HttpStatus.BAD_REQUEST if dto is not valid', async () => { + const res = ( + await request(app.getHttpServer()) + .patch('/user/1') + .set('Authorization', 'Bearer token') + .send({ + ...updateUserDto, + thumbnail: 'invalid-thumbnail', + }) + .expect(HttpStatus.BAD_REQUEST) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'BadRequestException', + }), + ); + }); + + it('should return HttpStatus.UNAUTHORIZED if session is not valid', async () => { + jest.spyOn(authService, 'findBySessionToken').mockResolvedValue(null); + + const res = ( + await request(app.getHttpServer()) + .patch('/user/1') + .set('Authorization', 'Bearer token') + .send(updateUserDto) + .expect(HttpStatus.UNAUTHORIZED) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'UnauthorizedException', + }), + ); + }); + }); + + describe('/user/:id (DELETE)', () => { + it('should return HttpStatus.NO_CONTENT and the deleted user', async () => { + jest.spyOn(userService, 'deleteOne').mockResolvedValue(userEntity); + + const res = ( + await request(app.getHttpServer()) + .delete('/user/1') + .set('Authorization', 'Bearer token') + .expect(HttpStatus.NO_CONTENT) + ).body; + }); + + it('should return HttpStatus.BAD_REQUEST if id is not valid', async () => { + const res = ( + await request(app.getHttpServer()) + .delete('/user/invalid-id') + .set('Authorization', 'Bearer token') + .expect(HttpStatus.BAD_REQUEST) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'BadRequestException', + }), + ); + }); + + it('should return HttpStatus.UNAUTHORIZED if session is not valid', async () => { + jest.spyOn(authService, 'findBySessionToken').mockResolvedValue(null); + + const res = ( + await request(app.getHttpServer()) + .delete('/user/1') + .set('Authorization', 'Bearer token') + .expect(HttpStatus.UNAUTHORIZED) + ).body; + + expect(res.success).toBe(false); + expect(res.error).toEqual( + expect.objectContaining({ + name: 'UnauthorizedException', + }), + ); + }); + }); +}); diff --git a/api/libs/user/test/unit/user.service.spec.ts b/api/libs/user/test/unit/user.service.spec.ts index 6d41d43..03fd6fa 100644 --- a/api/libs/user/test/unit/user.service.spec.ts +++ b/api/libs/user/test/unit/user.service.spec.ts @@ -1,13 +1,11 @@ import { TestBed } from '@automock/jest'; -import { CreateUserDto } from '@app/user/dto/modify-user.dto'; import { UserRepository } from '@app/user/repositories/user.repository'; import { UserService } from '@app/user/user.service'; -import { UserEntity } from '@app/user/domain/user.entity'; -import { Diet } from '@prisma/client'; import { UserEmailDuplicateException, UserNameDuplicateException, } from '@app/user/exception/domain.exception'; +import { createUserDto, userEntity } from '../fixture/user.fixture'; describe('UserService', () => { let service: UserService; @@ -21,38 +19,18 @@ describe('UserService', () => { }); describe('create', () => { - const mockUser: UserEntity = { - id: 1, - email: 'test@example.com', - username: 'test', - introduction: '', - diet: Diet.NORMAL, - thumbnail: '', - createdAt: new Date(), - updatedAt: new Date(), - }; it('should create a new user', async () => { - const createUserDto: CreateUserDto = { - ...new CreateUserDto(), - username: 'test', - email: 'test@example.com', - }; userRepository.findByEmail.mockResolvedValue(undefined); userRepository.findByUsername.mockResolvedValue(undefined); - userRepository.create.mockResolvedValue(mockUser); + userRepository.create.mockResolvedValue(userEntity); const result = await service.create(createUserDto); - expect(result).toEqual(mockUser); + expect(result).toEqual(userEntity); }); it('should not create user if the email already exists', async () => { - const createUserDto: CreateUserDto = { - ...new CreateUserDto(), - username: 'test', - email: 'test@example.com', - }; - userRepository.findByEmail.mockResolvedValue(mockUser); + userRepository.findByEmail.mockResolvedValue(userEntity); userRepository.findByUsername.mockResolvedValue(undefined); await expect(service.create(createUserDto)).rejects.toThrowError( @@ -63,13 +41,8 @@ describe('UserService', () => { }); it('should not create user if the username already exists', async () => { - const createUserDto: CreateUserDto = { - ...new CreateUserDto(), - username: 'test', - email: 'test@example.com', - }; userRepository.findByEmail.mockResolvedValue(undefined); - userRepository.findByUsername.mockResolvedValue(mockUser); + userRepository.findByUsername.mockResolvedValue(userEntity); await expect(service.create(createUserDto)).rejects.toThrowError( UserNameDuplicateException, @@ -80,24 +53,14 @@ describe('UserService', () => { }); describe('findByEmail', () => { - const mockUser: UserEntity = { - id: 1, - email: 'test@example.com', - username: 'test', - introduction: '', - diet: Diet.NORMAL, - thumbnail: '', - createdAt: new Date(), - updatedAt: new Date(), - }; it('should return the user with the given email', async () => { - const email = 'test@example.com'; - userRepository.findByEmail.mockResolvedValue(mockUser); + const email = 'test@test.com'; + userRepository.findByEmail.mockResolvedValue(userEntity); const result = await service.findByEmail(email); expect(userRepository.findByEmail).toHaveBeenCalledWith(email); - expect(result).toEqual(mockUser); + expect(result).toEqual(userEntity); }); it('should return undefined if no user is found with the given email', async () => {