diff --git a/package.json b/package.json index 04b032e..de4f1bb 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "zod": "^3.23.8" }, "devDependencies": { + "@automock/jest": "^2.1.0", "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/swagger": "^7.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcf9416..e334cd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: specifier: ^3.23.8 version: 3.23.8 devDependencies: + '@automock/jest': + specifier: ^2.1.0 + version: 2.1.0(jest@29.7.0(@types/node@20.14.10)(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.5.3))) '@nestjs/cli': specifier: ^10.0.0 version: 10.4.2 @@ -187,6 +190,23 @@ packages: resolution: {integrity: sha512-QRVEYpIfgkprNHc916JlPuNbLzOgrm9DZalHasnLUz4P6g7pR21olb8YCyM2OTJjombNhya9ZpckcADU5Qyvlg==} engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@automock/common@3.1.0': + resolution: {integrity: sha512-Y7N4YnSZRuSIfGV4/PdG2JyURa9PK1maqGZBYUG2/sIW+H/Mlhc/3NpegnlUyQFRBexEJtgmUsvfzzI1/lm0JA==} + engines: {node: ^16.10.0 || ^18.12.0 || >=20.0.0} + + '@automock/core@2.1.0': + resolution: {integrity: sha512-VIaY2woE7nWXhXKlNzk4+xzCuaVBXfGYwowrzdI+MmJBKqi2z0lUjNVix74hZRWhJpkOW+PR3UThFvThgyqIqQ==} + engines: {node: ^16.10.0 || ^18.12.0 || >=20.0.0} + + '@automock/jest@2.1.0': + resolution: {integrity: sha512-kJuTu/a5bbG8SICIfITgESpIrVbBfbMykDdICmPvJkeI51Ldq+tqiLS2Ded16vLWS9WeFKeTw/+2b/DO8QyV0Q==} + engines: {node: ^16.10.0 || ^18.12.0 || >=20.0.0} + peerDependencies: + jest: ^26 || ^27 || ^28 || ^29 + + '@automock/types@2.0.1': + resolution: {integrity: sha512-ue35e4im3n7l+Eqq3kCA2nNs8jzJbjYLni+vlPdgJ+9KfsEykJjA1OpnNN/PG1tDaM0iyR2p/uZg27MOC/qiTg==} + '@babel/code-frame@7.24.7': resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} engines: {node: '>=6.9.0'} @@ -2568,6 +2588,9 @@ packages: lodash.isboolean@3.0.3: resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + lodash.isinteger@4.0.4: resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} @@ -3806,6 +3829,24 @@ snapshots: transitivePeerDependencies: - chokidar + '@automock/common@3.1.0': + dependencies: + '@automock/types': 2.0.1 + + '@automock/core@2.1.0': + dependencies: + '@automock/common': 3.1.0 + '@automock/types': 2.0.1 + lodash.isequal: 4.5.0 + + '@automock/jest@2.1.0(jest@29.7.0(@types/node@20.14.10)(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.5.3)))': + dependencies: + '@automock/core': 2.1.0 + '@automock/types': 2.0.1 + jest: 29.7.0(@types/node@20.14.10)(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.5.3)) + + '@automock/types@2.0.1': {} + '@babel/code-frame@7.24.7': dependencies: '@babel/highlight': 7.24.7 @@ -6753,6 +6794,8 @@ snapshots: lodash.isboolean@3.0.3: {} + lodash.isequal@4.5.0: {} + lodash.isinteger@4.0.4: {} lodash.isnumber@3.0.3: {} diff --git a/src/app/auth/tests/auth.controller.spec.ts b/src/app/auth/tests/auth.controller.spec.ts index 4b03350..bf9584f 100644 --- a/src/app/auth/tests/auth.controller.spec.ts +++ b/src/app/auth/tests/auth.controller.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Response, Request } from 'express'; -import { Document, Model, Types } from 'mongoose'; +import { Model } from 'mongoose'; import { JwtService } from '@nestjs/jwt'; import { getModelToken } from '@nestjs/mongoose'; @@ -10,6 +10,7 @@ import { SignInDto, SignUpDto } from '~/app/auth/auth.dto'; import { User } from '~/schemas/user.schema'; import { Session } from '~/schemas/session.schema'; import { SessionService } from '~/app/session/session.service'; +import { Entity } from '~/types'; describe('AuthController', () => { let authController: AuthController; @@ -60,8 +61,7 @@ describe('AuthController', () => { accessToken: expect.anything(), user: expect.anything(), refreshToken: expect.anything(), - } as Document & - Session & { _id: Types.ObjectId }; + } as Entity; jest.spyOn(authService, 'signIn').mockImplementation(async () => data); const response = await authController.signIn(payload, res); @@ -86,8 +86,7 @@ describe('AuthController', () => { accessToken: expect.anything(), user: expect.anything(), refreshToken: expect.anything(), - } as Document & - Session & { _id: Types.ObjectId }; + } as Entity; jest.spyOn(authService, 'signUp').mockResolvedValue(data); const response = await authController.signUp(payload, res); diff --git a/src/app/auth/tests/auth.service.spec.ts b/src/app/auth/tests/auth.service.spec.ts new file mode 100644 index 0000000..42aab4a --- /dev/null +++ b/src/app/auth/tests/auth.service.spec.ts @@ -0,0 +1,140 @@ +import * as bcrypt from 'bcrypt'; + +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthService } from '~/app/auth/auth.service'; +import { Model } from 'mongoose'; +import { JwtService } from '@nestjs/jwt'; +import { getModelToken } from '@nestjs/mongoose'; +import { HttpException, HttpStatus } from '@nestjs/common'; + +import { User } from '~/schemas/user.schema'; +import { Session } from '~/schemas/session.schema'; +import { SessionService } from '~/app/session/session.service'; +import { SignInDto, SignUpDto } from '~/app/auth/auth.dto'; +import { Entity } from '~/types'; +import { + EMAIL_IS_ALREDY_IN_USE, + INVALID_CREDENTIALS, + USER_NOT_FOUND, +} from '~/errors'; + +describe('AuthService', () => { + let authService: AuthService; + let userModel: Model; + let sessionService: SessionService; + + afterEach(() => jest.clearAllMocks()); + + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [], + providers: [ + AuthService, + JwtService, + SessionService, + { + provide: getModelToken(User.name), + useValue: Model, + }, + { + provide: getModelToken(Session.name), + useValue: Model, + }, + ], + }).compile(); + + authService = app.get(AuthService); + userModel = app.get>(getModelToken(User.name)); + sessionService = app.get(SessionService); + }); + + describe('signUp', () => { + const payload: SignUpDto = { + email: expect.anything(), + password: expect.anything(), + name: expect.anything(), + }; + const user = {} as unknown as Entity[]; + const session = {} as unknown as Entity; + + it('must create user and return his created session', async () => { + jest.spyOn(userModel, 'findOne').mockResolvedValue(null); + jest.spyOn(userModel, 'create').mockResolvedValue(user); + jest.spyOn(sessionService, 'create').mockResolvedValue(session); + + const data = await authService.signUp(payload); + + expect(data).toBe(session); + expect(sessionService.create).toHaveBeenCalledTimes(1); + expect(userModel.findOne).toHaveBeenCalledTimes(1); + }); + + it('should return error if email is already in use', async () => { + jest.spyOn(userModel, 'findOne').mockResolvedValue(user); + jest.spyOn(sessionService, 'create').mockResolvedValue(session); + + await expect(authService.signUp(payload)).rejects.toThrow( + new HttpException(EMAIL_IS_ALREDY_IN_USE, HttpStatus.CONFLICT), + ); + expect(sessionService.create).toHaveBeenCalledTimes(0); + expect(userModel.findOne).toHaveBeenCalledTimes(1); + }); + }); + + describe('signIn', () => { + const payload: SignInDto = { + email: expect.anything(), + password: expect.anything(), + }; + const user = {} as unknown as Entity; + const session = {} as unknown as Entity; + + it('should return the logged in user is session', async () => { + jest.spyOn(userModel, 'findOne').mockResolvedValue(user); + jest.spyOn(sessionService, 'findByUser').mockResolvedValue(session); + jest.spyOn(bcrypt, 'compare').mockImplementation(async () => true); + + const data = await authService.signIn(payload); + + expect(data).toBe(session); + expect(sessionService.findByUser).toHaveBeenCalledTimes(1); + expect(userModel.findOne).toHaveBeenCalledTimes(1); + }); + + it('must log in user and create a new session', async () => { + jest.spyOn(userModel, 'findOne').mockResolvedValue(user); + jest.spyOn(sessionService, 'findByUser').mockResolvedValue(null); + jest.spyOn(sessionService, 'create').mockResolvedValue(session); + jest.spyOn(bcrypt, 'compare').mockImplementation(async () => true); + + const data = await authService.signIn(payload); + + expect(data).toBe(session); + expect(sessionService.findByUser).toHaveBeenCalledTimes(1); + expect(userModel.findOne).toHaveBeenCalledTimes(1); + }); + + it('should return error when password is invalid', async () => { + jest.spyOn(userModel, 'findOne').mockResolvedValue(user); + jest.spyOn(sessionService, 'findByUser').mockResolvedValue(session); + jest.spyOn(bcrypt, 'compare').mockImplementation(async () => false); + + await expect(authService.signIn(payload)).rejects.toThrow( + new HttpException(INVALID_CREDENTIALS, HttpStatus.UNAUTHORIZED), + ); + expect(sessionService.findByUser).toHaveBeenCalledTimes(0); + expect(userModel.findOne).toHaveBeenCalledTimes(1); + }); + + it('should return error when user is not found', async () => { + jest.spyOn(userModel, 'findOne').mockResolvedValue(null); + jest.spyOn(bcrypt, 'compare').mockImplementation(async () => false); + + await expect(authService.signIn(payload)).rejects.toThrow( + new HttpException(USER_NOT_FOUND, HttpStatus.NOT_FOUND), + ); + expect(bcrypt.compare).toHaveBeenCalledTimes(0); + expect(userModel.findOne).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/app/session/tests/session.service.spec.ts b/src/app/session/tests/session.service.spec.ts new file mode 100644 index 0000000..5c354eb --- /dev/null +++ b/src/app/session/tests/session.service.spec.ts @@ -0,0 +1,115 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { JwtService } from '@nestjs/jwt'; +import { getModelToken } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; + +import { SessionService } from '~/app/session/session.service'; +import { Session } from '~/schemas/session.schema'; +import { Entity, EntityQuery } from '~/types'; + +describe('AuthService', () => { + let sessionService: SessionService; + let jwtService: JwtService; + let sessionModel: Model; + + const user = expect.anything(); + + afterEach(() => jest.clearAllMocks()); + + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [], + providers: [ + JwtService, + SessionService, + { + provide: getModelToken(Session.name), + useValue: Model, + }, + ], + }).compile(); + + jwtService = app.get(JwtService); + sessionModel = app.get>(getModelToken(Session.name)); + sessionService = app.get(SessionService); + }); + + describe('refresh', () => { + it('must generate a new access token and refresh its session', async () => { + const accessToken = expect.anything(); + const session = { + populate: jest.fn(() => session), + } as unknown as EntityQuery; + + jest.spyOn(jwtService, 'signAsync').mockResolvedValue(accessToken); + jest.spyOn(sessionModel, 'findOneAndUpdate').mockResolvedValue(session); + + const data = await sessionService.refresh(user); + + expect(data).toBe(session); + expect(session.populate).toHaveBeenCalledTimes(1); + expect(session.populate).toHaveBeenCalledWith('user'); + expect(sessionModel.findOneAndUpdate).toHaveBeenCalledTimes(1); + expect(sessionModel.findOneAndUpdate).toHaveBeenCalledWith( + { user, isExpired: false }, + { accessToken }, + { new: true }, + ); + }); + }); + + describe('create', () => { + const session = { + populate: jest.fn(), + }; + + it('should create and return a new session', async () => { + jest.spyOn(jwtService, 'signAsync').mockResolvedValue(expect.anything()); + jest + .spyOn(sessionModel, 'create') + .mockResolvedValue(session as unknown as Entity[]); + + const data = await sessionService.create(user); + + expect(session.populate).toHaveBeenCalledTimes(1); + expect(session.populate).toHaveBeenCalledWith('user'); + expect(sessionModel.create).toHaveBeenCalledTimes(1); + expect(data).toBe(session); + }); + }); + + describe('expireSession', () => { + it('should expire a session', async () => { + jest + .spyOn(sessionModel, 'updateOne') + .mockResolvedValue(expect.anything()); + + await sessionService.expireSession(user); + + expect(sessionModel.updateOne).toHaveBeenCalledTimes(1); + expect(sessionModel.updateOne).toHaveBeenCalledWith( + { user }, + { isExpired: true }, + ); + }); + }); + + describe('findByUser', () => { + it('must return session with user data', async () => { + const session = { + populate: jest.fn(() => session), + } as unknown as EntityQuery; + jest.spyOn(sessionModel, 'findOne').mockReturnValue(session); + + const data = await sessionService.findByUser(user); + + expect(session.populate).toHaveBeenCalledWith('user'); + expect(session.populate).toHaveBeenCalledTimes(1); + expect(sessionModel.findOne).toHaveBeenCalledWith({ + user, + isExpired: false, + }); + expect(data).toBe(session); + }); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..90c303e --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,5 @@ +import { Types, Document, Query } from 'mongoose'; + +export type Entity = Document & + T & { _id: Types.ObjectId }; +export type EntityQuery = Query;