diff --git a/.gitignore b/.gitignore index 43ad4b2f..b324834c 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,8 @@ lerna-debug.log* .data /files .env -/ormconfig.json \ No newline at end of file +/ormconfig.json + +# Other +local_dev/ +temp/ diff --git a/.husky/pre-commit b/.husky/pre-commit index 75fac8e1..65d9b480 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -npm run lint +npm run lint; diff --git a/README.md b/README.md index 5cfd645d..dda919db 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ O [Projeto do App CCT](https://github.com/RJ-SMTR/app-cct) consome esta API. * [Descrição](#descrição) * [Table of Contents](#table-of-contents) * [Quick run](#quick-run) - * [Comfortable development](#comfortable-development) + * [Desenvolvimento confortável](#desenvolvimento-confortável) * [Links](#links) * [Automatic update of dependencies](#automatic-update-of-dependencies) * [Banco de dados](#banco-de-dados) diff --git a/env-example b/env-example index 03360050..8150af9a 100644 --- a/env-example +++ b/env-example @@ -68,6 +68,4 @@ TWITTER_CONSUMER_SECRET= WORKER_HOST=redis://redis:6379/1 TEST_ADMIN_EMAIL= -TEST_ADMIN_PASSWORD= -TEST_LICENSEE_PERMIT_CODE= -TEST_LICENSEE_PASSWORD= \ No newline at end of file +TEST_ADMIN_PASSWORD= \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1efe0632..00852595 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "api-cct", - "version": "0.0.4", + "version": "0.0.8", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "api-cct", - "version": "0.0.4", + "version": "0.0.8", "license": "UNLICENSED", "dependencies": { "@aws-sdk/client-s3": "3.350.0", @@ -32,6 +32,7 @@ "class-validator": "0.14.0", "date-fns": "^2.30.0", "fb": "2.0.0", + "gerador-validador-cpf": "^5.0.2", "google-auth-library": "8.8.0", "handlebars": "4.7.7", "multer": "1.4.4", @@ -8002,19 +8003,6 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8= sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/ftp": { "version": "0.3.10", "resolved": "https://registry.npmjs.org/ftp/-/ftp-0.3.10.tgz", @@ -8115,6 +8103,11 @@ "node": ">=6.9.0" } }, + "node_modules/gerador-validador-cpf": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/gerador-validador-cpf/-/gerador-validador-cpf-5.0.2.tgz", + "integrity": "sha512-7nqJilkfIv3HIbB50uP32SOxe/A3TyvVS3AXxwU6cqHq7jMTnkp0WGPaGytY3Yc36RjzysVQ6xhlwcCt70CnOw==" + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -22042,12 +22035,6 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8= sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, - "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "optional": true - }, "ftp": { "version": "0.3.10", "resolved": "https://registry.npmjs.org/ftp/-/ftp-0.3.10.tgz", @@ -22129,6 +22116,11 @@ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true }, + "gerador-validador-cpf": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/gerador-validador-cpf/-/gerador-validador-cpf-5.0.2.tgz", + "integrity": "sha512-7nqJilkfIv3HIbB50uP32SOxe/A3TyvVS3AXxwU6cqHq7jMTnkp0WGPaGytY3Yc36RjzysVQ6xhlwcCt70CnOw==" + }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", diff --git a/package.json b/package.json index 9643c1de..c18a2fd5 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "class-validator": "0.14.0", "date-fns": "^2.30.0", "fb": "2.0.0", + "gerador-validador-cpf": "^5.0.2", "google-auth-library": "8.8.0", "handlebars": "4.7.7", "multer": "1.4.4", diff --git a/src/auth-licensee/auth-licensee.controller.spec.ts b/src/auth-licensee/auth-licensee.controller.spec.ts deleted file mode 100644 index 4985bf6f..00000000 --- a/src/auth-licensee/auth-licensee.controller.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AuthLicenseeController } from './auth-licensee.controller'; - -describe('AuthLicenseeController', () => { - let controller: AuthLicenseeController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [AuthLicenseeController], - }).compile(); - - controller = module.get(AuthLicenseeController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/auth-licensee/auth-licensee.controller.ts b/src/auth-licensee/auth-licensee.controller.ts index 8c29a282..ed52a4cd 100644 --- a/src/auth-licensee/auth-licensee.controller.ts +++ b/src/auth-licensee/auth-licensee.controller.ts @@ -14,6 +14,8 @@ import { LoginResponseType } from 'src/utils/types/auth/login-response.type'; import { AuthLicenseeService } from './auth-licensee.service'; import { AuthLicenseeLoginDto } from './dto/auth-licensee-login.dto'; import { AuthRegisterLicenseeDto } from './dto/auth-register-licensee.dto'; +import { IALConcludeRegistration } from './interfaces/al-conclude-registration.interface'; +import { IALInviteProfile } from './interfaces/al-invite-profile.interface'; @ApiTags('Auth') @Controller({ @@ -41,7 +43,7 @@ export class AuthLicenseeController { async invite( @Param('hash', MailHistoryValidationPipe) hash: string, - ): Promise { + ): Promise { return await this.authLicenseeService.getInviteProfile(hash); } @@ -52,7 +54,7 @@ export class AuthLicenseeController { @Param('hash', MailHistoryValidationPipe) hash: string, @Body() data: AuthRegisterLicenseeDto, - ): Promise { + ): Promise { return await this.authLicenseeService.concludeRegistration(data, hash); } } diff --git a/src/auth-licensee/auth-licensee.service.spec.ts b/src/auth-licensee/auth-licensee.service.spec.ts index 6ebe5e85..4445ccac 100644 --- a/src/auth-licensee/auth-licensee.service.spec.ts +++ b/src/auth-licensee/auth-licensee.service.spec.ts @@ -1,18 +1,216 @@ +import { Provider } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; +import { CoreBankService } from 'src/core-bank/core-bank.service'; +import { ForgotService } from 'src/forgot/forgot.service'; +import { MailHistory } from 'src/mail-history/entities/mail-history.entity'; +import { MailHistoryService } from 'src/mail-history/mail-history.service'; +import { MailService } from 'src/mail/mail.service'; +import { User } from 'src/users/entities/user.entity'; +import { UsersService } from 'src/users/users.service'; import { AuthLicenseeService } from './auth-licensee.service'; +import { SgtuService } from 'src/sgtu/sgtu.service'; +import { JaeService } from 'src/jae/jae.service'; +import { InviteStatus } from 'src/mail-history-statuses/entities/mail-history-status.entity'; +import { InviteStatusEnum } from 'src/mail-history-statuses/mail-history-status.enum'; +import { SgtuDto } from 'src/sgtu/dto/sgtu.dto'; +import { JaeProfileInterface } from 'src/jae/interfaces/jae-profile.interface'; +import { Role } from 'src/roles/entities/role.entity'; +import { RoleEnum } from 'src/roles/roles.enum'; +import { BaseValidator } from 'src/utils/validators/base-validator'; +/** + * All tests below were based on the requirements on GitHub. + * @see {@link https://github.com/RJ-SMTR/api-cct/issues/94#issuecomment-1815016208 Requirements #94 - GitHub} + */ describe('AuthLicenseeService', () => { - let service: AuthLicenseeService; + let authLicenseeService: AuthLicenseeService; + let jwtService: JwtService; + let usersService: UsersService; + let mailHistoryService: MailHistoryService; + let sgtuService: SgtuService; + let jaeService: JaeService; + let baseValidator: BaseValidator; beforeEach(async () => { + const usersServiceMock = { + provide: UsersService, + useValue: { + create: jest.fn(), + getOne: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + softDelete: jest.fn(), + }, + } as Provider; + const forgotServiceMock = { + provide: ForgotService, + useValue: { + findOne: jest.fn(), + create: jest.fn(), + }, + } as Provider; + const mailServiceMock = { + provide: MailService, + useValue: { + userConcludeRegistration: jest.fn(), + forgotPassword: jest.fn(), + }, + } as Provider; + const coreBankServiceMock = { + provide: CoreBankService, + useValue: { + updateDataIfNeeded: jest.fn(), + }, + } as Provider; + const mailHistoryServiceMock = { + provide: MailHistoryService, + useValue: { + findOne: jest.fn(), + getOne: jest.fn(), + getRemainingQuota: jest.fn(), + update: jest.fn(), + generateHash: jest.fn(), + }, + } as Provider; + const jwtServiceMock = { + provide: JwtService, + useValue: { + sign: jest.fn(), + }, + } as Provider; + const sgtuServiceMock = { + provide: SgtuService, + useValue: { + getGeneratedProfile: jest.fn(), + }, + } as Provider; + const jaeServiceMock = { + provide: JaeService, + useValue: { + getGeneratedProfileByUser: jest.fn(), + }, + } as Provider; + const BaseValidatorMock = { + provide: BaseValidator, + useValue: { + validateOrReject: jest.fn(), + }, + } as Provider; + const module: TestingModule = await Test.createTestingModule({ - providers: [AuthLicenseeService], + providers: [ + AuthLicenseeService, + jwtServiceMock, + usersServiceMock, + forgotServiceMock, + mailServiceMock, + coreBankServiceMock, + mailHistoryServiceMock, + sgtuServiceMock, + jaeServiceMock, + BaseValidatorMock, + ], }).compile(); - service = module.get(AuthLicenseeService); + authLicenseeService = module.get(AuthLicenseeService); + usersService = module.get(UsersService); + mailHistoryService = module.get(MailHistoryService); + sgtuService = module.get(SgtuService); + jaeService = module.get(JaeService); + jwtService = module.get(JwtService); + jwtService = module.get(JwtService); + baseValidator = module.get(BaseValidator); }); it('should be defined', () => { - expect(service).toBeDefined(); + expect(authLicenseeService).toBeDefined(); + }); + + describe('getInviteProfile', () => { + it('should throw exception when mail status is not SENT', async () => { + // Arrange + const user = new User({ + id: 1, + email: 'user1@example.com', + hash: 'hash_1', + }); + const mailHistory = { + id: 1, + user: user, + hash: 'hash_1', + inviteStatus: new InviteStatus(InviteStatusEnum.queued), + } as MailHistory; + jest.spyOn(usersService, 'getOne').mockResolvedValue(user); + jest.spyOn(mailHistoryService, 'getOne').mockResolvedValue(mailHistory); + jest.spyOn(mailHistoryService, 'getRemainingQuota').mockResolvedValue(0); + jest.spyOn(baseValidator, 'validateOrReject').mockResolvedValue({}); + + // Act + const response = authLicenseeService.getInviteProfile('hash_1'); + // Assert + await expect(response).rejects.toThrowError(); + }); + }); + + describe('concludeRegistration', () => { + it('should set mail status to SENT when succeeded', async () => { + // Arrange + const dateNow = new Date('2023-01-01T10:00:00'); + const user = new User({ + id: 1, + email: 'user1@example.com', + hash: 'hash_1', + permitCode: 'permitCode1', + role: new Role(RoleEnum.user), + }); + const mailHistory = { + id: 1, + user: user, + hash: 'hash_1', + inviteStatus: new InviteStatus(InviteStatusEnum.sent), + sentAt: dateNow, + } as MailHistory; + const sgtuProfile = { + cpfCnpj: 'cpf1', + permitCode: 'permitCode1', + email: user.email, + } as SgtuDto; + const jaeProfile = { + id: 1, + passValidatorId: 'validatorId', + permitCode: 'permitCode1', + } as JaeProfileInterface; + jest.spyOn(mailHistoryService, 'findOne').mockResolvedValue(mailHistory); + jest.spyOn(usersService, 'getOne').mockResolvedValue(user); + jest.spyOn(usersService, 'update').mockResolvedValue(user); + jest + .spyOn(sgtuService, 'getGeneratedProfile') + .mockResolvedValue(sgtuProfile); + jest + .spyOn(jaeService, 'getGeneratedProfileByUser') + .mockReturnValue(jaeProfile); + jest + .spyOn(global.Date, 'now') + .mockImplementation(() => dateNow.valueOf()); + jest.spyOn(jwtService, 'sign').mockReturnValue('token'); + jest.spyOn(baseValidator, 'validateOrReject').mockResolvedValue({}); + + // Act + await authLicenseeService.concludeRegistration( + { password: 'secret' }, + 'hash_1', + ); + // Assert + expect(mailHistoryService.update).toBeCalledWith( + 1, + { + inviteStatus: { + id: InviteStatusEnum.used, + }, + }, + expect.any(String), + ); + }); }); }); diff --git a/src/auth-licensee/auth-licensee.service.ts b/src/auth-licensee/auth-licensee.service.ts index 46f638bc..ad9a0fca 100644 --- a/src/auth-licensee/auth-licensee.service.ts +++ b/src/auth-licensee/auth-licensee.service.ts @@ -18,7 +18,8 @@ import { LoginResponseType } from 'src/utils/types/auth/login-response.type'; import { BaseValidator } from 'src/utils/validators/base-validator'; import { AuthLicenseeLoginDto } from './dto/auth-licensee-login.dto'; import { AuthRegisterLicenseeDto } from './dto/auth-register-licensee.dto'; -import { AuthLicenseeInviteProfileInterface } from './interfaces/auth-licensee-invite-profile.interface'; +import { IALInviteProfile } from './interfaces/al-invite-profile.interface'; +import { IALConcludeRegistration } from './interfaces/al-conclude-registration.interface'; @Injectable() export class AuthLicenseeService { @@ -114,9 +115,7 @@ export class AuthLicenseeService { return { token, user }; } - async getInviteProfile( - hash: string, - ): Promise { + async getInviteProfile(hash: string): Promise { const invite = await this.mailHistoryService.getOne({ hash }); if (invite.inviteStatus.id !== InviteStatusEnum.sent) { @@ -187,11 +186,12 @@ export class AuthLicenseeService { ); } - const inviteResponse: AuthLicenseeInviteProfileInterface = { + const inviteResponse: IALInviteProfile = { fullName: sgtuProfile.fullName, permitCode: sgtuProfile.permitCode, email: sgtuProfile.email, hash: invite.hash, + inviteStatus: invite.inviteStatus, }; return inviteResponse; @@ -200,7 +200,7 @@ export class AuthLicenseeService { async concludeRegistration( registerDto: AuthRegisterLicenseeDto, hash: string, - ): Promise { + ): Promise { const invite = await this.mailHistoryService.findOne({ hash }); if (!invite) { throw new HttpException( diff --git a/src/auth-licensee/interfaces/al-conclude-registration.interface.ts b/src/auth-licensee/interfaces/al-conclude-registration.interface.ts new file mode 100644 index 00000000..991d6ab6 --- /dev/null +++ b/src/auth-licensee/interfaces/al-conclude-registration.interface.ts @@ -0,0 +1,6 @@ +import { User } from 'src/users/entities/user.entity'; + +export interface IALConcludeRegistration { + token: string; + user: User; +} diff --git a/src/auth-licensee/interfaces/al-invite-profile.interface.ts b/src/auth-licensee/interfaces/al-invite-profile.interface.ts new file mode 100644 index 00000000..536af41b --- /dev/null +++ b/src/auth-licensee/interfaces/al-invite-profile.interface.ts @@ -0,0 +1,9 @@ +import { InviteStatus } from 'src/mail-history-statuses/entities/mail-history-status.entity'; + +export interface IALInviteProfile { + hash: string; + permitCode: string; + email: string; + fullName: string; + inviteStatus: InviteStatus; +} diff --git a/src/auth-licensee/interfaces/auth-licensee-invite-profile.interface.ts b/src/auth-licensee/interfaces/auth-licensee-invite-profile.interface.ts deleted file mode 100644 index 555bad5f..00000000 --- a/src/auth-licensee/interfaces/auth-licensee-invite-profile.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface AuthLicenseeInviteProfileInterface { - hash: string; - permitCode: string; - email: string; - fullName: string; -} diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts new file mode 100644 index 00000000..d6c9dad6 --- /dev/null +++ b/src/auth/auth.service.spec.ts @@ -0,0 +1,247 @@ +import { Provider } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Test, TestingModule } from '@nestjs/testing'; +import { CoreBankService } from 'src/core-bank/core-bank.service'; +import { ForgotService } from 'src/forgot/forgot.service'; +import { InviteStatusEnum } from 'src/mail-history-statuses/mail-history-status.enum'; +import { MailHistory } from 'src/mail-history/entities/mail-history.entity'; +import { MailHistoryService } from 'src/mail-history/mail-history.service'; +import { MailRegistrationInterface } from 'src/mail/interfaces/mail-registration.interface'; +import { MailSentInfo } from 'src/mail/interfaces/mail-sent-info.interface'; +import { MailService } from 'src/mail/mail.service'; +import { User } from 'src/users/entities/user.entity'; +import { UsersService } from 'src/users/users.service'; +import { AuthService } from './auth.service'; +import { DeepPartial } from 'typeorm'; +import { InviteStatus } from 'src/mail-history-statuses/entities/mail-history-status.entity'; + +process.env.TZ = 'UTC'; + +/** + * All tests below were based on the requirements on GitHub. + * @see {@link https://github.com/RJ-SMTR/api-cct/issues/94#issuecomment-1815016208 Requirements #94 - GitHub} + */ +describe('AuthService', () => { + let authService: AuthService; + let usersService: UsersService; + let mailService: MailService; + let mailHistoryService: MailHistoryService; + let forgotService: ForgotService; + + beforeEach(async () => { + const usersServiceMock = { + provide: UsersService, + useValue: { + create: jest.fn(), + getOne: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + softDelete: jest.fn(), + }, + } as Provider; + const forgotServiceMock = { + provide: ForgotService, + useValue: { + findOne: jest.fn(), + create: jest.fn(), + generateHash: jest.fn(), + }, + } as Provider; + const mailServiceMock = { + provide: MailService, + useValue: { + sendConcludeRegistration: jest.fn(), + sendForgotPassword: jest.fn(), + }, + } as Provider; + const coreBankServiceMock = { + provide: CoreBankService, + useValue: { + updateDataIfNeeded: jest.fn(), + }, + } as Provider; + const mailHistoryServiceMock = { + provide: MailHistoryService, + useValue: { + findOne: jest.fn(), + getOne: jest.fn(), + getRemainingQuota: jest.fn(), + update: jest.fn(), + }, + } as Provider; + const jwtServiceMock = { + provide: JwtService, + useValue: { + sign: jest.fn(), + }, + } as Provider; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + jwtServiceMock, + usersServiceMock, + forgotServiceMock, + mailServiceMock, + coreBankServiceMock, + mailHistoryServiceMock, + ], + }).compile(); + + authService = module.get(AuthService); + mailHistoryService = module.get(MailHistoryService); + forgotService = module.get(ForgotService); + mailService = module.get(MailService); + usersService = module.get(UsersService); + }); + + it('should be defined', () => { + expect(authService).toBeDefined(); + }); + + describe('resendRegisterMail', () => { + it('should throw exception when no mail quota available', async () => { + // Arrange + const user = new User({ + id: 1, + email: 'user1@example.com', + hash: 'hash_1', + }); + const mailHistory = { id: 1, user: user, hash: 'hash_1' } as MailHistory; + jest.spyOn(usersService, 'getOne').mockResolvedValue(user); + jest.spyOn(mailHistoryService, 'findOne').mockResolvedValue(mailHistory); + jest.spyOn(mailHistoryService, 'getRemainingQuota').mockResolvedValue(0); + + // Act + const response = authService.resendRegisterMail({ id: 1 }); + + // Assert + await expect(response).rejects.toThrowError(); + }); + + it('should throw exception when mail status is not QUEUED', async () => { + // Arrange + const user = new User({ + id: 1, + email: 'user1@example.com', + hash: 'hash_1', + }); + const mailHistory = new MailHistory({ + id: 1, + user: user, + hash: 'hash_1', + }); + mailHistory.setInviteStatus(InviteStatusEnum.sent); + jest.spyOn(usersService, 'getOne').mockResolvedValue(user); + jest.spyOn(mailHistoryService, 'findOne').mockResolvedValue(mailHistory); + jest.spyOn(mailHistoryService, 'getRemainingQuota').mockResolvedValue(1); + + // Act + const response = authService.resendRegisterMail({ id: 1 }); + let error: any; + await response.catch((httpException) => { + error = httpException; + }); + + // Assert + await expect(response).rejects.toThrowError(); + await expect(error?.response?.error).toContain( + "User's mailStatus is not 'queued'", + ); + }); + + it('should consume the quota when available', async () => { + // Arrange + const user = new User({ id: 1, email: 'user1@mail.com', hash: 'hash_1' }); + const mailResponse = { + mailConfirmationLink: 'link', + mailSentInfo: { + success: true, + }, + } as MailRegistrationInterface; + const mailHistory = new MailHistory({ + id: 1, + user: user, + hash: 'hash_1', + }); + mailHistory.setInviteStatus(InviteStatusEnum.queued); + const dateNow = new Date('2023-01-01T10:00:00'); + const updatedMailHistory = { + sentAt: dateNow, + inviteStatus: new InviteStatus(InviteStatusEnum.sent), + } as DeepPartial; + + jest.spyOn(usersService, 'getOne').mockResolvedValue(user); + jest.spyOn(mailHistoryService, 'findOne').mockResolvedValue(mailHistory); + jest.spyOn(mailHistoryService, 'getRemainingQuota').mockResolvedValue(1); + jest + .spyOn(mailService, 'sendConcludeRegistration') + .mockResolvedValue(mailResponse); + jest + .spyOn(global.Date, 'now') + .mockImplementation(() => dateNow.valueOf()); + + // Act + await authService.resendRegisterMail(user); + + // Assert + expect(mailHistoryService.update).toBeCalledWith( + 1, + updatedMailHistory, + expect.any(String), + ); + }); + }); + + describe('forgotPassword', () => { + it('should throw exception when no mail quota available', async () => { + // Arrange + jest.spyOn(mailHistoryService, 'getRemainingQuota').mockResolvedValue(0); + + // Act + const response = authService.forgotPassword('user@mail.com'); + + // Assert + await expect(response).rejects.toThrowError(); + }); + + it('should not consume the quota when available', async () => { + // Arrange + const user = new User({ id: 1, email: 'user1@mail.com', hash: 'hash_1' }); + const mailSentInfo = { + success: true, + } as MailSentInfo; + const mailHistory = new MailHistory({ + id: 1, + user: user, + hash: 'hash_1', + }); + mailHistory.setInviteStatus(InviteStatusEnum.queued); + const dateNow = new Date('2023-01-01T10:00:00'); + const updatedMailHistory = new MailHistory({ + ...mailHistory, + sentAt: dateNow, + }); + updatedMailHistory.setInviteStatus(InviteStatusEnum.sent); + + jest.spyOn(usersService, 'findOne').mockResolvedValue(user); + jest + .spyOn(forgotService, 'generateHash') + .mockResolvedValue('unique_hash'); + jest.spyOn(mailHistoryService, 'getRemainingQuota').mockResolvedValue(1); + jest.spyOn(mailHistoryService, 'getOne').mockResolvedValue(mailHistory); + jest + .spyOn(mailService, 'sendForgotPassword') + .mockResolvedValue(mailSentInfo); + jest + .spyOn(global.Date, 'now') + .mockImplementation(() => dateNow.valueOf()); + + // Act + await authService.forgotPassword(user.email as string); + + // Assert + expect(mailHistoryService.update).toBeCalledTimes(0); + }); + }); +}); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index d7cf1bb0..0a6c3a3d 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -318,12 +318,7 @@ export class AuthService { } } - /** - * @throws `HttpException` - */ - async resendRegisterMail(args: AuthResendEmailDto): Promise { - const user = await this.getUser(args.id); - const userMailHsitory = await this.getMailHistory(user); + async validateQuota(): Promise { const quota = await this.mailHistoryService.getRemainingQuota(); if (quota <= 0) { throw new HttpException( @@ -335,7 +330,18 @@ export class AuthService { }, HttpStatus.NOT_FOUND, ); + } else { + return quota; } + } + + /** + * @throws `HttpException` + */ + async resendRegisterMail(args: AuthResendEmailDto): Promise { + const user = await this.getUser(args.id); + const userMailHsitory = await this.getMailHistory(user); + await this.validateQuota(); await this.sendRegisterEmail(user, userMailHsitory); } @@ -361,6 +367,7 @@ export class AuthService { } async forgotPassword(email: string): Promise { + await this.validateQuota(); const user = await this.usersService.findOne({ email, }); @@ -374,16 +381,7 @@ export class AuthService { return returnMessage; } - let hash = crypto - .createHash('sha256') - .update(randomStringGenerator()) - .digest('hex'); - while (await this.mailHistoryService.findOne({ hash })) { - hash = crypto - .createHash('sha256') - .update(randomStringGenerator()) - .digest('hex'); - } + const hash = await this.forgotService.generateHash(); await this.forgotService.create({ hash, @@ -440,7 +438,8 @@ export class AuthService { { error: HttpErrorMessages.UNAUTHORIZED, details: { - hash: `notFound`, + error: 'hash not found', + hash, }, }, HttpStatus.UNAUTHORIZED, diff --git a/src/bank-statements/bank-statements.controller.spec.ts b/src/bank-statements/bank-statements.controller.spec.ts index 38761648..3bec23d9 100644 --- a/src/bank-statements/bank-statements.controller.spec.ts +++ b/src/bank-statements/bank-statements.controller.spec.ts @@ -64,8 +64,11 @@ describe('BankStatementsController', () => { jest .spyOn(bankStatementsService, 'getBankStatementsFromUser') .mockResolvedValue({ - data: bankStatements, amountSum: 30, + todaySum: 10, + ticketCount: 10, + count: bankStatements.length, + data: bankStatements, }); jest.spyOn(usersService, 'getOneFromRequest').mockResolvedValueOnce(user); @@ -76,7 +79,7 @@ describe('BankStatementsController', () => { ); // Assert - expect(result).toEqual(bankStatements); + expect(result?.data).toEqual(bankStatements); }); it('should throw an exception when user is not found', async () => { // Arrange diff --git a/src/bank-statements/bank-statements.service.spec.ts b/src/bank-statements/bank-statements.service.spec.ts index d2282017..886d7e56 100644 --- a/src/bank-statements/bank-statements.service.spec.ts +++ b/src/bank-statements/bank-statements.service.spec.ts @@ -2,40 +2,74 @@ import { Provider } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { CoreBankService } from 'src/core-bank/core-bank.service'; import { ICoreBankStatements } from 'src/core-bank/interfaces/core-bank-statements.interface'; +import { ITicketRevenuesGroup } from 'src/ticket-revenues/interfaces/ticket-revenues-group.interface'; +import { TicketRevenuesService } from 'src/ticket-revenues/ticket-revenues.service'; import { User } from 'src/users/entities/user.entity'; +import { UsersService } from 'src/users/users.service'; +import { getDateYMDString } from 'src/utils/date-utils'; +import { TimeIntervalEnum } from 'src/utils/enums/time-interval.enum'; import { BankStatementsService } from './bank-statements.service'; -import { IBankStatementsGet } from './interfaces/bank-statements-get.interface'; const allBankStatements = [ - { id: 0, cpfCnpj: 'cpfCnpj_1', date: '2023-01-27', amount: 1 }, - { id: 1, cpfCnpj: 'cpfCnpj_1', date: '2023-01-20', amount: 2 }, - { id: 2, cpfCnpj: 'cpfCnpj_1', date: '2023-01-13', amount: 3 }, - { id: 3, cpfCnpj: 'cpfCnpj_1', date: '2023-01-06', amount: 4 }, + { id: 0, cpfCnpj: 'cc_1', permitCode: 'pc_1', date: '2023-01-27', amount: 1 }, + { id: 1, cpfCnpj: 'cc_1', permitCode: 'pc_1', date: '2023-01-20', amount: 2 }, + { id: 2, cpfCnpj: 'cc_1', permitCode: 'pc_1', date: '2023-01-13', amount: 3 }, + { id: 3, cpfCnpj: 'cc_1', permitCode: 'pc_1', date: '2023-01-06', amount: 4 }, ] as Partial[] as ICoreBankStatements[]; describe('BankStatementsService', () => { + const endpoint = 'bank-statements'; + let bankStatementsService: BankStatementsService; let coreBankService: CoreBankService; + let usersService: UsersService; + let ticketRevenuesService: TicketRevenuesService; beforeEach(async () => { const coreBankServiceMock = { provide: CoreBankService, useValue: { - getBankStatementsByCpfCnpj: jest.fn(), getBankStatementsMocked: jest.fn(), - getProfileByCpfCnpj: jest.fn(), + getBankStatementsByPermitCode: jest.fn(), + isPermitCodeExists: jest.fn(), update: jest.fn().mockReturnValue(undefined), }, } as Provider; + const usersServiceMock = { + provide: UsersService, + useValue: { + create: jest.fn(), + getOne: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + softDelete: jest.fn(), + }, + } as Provider; + const ticketRevenuesServiceMock = { + provide: TicketRevenuesService, + useValue: { + getGroupedFromUser: jest.fn(), + getMeFromUser: jest.fn(), + }, + } as Provider; const module: TestingModule = await Test.createTestingModule({ - providers: [BankStatementsService, coreBankServiceMock], + providers: [ + BankStatementsService, + coreBankServiceMock, + usersServiceMock, + ticketRevenuesServiceMock, + ], }).compile(); bankStatementsService = module.get( BankStatementsService, ); coreBankService = module.get(CoreBankService); + usersService = module.get(UsersService); + ticketRevenuesService = module.get( + TicketRevenuesService, + ); }); it('should be defined', () => { @@ -44,82 +78,211 @@ describe('BankStatementsService', () => { }); describe('getBankStatementsFromUser', () => { - it('should return statements for previous days when user fetched successfully', () => { + it('should return statements for previous days when user fetched successfully', async () => { // Arrange - const args = { - timeInterval: 'last2Weeks', - userId: 1, - } as IBankStatementsGet; + // const bankStatements = allBankStatements.filter( + // (i) => i.permitCode === 'pc_1', + // ); - const expectedResult = { - amountSum: 6, - data: allBankStatements.slice(0, 3), - }; + const revenuesGroup: ITicketRevenuesGroup[] = []; + for (let day = 0; day < 14; day++) { + const date = new Date('2023-01-11'); + date.setDate(date.getDate() - day); + revenuesGroup.push({ + count: 1, + partitionDate: getDateYMDString(date), + transportTypeCounts: { + [day.toString()]: { count: 1, transactionValue: 10 }, + }, + paymentMediaTypeCounts: { + [`media_${day}`]: { count: 1, transactionValue: 10 }, + }, + transportIntegrationTypeCounts: { + [`integration_${day}`]: { count: 1, transactionValue: 10 }, + }, + transactionTypeCounts: { + [`Integração`]: { count: 1, transactionValue: 10 }, + }, + transactionValueSum: 10, + permitCode: `permitCode_1`, + directionIdCounts: { 0: { count: 1, transactionValue: 10 } }, + stopIdCounts: { + [day.toString()]: { count: 1, transactionValue: 10 }, + }, + stopLatCounts: { + [day.toString()]: { count: 1, transactionValue: 10 }, + }, + stopLonCounts: { + [day.toString()]: { count: 1, transactionValue: 10 }, + }, + aux_epochWeek: 10, + aux_groupDateTime: '2023-01', + }); + } + jest.spyOn(usersService, 'getOne').mockResolvedValue( + new User({ + id: 1, + permitCode: '123456', + email: 'user1@example.com', + hash: 'hash_1', + }), + ); + jest.spyOn(coreBankService, 'isPermitCodeExists').mockReturnValue(true); jest - .spyOn(coreBankService, 'getBankStatementsMocked') + .spyOn(coreBankService, 'getBankStatementsByPermitCode') .mockReturnValue(allBankStatements); jest .spyOn(global.Date, 'now') .mockImplementation(() => new Date('2023-01-22').valueOf()); + jest.spyOn(ticketRevenuesService, 'getMeFromUser').mockResolvedValue({ + startDate: '2023-01-06', + endDate: '2023-01-13', + amountSum: 70, + todaySum: 170, + ticketCount: 8, + count: 2, + data: revenuesGroup.slice(9, 17), + }); // Act - const result = bankStatementsService.getBankStatementsFromUser(args); + const result = await bankStatementsService.getBankStatementsFromUser( + { + timeInterval: TimeIntervalEnum.LAST_2_WEEKS, + userId: 1, + }, + endpoint, + ); // Assert - expect(result).toEqual(expectedResult); + expect(result).toEqual({ + amountSum: 70, + todaySum: 170, + count: 8, + ticketCount: 8, + data: allBankStatements.slice(0, 3), + }); }); - it('should return statements between dates when user fetched successfully', () => { + it('should return statements between dates when user fetched successfully', async () => { // Arrange + const bankStatements = allBankStatements.filter( + (i) => i.permitCode === 'pc_1', + ); + + const revenuesGroup: ITicketRevenuesGroup[] = []; + for (let day = 0; day < 14; day++) { + const date = new Date('2023-01-11'); + date.setDate(date.getDate() - day); + revenuesGroup.push({ + count: 1, + partitionDate: getDateYMDString(date), + transportTypeCounts: { + [day.toString()]: { count: 1, transactionValue: 10 }, + }, + paymentMediaTypeCounts: { + [`media_${day}`]: { count: 1, transactionValue: 10 }, + }, + transportIntegrationTypeCounts: { + [`integration_${day}`]: { count: 1, transactionValue: 10 }, + }, + transactionTypeCounts: { + [`Integração`]: { count: 1, transactionValue: 10 }, + }, + transactionValueSum: 10, + permitCode: `permitCode_1`, + directionIdCounts: { 0: { count: 1, transactionValue: 10 } }, + stopIdCounts: { + [day.toString()]: { count: 1, transactionValue: 10 }, + }, + stopLatCounts: { + [day.toString()]: { count: 1, transactionValue: 10 }, + }, + stopLonCounts: { + [day.toString()]: { count: 1, transactionValue: 10 }, + }, + aux_epochWeek: 10, + aux_groupDateTime: '2023-01', + }); + } + const user = { - cpfCnpj: allBankStatements[0].cpfCnpj, + id: 1, + permitCode: '123456', + cpfCnpj: bankStatements[0].cpfCnpj, } as User; - const args = { - startDate: '2023-01-06', - endDate: '2023-01-13', - userId: 1, - } as IBankStatementsGet; - - const bankStatementsByCpf = allBankStatements.filter( - (i) => i.cpfCnpj === user.cpfCnpj, - ); - const expectedResult = { - amountSum: 7, - data: bankStatementsByCpf.slice(2, 4), - }; + jest.spyOn(usersService, 'getOne').mockResolvedValue(user); + jest.spyOn(coreBankService, 'isPermitCodeExists').mockReturnValue(true); jest - .spyOn(coreBankService, 'getBankStatementsMocked') - .mockReturnValueOnce(bankStatementsByCpf); + .spyOn(coreBankService, 'getBankStatementsByPermitCode') + .mockReturnValueOnce(bankStatements); jest .spyOn(global.Date, 'now') .mockImplementation(() => new Date('2023-01-22').valueOf()); + jest.spyOn(ticketRevenuesService, 'getMeFromUser').mockResolvedValue({ + startDate: '2022-12-29', + endDate: '2023-01-11', + amountSum: 140, + todaySum: 10, + ticketCount: 14, + count: 14, + data: revenuesGroup, + }); // Act - const result = bankStatementsService.getBankStatementsFromUser(args); + const result = await bankStatementsService.getBankStatementsFromUser( + { + userId: 1, + startDate: '2022-01-06', + endDate: '2023-01-13', + }, + endpoint, + ); // Assert - expect(result).toEqual(expectedResult); + expect(result).toEqual({ + amountSum: 140, + todaySum: 10, + count: 2, + ticketCount: 14, + data: [ + { + id: 2, + cpfCnpj: 'cc_1', + permitCode: 'pc_1', + date: '2023-01-13', + amount: 70, + }, + { + id: 3, + cpfCnpj: 'cc_1', + permitCode: 'pc_1', + date: '2023-01-06', + amount: 70, + }, + ], + }); }); - it('should throw exception when profile is not found', () => { + it('should throw exception when profile is not found', async () => { // Arrange - const user = { - cpfCnpj: 'inexistent-cpf', - } as User; - const args = { - previousDays: 14, - } as IBankStatementsGet; - + jest.spyOn(usersService, 'getOne').mockRejectedValue(new Error()); + jest.spyOn(coreBankService, 'isPermitCodeExists').mockReturnValue(false); jest - .spyOn(coreBankService, 'getBankStatementsByCpfCnpj') - .mockReturnValueOnce([]); + .spyOn(coreBankService, 'getBankStatementsMocked') + .mockReturnValue(allBankStatements); // Assert - expect(() => - bankStatementsService.getBankStatementsFromUser(user, args), - ).toThrowError(); + await expect( + bankStatementsService.getBankStatementsFromUser( + { + userId: 0, + timeInterval: TimeIntervalEnum.LAST_WEEK, + }, + endpoint, + ), + ).rejects.toThrowError(); }); }); }); diff --git a/src/bank-statements/bank-statements.service.ts b/src/bank-statements/bank-statements.service.ts index a838f9bd..d5dac460 100644 --- a/src/bank-statements/bank-statements.service.ts +++ b/src/bank-statements/bank-statements.service.ts @@ -33,13 +33,14 @@ export class BankStatementsService { } // For now it validates if user exists const user = await this.usersService.getOne({ id: args?.userId }); - if (!user.permitCode) { + if (!user.permitCode || !user.id) { throw new HttpException( { error: { message: 'User not found', user: { - permitCode: 'fieldIsEmpty', + ...(!user.permitCode ? { permitCode: 'fieldIsEmpty' } : {}), + ...(!user.id ? { id: 'fieldIsEmpty' } : {}), }, }, }, @@ -47,7 +48,6 @@ export class BankStatementsService { ); } - // TODO: fetch instead of mockup let bankStatementsResponse: ICoreBankStatements[] = []; if (this.coreBankService.isPermitCodeExists(user.permitCode)) { bankStatementsResponse = @@ -91,8 +91,6 @@ export class BankStatementsService { }; } - //#region mockData - private async insertTicketData( statements: ICoreBankStatements[], args: IBankStatementsGet, @@ -151,6 +149,4 @@ export class BankStatementsService { const countSum = revenuesResponse.ticketCount; return { todaySum, allSum, countSum, statements: newStatements }; } - - //#endregion mockData } diff --git a/src/cron-jobs/cron-jobs.service.spec.ts b/src/cron-jobs/cron-jobs.service.spec.ts index 2c065020..72778f88 100644 --- a/src/cron-jobs/cron-jobs.service.spec.ts +++ b/src/cron-jobs/cron-jobs.service.spec.ts @@ -1,18 +1,195 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CronJobsService } from './cron-jobs.service'; +import { ConfigService } from '@nestjs/config'; +import { Provider } from '@nestjs/common'; +import { MailHistoryService } from 'src/mail-history/mail-history.service'; +import { UsersService } from 'src/users/users.service'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { JaeService } from 'src/jae/jae.service'; +import { CoreBankService } from 'src/core-bank/core-bank.service'; +import { MailService } from 'src/mail/mail.service'; +import { SettingsService } from 'src/settings/settings.service'; +import { SettingEntity } from 'src/settings/entities/setting.entity'; +import { MailHistory } from 'src/mail-history/entities/mail-history.entity'; +import { User } from 'src/users/entities/user.entity'; +import { Role } from 'src/roles/entities/role.entity'; +import { InviteStatus } from 'src/mail-history-statuses/entities/mail-history-status.entity'; +import { InviteStatusEnum } from 'src/mail-history-statuses/mail-history-status.enum'; +import { RoleEnum } from 'src/roles/roles.enum'; +import { MailRegistrationInterface } from 'src/mail/interfaces/mail-registration.interface'; +import { DeepPartial } from 'typeorm'; +/** + * All tests below were based on the requirements on GitHub. + * @see {@link https://github.com/RJ-SMTR/api-cct/issues/94#issuecomment-1815016208 Requirements #94 - GitHub} + */ describe('CronJobsService', () => { - let service: CronJobsService; + let cronJobsService: CronJobsService; + let settingsService: SettingsService; + let mailHistoryService: MailHistoryService; + let mailService: MailService; + let usersService: UsersService; beforeEach(async () => { + const configServiceMock = { + provide: ConfigService, + useValue: { + getOrThrow: jest.fn(), + }, + } as Provider; + const settingsServiceMock = { + provide: SettingsService, + useValue: { + getOneBySettingData: jest.fn(), + findOneBySettingData: jest.fn(), + }, + } as Provider; + const mailHistoryServiceMock = { + provide: MailHistoryService, + useValue: { + findSentToday: jest.fn(), + findUnsent: jest.fn(), + getRemainingQuota: jest.fn(), + update: jest.fn(), + }, + } as Provider; + const mailServiceMock = { + provide: MailService, + useValue: { + sendConcludeRegistration: jest.fn(), + }, + } as Provider; + const usersServiceMock = { + provide: UsersService, + useValue: { + findOne: jest.fn(), + }, + } as Provider; + const schedulerRegistryMock = { + provide: SchedulerRegistry, + useValue: { + addCronJob: jest.fn(), + }, + } as Provider; + const jaeServiceMock = { + provide: JaeService, + useValue: { + updateDataIfNeeded: jest.fn(), + }, + } as Provider; + const coreBankServiceMock = { + provide: CoreBankService, + useValue: { + updateDataIfNeeded: jest.fn(), + }, + } as Provider; + const module: TestingModule = await Test.createTestingModule({ - providers: [CronJobsService], + providers: [ + CronJobsService, + configServiceMock, + settingsServiceMock, + mailHistoryServiceMock, + mailServiceMock, + usersServiceMock, + schedulerRegistryMock, + jaeServiceMock, + coreBankServiceMock, + ], }).compile(); - service = module.get(CronJobsService); + cronJobsService = module.get(CronJobsService); + settingsService = module.get(SettingsService); + mailHistoryService = module.get(MailHistoryService); + mailService = module.get(MailService); + usersService = module.get(UsersService); }); it('should be defined', () => { - expect(service).toBeDefined(); + expect(cronJobsService).toBeDefined(); + }); + + describe('bulkSendInvites', () => { + it('should abort if no mail quota available', async () => { + // Arrange + jest + .spyOn(settingsService, 'findOneBySettingData') + .mockResolvedValue({ value: 'true' } as SettingEntity); + jest.spyOn(mailHistoryService, 'findSentToday').mockResolvedValue([]); + jest + .spyOn(mailHistoryService, 'findUnsent') + .mockResolvedValue([{ id: 1 }, { id: 2 }] as MailHistory[]); + jest.spyOn(mailHistoryService, 'getRemainingQuota').mockResolvedValue(0); + jest + .spyOn(global.Date, 'now') + .mockImplementation(() => new Date('2023-01-01').valueOf()); + + // Act + await cronJobsService.bulkSendInvites(); + + // Assert + expect(usersService.findOne).toBeCalledTimes(0); + expect(mailService.sendConcludeRegistration).toBeCalledTimes(0); + }); + + it('should set mail status to SENT when succeeded', async () => { + // Arrange + const dateNow = new Date('2023-01-01T10:00:00'); + const user = new User({ + id: 1, + email: 'user1@example.com', + hash: 'hash_1', + permitCode: 'permitCode1', + role: new Role(RoleEnum.user), + }); + const mailHistory = { + id: 1, + user: user, + hash: 'hash_1', + inviteStatus: new InviteStatus(InviteStatusEnum.sent), + sentAt: dateNow, + } as MailHistory; + const updatedMailHistory = { + failedAt: null, + sentAt: dateNow, + httpErrorCode: null, + smtpErrorCode: null, + inviteStatus: new InviteStatus(InviteStatusEnum.sent), + } as DeepPartial; + const mailResponse = { + mailConfirmationLink: 'link', + mailSentInfo: { + success: true, + }, + } as MailRegistrationInterface; + + jest + .spyOn(settingsService, 'findOneBySettingData') + .mockResolvedValue({ value: 'true' } as SettingEntity); + jest.spyOn(mailHistoryService, 'findSentToday').mockResolvedValue([]); + jest + .spyOn(mailHistoryService, 'findUnsent') + .mockResolvedValue([mailHistory] as MailHistory[]); + jest.spyOn(mailHistoryService, 'getRemainingQuota').mockResolvedValue(1); + jest.spyOn(usersService, 'findOne').mockResolvedValue(user); + jest + .spyOn(mailService, 'sendConcludeRegistration') + .mockResolvedValue(mailResponse); + jest + .spyOn(global.Date, 'now') + .mockImplementation(() => dateNow.valueOf()); + + // Act + await cronJobsService.bulkSendInvites(); + + // Assert + expect(usersService.findOne).toBeCalled(); + expect(mailService.sendConcludeRegistration).toBeCalled(); + expect(mailHistoryService.update).toBeCalledWith( + 1, + updatedMailHistory, + expect.any(String), + ); + }); }); }); diff --git a/src/database/migrations/1702556018633-AddExtension.ts b/src/database/migrations/1702556018633-AddExtension.ts new file mode 100644 index 00000000..fbd64678 --- /dev/null +++ b/src/database/migrations/1702556018633-AddExtension.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddExtension1702556018633 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('CREATE EXTENSION IF NOT EXISTS unaccent;'); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP EXTENSION IF EXISTS unaccent;'); + } +} diff --git a/src/database/seeds/mail-history/mail-history-seed-data.service.ts b/src/database/seeds/mail-history/mail-history-seed-data.service.ts index a9ff37d9..9bf47593 100644 --- a/src/database/seeds/mail-history/mail-history-seed-data.service.ts +++ b/src/database/seeds/mail-history/mail-history-seed-data.service.ts @@ -19,7 +19,7 @@ export class MailHistorySeedDataService { ...(i.id ? { id: i.id } : {}), ...(i.email ? { email: i.email } : {}), }, - inviteStatus: { id: InviteStatusEnum.used } as InviteStatus, + inviteStatus: new InviteStatus(InviteStatusEnum.used), } as IMailSeedData), ); return mailSeedData; diff --git a/src/database/seeds/mail-history/mail-history-seed.service.ts b/src/database/seeds/mail-history/mail-history-seed.service.ts index 592c5c15..975a8b97 100644 --- a/src/database/seeds/mail-history/mail-history-seed.service.ts +++ b/src/database/seeds/mail-history/mail-history-seed.service.ts @@ -7,6 +7,7 @@ import { IMailSeedData } from 'src/mail-history/interfaces/mail-history-data.int import { User } from 'src/users/entities/user.entity'; import { Repository } from 'typeorm'; import { MailHistorySeedDataService } from './mail-history-seed-data.service'; +import { UserSeedDataService } from '../user/user-seed-data.service'; @Injectable() export class MailHistorySeedService { @@ -17,7 +18,8 @@ export class MailHistorySeedService { private mailHistoryRepository: Repository, @InjectRepository(User) private usersRepository: Repository, - private dataService: MailHistorySeedDataService, + private mhSeedDataService: MailHistorySeedDataService, + private userSeedDataService: UserSeedDataService, ) {} async run() { @@ -26,22 +28,30 @@ export class MailHistorySeedService { return; } this.logger.log('run()'); - for (const item of this.dataService.getDataFromConfig()) { + for (const item of this.mhSeedDataService.getDataFromConfig()) { const itemUser = await this.getHistoryUser(item); - item.user = itemUser; + const itemSeedUser = this.userSeedDataService + .getDataFromConfig() + .find((i) => i.email === itemUser.email); const foundItem = await this.mailHistoryRepository.findOne({ where: { - user: { id: itemUser.id }, + user: { email: itemUser.email as string }, }, }); if (!foundItem) { - item.email = item.user.email as string; - item.hash = await this.generateInviteHash(); + const newItem = { ...item }; + newItem.user = itemUser; + newItem.email = itemUser.email as string; + newItem.hash = await this.generateInviteHash(); + if (itemSeedUser?.inviteStatus) { + newItem.inviteStatus = itemSeedUser.inviteStatus; + } + this.logger.log(`Creating user: ${JSON.stringify(newItem)}`); await this.mailHistoryRepository.save( - this.mailHistoryRepository.create(item), + this.mailHistoryRepository.create(newItem), ); - itemUser.hash = item.hash; + itemUser.hash = newItem.hash; await this.usersRepository.save(this.usersRepository.create(itemUser)); } } diff --git a/src/database/seeds/user/user-seed-data.service.ts b/src/database/seeds/user/user-seed-data.service.ts index 01cf29a1..202cb1dd 100644 --- a/src/database/seeds/user/user-seed-data.service.ts +++ b/src/database/seeds/user/user-seed-data.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { InviteStatus } from 'src/mail-history-statuses/entities/mail-history-status.entity'; +import { InviteStatusEnum } from 'src/mail-history-statuses/mail-history-status.enum'; import { Role } from 'src/roles/entities/role.entity'; import { RoleEnum } from 'src/roles/roles.enum'; import { Status } from 'src/statuses/entities/status.entity'; @@ -8,29 +10,15 @@ import { UserDataInterface } from 'src/users/interfaces/user-data.interface'; @Injectable() export class UserSeedDataService { - private generateRandomPassword(): string { - const length = 10; - const charset = - 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - let password = ''; - - for (let i = 0; i < length; i++) { - const randomIndex = Math.floor(Math.random() * charset.length); - password += charset.charAt(randomIndex); - } - - return password; - } - constructor(private configService: ConfigService) {} getDataFromConfig(): UserDataInterface[] { const nodeEnv = () => this.configService.getOrThrow('app.nodeEnv', { infer: true }); return [ - // // Test + // Test ...(nodeEnv() !== 'production' - ? [ + ? ([ { id: 2, fullName: 'Henrique Santos Template', @@ -49,7 +37,7 @@ export class UserSeedDataService { role: { id: RoleEnum.user } as Role, status: { id: StatusEnum.active } as Status, }, - ] + ] as UserDataInterface[]) : []), // Dev team @@ -127,7 +115,7 @@ export class UserSeedDataService { }, ...(nodeEnv() === 'local' || nodeEnv() === 'test' - ? [ + ? ([ { id: 1, fullName: 'Administrador', @@ -137,8 +125,57 @@ export class UserSeedDataService { role: { id: RoleEnum.admin } as Role, status: { id: StatusEnum.active } as Status, }, - ] + { + fullName: 'Administrador Teste', + email: 'admin.test@example.com', + password: 'secret', + permitCode: '', + role: { id: RoleEnum.admin } as Role, + status: { id: StatusEnum.active } as Status, + }, + { + fullName: 'Queued user', + email: 'queued.user@example.com', + password: 'secret', + permitCode: '319274392832024', + role: { id: RoleEnum.user } as Role, + status: { id: StatusEnum.active } as Status, + inviteStatus: new InviteStatus(InviteStatusEnum.queued), + }, + { + fullName: 'Sent user', + email: 'sent.user@example.com', + password: 'secret', + permitCode: '319274392832024', + role: { id: RoleEnum.user } as Role, + status: { id: StatusEnum.active } as Status, + inviteStatus: new InviteStatus(InviteStatusEnum.sent), + }, + { + fullName: 'Used user', + email: 'used.user@example.com', + password: 'secret', + permitCode: '319274392832024', + role: { id: RoleEnum.user } as Role, + status: { id: StatusEnum.active } as Status, + inviteStatus: new InviteStatus(InviteStatusEnum.used), + }, + ] as UserDataInterface[]) : []), ]; } + + private generateRandomPassword(): string { + const length = 10; + const charset = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let password = ''; + + for (let i = 0; i < length; i++) { + const randomIndex = Math.floor(Math.random() * charset.length); + password += charset.charAt(randomIndex); + } + + return password; + } } diff --git a/src/database/seeds/user/user-seed.service.ts b/src/database/seeds/user/user-seed.service.ts index 77898e40..c102f71f 100644 --- a/src/database/seeds/user/user-seed.service.ts +++ b/src/database/seeds/user/user-seed.service.ts @@ -13,8 +13,8 @@ export class UserSeedService { constructor( @InjectRepository(User) - private usersRepository: Repository, - private dataService: UserSeedDataService, + private userSeedRepository: Repository, + private userSeedDataService: UserSeedDataService, ) {} async run() { @@ -23,10 +23,10 @@ export class UserSeedService { return; } this.logger.log( - `run() ${this.dataService.getDataFromConfig().length} items`, + `run() ${this.userSeedDataService.getDataFromConfig().length} items`, ); - for (const item of this.dataService.getDataFromConfig()) { - const foundItem = await this.usersRepository.findOne({ + for (const item of this.userSeedDataService.getDataFromConfig()) { + const foundItem = await this.userSeedRepository.findOne({ where: { email: item.email, }, @@ -36,15 +36,17 @@ export class UserSeedService { if (foundItem) { const newItem = new User(foundItem); newItem.update(item); - await this.usersRepository.save(this.usersRepository.create(newItem)); - createdItem = (await this.usersRepository.findOne({ + await this.userSeedRepository.save( + this.userSeedRepository.create(newItem), + ); + createdItem = (await this.userSeedRepository.findOne({ where: { email: newItem.email as string, }, })) as User; } else { - createdItem = await this.usersRepository.save( - this.usersRepository.create(item), + createdItem = await this.userSeedRepository.save( + this.userSeedRepository.create(item), ); } item.hash = await this.generateHash(); @@ -79,7 +81,7 @@ export class UserSeedService { .createHash('sha256') .update(randomStringGenerator()) .digest('hex'); - while (await this.usersRepository.findOne({ where: { hash } })) { + while (await this.userSeedRepository.findOne({ where: { hash } })) { hash = crypto .createHash('sha256') .update(randomStringGenerator()) @@ -89,6 +91,6 @@ export class UserSeedService { } async validateRun() { - return global.force || (await this.usersRepository.count()) === 0; + return global.force || (await this.userSeedRepository.count()) === 0; } } diff --git a/src/forgot/forgot.service.ts b/src/forgot/forgot.service.ts index 8379de49..60dcc606 100644 --- a/src/forgot/forgot.service.ts +++ b/src/forgot/forgot.service.ts @@ -1,3 +1,5 @@ +import * as crypto from 'crypto'; +import { randomStringGenerator } from '@nestjs/common/utils/random-string-generator.util'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { FindOptions } from 'src/utils/types/find-options.type'; @@ -31,4 +33,18 @@ export class ForgotService { async softDelete(id: number): Promise { await this.forgotRepository.softDelete(id); } + + async generateHash(): Promise { + let hash = crypto + .createHash('sha256') + .update(randomStringGenerator()) + .digest('hex'); + while (await this.findOne({ where: { hash } })) { + hash = crypto + .createHash('sha256') + .update(randomStringGenerator()) + .digest('hex'); + } + return hash; + } } diff --git a/src/mail-history/entities/mail-history.entity.ts b/src/mail-history/entities/mail-history.entity.ts index 222dccf1..15afe133 100644 --- a/src/mail-history/entities/mail-history.entity.ts +++ b/src/mail-history/entities/mail-history.entity.ts @@ -16,7 +16,7 @@ import { @Entity('invite') export class MailHistory extends BaseEntity { - constructor(mailHistory?: MailHistory) { + constructor(mailHistory?: Partial) { super(); if (mailHistory !== undefined) { Object.assign(this, mailHistory); @@ -82,7 +82,7 @@ export class MailHistory extends BaseEntity { } /** - * Sets errors and updates `failedAt` + * Set errors and updates `failedAt` */ public setInviteError(args: { smtpErrorCode?: number | null; diff --git a/src/mail-history/mail-history.service.spec.ts b/src/mail-history/mail-history.service.spec.ts index 12e81e91..9df3e289 100644 --- a/src/mail-history/mail-history.service.spec.ts +++ b/src/mail-history/mail-history.service.spec.ts @@ -1,18 +1,108 @@ +import { Provider } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { MailHistory } from './entities/mail-history.entity'; import { MailHistoryService } from './mail-history.service'; describe('InviteService', () => { - let service: MailHistoryService; + let mailHistoryService: MailHistoryService; + let configService: ConfigService; + let mailHistoryRepository: Repository; beforeEach(async () => { + const MAIL_HSITORY_REPOSITORY_TOKEN = getRepositoryToken(MailHistory); + const mailHistoryRepositoryMock = { + provide: MAIL_HSITORY_REPOSITORY_TOKEN, + useValue: { + find: jest.fn(), + createQueryBuilder: jest.fn(), + }, + } as Provider; + const configServiceMock = { + provide: ConfigService, + useValue: { + getOrThrow: jest.fn(), + }, + } as Provider; + const dataSourceMock = { + provide: DataSource, + useValue: { + query: jest.fn(), + }, + } as Provider; const module: TestingModule = await Test.createTestingModule({ - providers: [MailHistoryService], + providers: [ + MailHistoryService, + mailHistoryRepositoryMock, + configServiceMock, + dataSourceMock, + ], }).compile(); - service = module.get(MailHistoryService); + mailHistoryService = module.get(MailHistoryService); + mailHistoryRepository = module.get>( + MAIL_HSITORY_REPOSITORY_TOKEN, + ); + configService = module.get(ConfigService); }); it('should be defined', () => { - expect(service).toBeDefined(); + expect(mailHistoryService).toBeDefined(); + }); + + /** + * @see {@link https://github.com/RJ-SMTR/api-cct/issues/94#issuecomment-1815016208 Requirements #94 - GitHub} + */ + describe('getUpdatedMailCounts', () => { + it('should return quota as max value after midnight', async () => { + // Arrange + const findResult: Partial[] = [ + { + email: 'user1@mail.com', + sentAt: new Date('2023-06-30T06:10:00.000Z'), + }, + { + email: 'user2@mail.com', + sentAt: new Date('2023-06-30T22:00:00.000Z'), + }, + { + email: 'user3@mail.com', + sentAt: new Date('2023-06-30T23:00:00.000Z'), + }, + ]; + jest + .spyOn(mailHistoryRepository, 'find') + .mockResolvedValue(findResult as MailHistory[]); + jest.spyOn(configService, 'getOrThrow').mockReturnValue(3); + function mockDate(dateStr: string) { + const date = new Date(dateStr); + const dateStart = new Date(dateStr.slice(0, 10)); + jest.spyOn(global.Date, 'now').mockImplementation(() => date.valueOf()); + const sentToday = findResult.filter( + (i) => i.sentAt && new Date(i.sentAt) >= dateStart, + ).length; + const createQueryBuilder: any = { + select: () => createQueryBuilder, + where: () => createQueryBuilder, + orderBy: () => createQueryBuilder, + getCount: () => sentToday, + }; + jest + .spyOn(mailHistoryRepository, 'createQueryBuilder') + .mockImplementation(() => createQueryBuilder); + } + + // Act + mockDate('2023-06-30T06:10:00.000Z'); + const result_unchanged = await mailHistoryService.getRemainingQuota(); + mockDate('2023-07-01T00:00:00.000Z'); + const result_07_01 = await mailHistoryService.getRemainingQuota(); + + // Assert + expect(result_unchanged).toBe(0); + expect(result_07_01).toBe(3); + }, 35000); }); }); diff --git a/src/mail/mail.service.ts b/src/mail/mail.service.ts index 98972f1f..c6b96911 100644 --- a/src/mail/mail.service.ts +++ b/src/mail/mail.service.ts @@ -127,7 +127,7 @@ export class MailService { async sendForgotPassword( mailData: MailData<{ hash: string }>, ): Promise { - const resetPasswordTitle = 'Refedinir senha'; + const mailTitle = 'Redefinir senha'; try { const frontendDomain = this.configService.get('app.frontendDomain', { @@ -135,17 +135,17 @@ export class MailService { }); const response = await this.safeSendMail({ to: mailData.to, - subject: resetPasswordTitle, + subject: mailTitle, text: `${this.configService.get('app.frontendDomain', { infer: true, - })}reset-password/${mailData.data.hash} ${resetPasswordTitle}`, + })}reset-password/${mailData.data.hash} ${mailTitle}`, template: 'reset-password', context: { - title: resetPasswordTitle, + title: mailTitle, url: `${this.configService.get('app.frontendDomain', { infer: true, })}reset-password/${mailData.data.hash}`, - actionTitle: resetPasswordTitle, + actionTitle: mailTitle, logoSrc: `${frontendDomain}/assets/icons/logoPrefeitura.png`, logoAlt: 'Prefeitura do Rio', bodyText: 'Redefina sua senha clicando no botão abaixo!', @@ -168,7 +168,7 @@ export class MailService { statusCount: IMailHistoryStatusCount; }>, ): Promise { - const resetPasswordTitle = 'Relatório diário'; + const mailTitle = 'Relatório diário'; const from = this.configService.get('mail.senderNotification', { infer: true, }); @@ -192,16 +192,11 @@ export class MailService { const response = await this.safeSendMail({ from, to: mailData.to, - subject: resetPasswordTitle, - text: `${this.configService.get('app.frontendDomain', { - infer: true, - })}reset-password/${'mailData.data.hash'} ${resetPasswordTitle}`, + subject: mailTitle, + text: mailTitle, template: 'report', context: { - title: resetPasswordTitle, - url: `${this.configService.get('app.frontendDomain', { - infer: true, - })}reset-password/${'mailData.data.hash'}`, + title: mailTitle, headerTitle: appName, mailQueued: mailData.data.statusCount.queued, mailSent: mailData.data.statusCount.sent, diff --git a/src/users/interfaces/user-data.interface.ts b/src/users/interfaces/user-data.interface.ts index 1d5b83f2..a3b3ce0d 100644 --- a/src/users/interfaces/user-data.interface.ts +++ b/src/users/interfaces/user-data.interface.ts @@ -1,3 +1,4 @@ +import { InviteStatus } from 'src/mail-history-statuses/entities/mail-history-status.entity'; import { Role } from 'src/roles/entities/role.entity'; import { Status } from 'src/statuses/entities/status.entity'; @@ -10,4 +11,5 @@ export interface UserDataInterface { password?: string; role: Role; status: Status; + inviteStatus?: InviteStatus; } diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index a80cb1b1..9241ec52 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -1,18 +1,26 @@ +import { Provider } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { UsersService } from './users.service'; -import { User } from './entities/user.entity'; -import { CreateUserDto } from './dto/create-user.dto'; -import { Provider } from '@nestjs/common'; -import { Repository } from 'typeorm'; -import { IFileUser } from './interfaces/file-user.interface'; -import { CreateFileUserDto } from './dto/create-user-file.dto'; import * as CLASS_VALIDATOR from 'class-validator'; +import { BanksService } from 'src/banks/banks.service'; +import { MailHistoryService } from 'src/mail-history/mail-history.service'; +import { Repository } from 'typeorm'; import * as XLSX from 'xlsx'; +import { CreateUserFileDto } from './dto/create-user-file.dto'; +import { CreateUserDto } from './dto/create-user.dto'; +import { User } from './entities/user.entity'; +import { ICreateUserFile } from './interfaces/create-user-file.interface'; +import { IFileUser } from './interfaces/file-user.interface'; +import { UsersService } from './users.service'; +import { MailHistory } from 'src/mail-history/entities/mail-history.entity'; +import { InviteStatus } from 'src/mail-history-statuses/entities/mail-history-status.entity'; +import { InviteStatusEnum } from 'src/mail-history-statuses/mail-history-status.enum'; describe('UsersService', () => { let usersService: UsersService; let usersRepository: Repository; + let banksService: BanksService; + let mailHistoryService: MailHistoryService; const USER_REPOSITORY_TOKEN = getRepositoryToken(User); const usersRepositoryMock = { provide: USER_REPOSITORY_TOKEN, @@ -21,16 +29,43 @@ describe('UsersService', () => { save: jest.fn(), find: jest.fn(), findOne: jest.fn(), + getOne: jest.fn(), + }, + } as Provider; + const mailHistoryServiceMock = { + provide: MailHistoryService, + useValue: { + find: jest.fn(), + findRecentByUser: jest.fn(), + getOne: jest.fn(), + generateHash: jest.fn(), + update: jest.fn(), + create: jest.fn(), + }, + } as Provider; + const banksServiceMock = { + provide: BanksService, + useValue: { + findOne: jest.fn(), }, } as Provider; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [UsersService, usersRepositoryMock], + providers: [ + UsersService, + usersRepositoryMock, + mailHistoryServiceMock, + banksServiceMock, + ], }).compile(); usersService = module.get(UsersService); + banksService = module.get(BanksService); usersRepository = module.get>(USER_REPOSITORY_TOKEN); + mailHistoryService = module.get(MailHistoryService); + + jest.spyOn(banksService, 'findOne').mockResolvedValue(null); }); it('should be defined', () => { @@ -45,6 +80,7 @@ describe('UsersService', () => { } as CreateUserDto; const user = { email: createUserDto.email, + getLogInfo: () => 'log info', } as User; jest.spyOn(usersRepository, 'save').mockResolvedValue(user); @@ -58,9 +94,33 @@ describe('UsersService', () => { }); }); + describe('update', () => { + it('should throw error when new email already exists', async () => { + // Arrange + const oldUser = { + id: 1, + email: 'old@email.com', + } as User; + const existingUser = { + id: 2, + email: 'existing@email.com', + } as User; + jest.spyOn(usersService, 'getOne').mockResolvedValue(oldUser); + jest.spyOn(usersRepository, 'findOne').mockResolvedValue(existingUser); + + // Act + const result = usersService.update(1, existingUser); + + // Assert + await expect(result).rejects.toThrowError(); + expect(usersService.getOne).toBeCalled(); + }); + }); + describe('createFromFile', () => { it('should create users from a valid file', async () => { // Arrange + const user = new User({ email: 'email_1@example.com' }); const fileMock = { buffer: {}, } as Express.Multer.File; @@ -75,23 +135,32 @@ describe('UsersService', () => { errors: {}, } as IFileUser); } + const createdMailHistory = new MailHistory({ + email: `email@example.com`, + inviteStatus: new InviteStatus(InviteStatusEnum.queued), + }); jest - .spyOn(usersService, 'getExcelUsersFromWorksheet') + .spyOn(usersService, 'getUserFilesFromWorksheet') .mockResolvedValue(expectedFileUsers); + jest.spyOn(usersRepository, 'create').mockReturnValue(user); + jest.spyOn(usersRepository, 'save').mockResolvedValue(user); + jest + .spyOn(mailHistoryService, 'create') + .mockResolvedValue(createdMailHistory); // Act - await usersService.createFromFile(fileMock); + const response = await usersService.createFromFile(fileMock); // Assert - expect(usersService.getUserFilesFromWorksheet).toHaveBeenCalledWith( - expect.any(Object), - expect.any(Array), - expect.any(Function), - ); expect(usersRepository.save).toBeCalledTimes(expectedFileUsers.length); + expect(mailHistoryService.create).toBeCalledTimes( + expectedFileUsers.length, + ); + expect(response.invalidUsers).toEqual(0); + expect(response.uploadedUsers).toEqual(3); }); - it('should throw error if file content has errors', async () => { + it('should throw error if field value has errors', async () => { // Arrange const fileMock = { buffer: {}, @@ -102,7 +171,7 @@ describe('UsersService', () => { expectedFileUsers.push({ row: i + 2, user: { - email: `invalidEmail_${i} example.com`, + email: `invalidEmail_${i}#example.com`, }, errors: { email: 'invalid', @@ -110,7 +179,7 @@ describe('UsersService', () => { } as IFileUser); } jest - .spyOn(usersService, 'getExcelUsersFromWorksheet') + .spyOn(usersService, 'getUserFilesFromWorksheet') .mockResolvedValue(expectedFileUsers); // Assert @@ -120,25 +189,25 @@ describe('UsersService', () => { }); }); - describe('getExcelUsersFromWorksheet', () => { + describe('getUserFilesFromWorksheet', () => { it('should extract users from a valid worksheet', async () => { // Arrange - const worksheetMock = {}; - const expectedUserFields = ['permitCode', 'email']; - jest.spyOn(XLSX.utils, 'sheet_to_json').mockReturnValue([ - { - codigo_permissionario: 'permitCode1', - email: 'test@example.com', - }, - ]); - jest.spyOn(CLASS_VALIDATOR, 'validate').mockReturnValue([]); + const fileUser = { + codigo_permissionario: 'permitCode1', + email: 'test@example.com', + cpf: '59777618212', + nome: 'Henrique Santos Template', + telefone: '21912345678', + } as Partial; + jest.spyOn(XLSX.utils, 'sheet_to_json').mockReturnValue([fileUser]); + jest.spyOn(CLASS_VALIDATOR, 'validate').mockResolvedValue([]); + jest.spyOn(usersRepository, 'find').mockResolvedValue([]); + + const worksheetMock = XLSX.utils.json_to_sheet([fileUser]); const expectedResult = [ { row: 2, - user: { - permitCode: 'permitCode1', - email: 'test@example.com', - }, + user: fileUser, errors: {}, }, ] as IFileUser[]; @@ -146,13 +215,90 @@ describe('UsersService', () => { // Act const result = await usersService.getUserFilesFromWorksheet( worksheetMock, - expectedUserFields, - CreateFileUserDto, + CreateUserFileDto, ); // Assert expect(result).toEqual(expectedResult); }); + + it('should throw error invalid headers', async () => { + // Arrange + function testHeader(fileUser): Promise { + jest.spyOn(XLSX.utils, 'sheet_to_json').mockReturnValue([fileUser]); + jest.spyOn(CLASS_VALIDATOR, 'validate').mockResolvedValue([]); + jest.spyOn(usersRepository, 'find').mockResolvedValue([]); + return usersService.getUserFilesFromWorksheet( + XLSX.utils.json_to_sheet([fileUser]), + CreateUserFileDto, + ); + } + + // Act + const resultLessHeaders = testHeader({ + codigo_permissionario: 'permitCode1', + email: 'test@example.com', + cpf: '59777618212', + nome: 'Henrique Santos Template', + }); + const resultMoreHeaders = testHeader({ + codigo_permissionario: 'permitCode1', + email: 'test@example.com', + cpf: '59777618212', + nome: 'Henrique Santos Template', + telefone: '21912345678', + telefone2: '21912345678', + }); + + // Assert + await expect(resultLessHeaders).rejects.toThrowError(); + await expect(resultMoreHeaders).rejects.toThrowError(); + }); + + it('should extract users when valid content even if headers are unsorted', async () => { + // Arrange + async function testFile(fileUser: any): Promise<{ + result: IFileUser[]; + expectedResult: any; + }> { + jest.spyOn(XLSX.utils, 'sheet_to_json').mockReturnValue([fileUser]); + jest.spyOn(CLASS_VALIDATOR, 'validate').mockResolvedValue([]); + jest.spyOn(usersRepository, 'find').mockResolvedValue([]); + const worksheetMock = XLSX.utils.json_to_sheet([fileUser]); + const expectedResult = [ + { + row: 2, + user: fileUser, + errors: {}, + }, + ] as IFileUser[]; + const result = await usersService.getUserFilesFromWorksheet( + worksheetMock, + CreateUserFileDto, + ); + return { result, expectedResult }; + } + + // Act + const resultSorted = await testFile({ + codigo_permissionario: 'permitCode1', + email: 'test@example.com', + telefone: '21912345678', + nome: 'Henrique Santos Template', + cpf: '59777618212', + }); + const resultUnsorted = await testFile({ + email: 'test@example.com', + codigo_permissionario: 'permitCode1', + cpf: '59777618212', + nome: 'Henrique Santos Template', + telefone: '21912345678', + }); + + // Assert + expect(resultSorted.result).toEqual(resultSorted.expectedResult); + expect(resultUnsorted.result).toEqual(resultUnsorted.expectedResult); + }); }); afterEach(() => { diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 3ef666cb..ddf5ca25 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -16,10 +16,18 @@ import { StatusEnum } from 'src/statuses/statuses.enum'; import { isArrayContainEqual } from 'src/utils/array-utils'; import { Enum } from 'src/utils/enum'; import { HttpErrorMessages } from 'src/utils/enums/http-error-messages.enum'; +import { formatLog } from 'src/utils/logging'; import { EntityCondition } from 'src/utils/types/entity-condition.type'; import { InvalidRowsType } from 'src/utils/types/invalid-rows.type'; import { IPaginationOptions } from 'src/utils/types/pagination-options'; -import { DeepPartial, FindOptionsWhere, ILike, Repository } from 'typeorm'; +import { + Brackets, + DeepPartial, + FindOptionsWhere, + ILike, + Repository, + WhereExpressionBuilder, +} from 'typeorm'; import * as xlsx from 'xlsx'; import { NullableType } from '../utils/types/nullable.type'; import { CreateUserFileDto } from './dto/create-user-file.dto'; @@ -30,7 +38,7 @@ import { IFileUser } from './interfaces/file-user.interface'; import { IFindUserPaginated } from './interfaces/find-user-paginated.interface'; import { IUserUploadResponse } from './interfaces/user-upload-response.interface'; import { FileUserMap } from './mappings/user-file.map'; -import { formatLog } from 'src/utils/logging'; +import { stringUppercaseUnaccent } from 'src/utils/string-utils'; export enum userUploadEnum { DUPLICATED_FIELD = 'Campo duplicado no arquivo de upload', @@ -79,6 +87,7 @@ export class UsersService { paginationOptions: IPaginationOptions, fields?: IFindUserPaginated, ): Promise { + console.log('findManyWithPagination'); const isSgtuBlocked = fields?.isSgtuBlocked || fields?._anyField?.value; let inviteStatus: any = null; @@ -91,67 +100,86 @@ export class UsersService { ), }; } - const where = [ - ...(fields?.name || fields?._anyField?.value - ? [ - { - fullName: ILike(`%${fields?.name || fields?._anyField?.value}%`), - }, - { - firstName: ILike(`%${fields?.name || fields?._anyField?.value}%`), - }, - { - lastName: ILike(`%${fields?.name || fields?._anyField?.value}%`), - }, - ] - : []), - ...(fields?.permitCode || fields?._anyField?.value - ? [ - { - permitCode: ILike( - `%${fields?.permitCode || fields?._anyField?.value}%`, - ), - }, - ] - : []), - ...(fields?.email || fields?._anyField?.value - ? [{ email: ILike(`%${fields?.email || fields?._anyField?.value}%`) }] - : []), - ...(fields?.cpfCnpj || fields?._anyField?.value - ? [ - { - cpfCnpj: ILike( - `%${fields?.cpfCnpj || fields?._anyField?.value}%`, - ), - }, - ] - : []), - ...(isSgtuBlocked === 'true' || isSgtuBlocked === 'false' - ? [{ isSgtuBlocked: isSgtuBlocked === 'true' }] - : []), - ...(fields?.passValidatorId || fields?._anyField?.value - ? [ - { - passValidatorId: ILike( - `%${fields?.passValidatorId || fields?._anyField?.value}%`, - ), - }, - ] - : []), + + const andWhere = { ...(fields?.role - ? [ - { - role: { id: fields.role.id }, - }, - ] - : []), - ] as FindOptionsWhere[]; - - let users = await this.usersRepository.find({ - ...(fields ? { where: where } : {}), - skip: (paginationOptions.page - 1) * paginationOptions.limit, - take: paginationOptions.limit, - }); + ? { + role: { id: fields.role.id }, + } + : {}), + } as FindOptionsWhere; + + const where = (qb: WhereExpressionBuilder) => { + const whereFields = [ + ...(fields?.permitCode || fields?._anyField?.value + ? [ + { + permitCode: ILike( + `%${fields?.permitCode || fields?._anyField?.value}%`, + ), + }, + ] + : []), + + ...(fields?.email || fields?._anyField?.value + ? [{ email: ILike(`%${fields?.email || fields?._anyField?.value}%`) }] + : []), + + ...(fields?.cpfCnpj || fields?._anyField?.value + ? [ + { + cpfCnpj: ILike( + `%${fields?.cpfCnpj || fields?._anyField?.value}%`, + ), + }, + ] + : []), + + ...(isSgtuBlocked === 'true' || isSgtuBlocked === 'false' + ? [{ isSgtuBlocked: isSgtuBlocked === 'true' }] + : []), + + ...(fields?.passValidatorId || fields?._anyField?.value + ? [ + { + passValidatorId: ILike( + `%${fields?.passValidatorId || fields?._anyField?.value}%`, + ), + }, + ] + : []), + ] as FindOptionsWhere[]; + + if (fields?.name || fields?._anyField?.value) { + const fieldName = fields?.name || fields?._anyField?.value; + return qb + .where(() => (whereFields.length > 0 ? whereFields : '1 = 0')) + .orWhere( + 'unaccent(UPPER("user"."fullName")) ILIKE unaccent(UPPER(:name))', + { name: `%${fieldName}%` }, + ) + .orWhere( + 'unaccent(UPPER("user"."firstName")) ILIKE unaccent(UPPER(:name))', + { name: `%${fieldName}%` }, + ) + .orWhere( + 'unaccent(UPPER("user"."lastName")) ILIKE unaccent(UPPER(:name))', + { name: `%${fieldName}%` }, + ); + } else { + return qb.where(whereFields); + } + }; + + let users = await this.usersRepository + .createQueryBuilder('user') + .where( + new Brackets((qb) => { + where(qb); + }), + ) + .andWhere(andWhere) + .getMany(); let invites: NullableType = null; if (inviteStatus) { @@ -522,7 +550,7 @@ export class UsersService { ), email: fileUser.user.email, phone: fileUser.user.telefone, - fullName: fileUser.user.nome, + fullName: stringUppercaseUnaccent(fileUser.user.nome as string), cpfCnpj: fileUser.user.cpf, hash: hash, status: new Status(StatusEnum.register), diff --git a/src/utils/string-utils.ts b/src/utils/string-utils.ts new file mode 100644 index 00000000..3a6f7c88 --- /dev/null +++ b/src/utils/string-utils.ts @@ -0,0 +1,6 @@ +export function stringUppercaseUnaccent(str: string): string { + return str + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toUpperCase(); +} diff --git a/test/admin/auth.e2e-spec.ts b/test/admin/auth.e2e-spec.ts index 8474530c..c245a20b 100644 --- a/test/admin/auth.e2e-spec.ts +++ b/test/admin/auth.e2e-spec.ts @@ -1,24 +1,83 @@ +import { HttpStatus } from '@nestjs/common'; +import { differenceInSeconds } from 'date-fns'; import * as request from 'supertest'; -import { ADMIN_EMAIL, ADMIN_PASSWORD, APP_URL } from '../utils/constants'; +import { + ADMIN_2_EMAIL, + ADMIN_EMAIL, + ADMIN_PASSWORD, + APP_URL, + MAILDEV_URL, +} from '../utils/constants'; -describe('Auth admin (e2e)', () => { - const app = APP_URL; +describe('Admin auth (e2e)', () => { + describe('Setup tests', () => { + it('Should have UTC and local timezones', () => { + new Date().getTimezoneOffset(); + expect(process.env.TZ).toEqual('UTC'); + expect(global.__localTzOffset).toBeDefined(); + }); - it('Login: /api/v1/auth/admin/email/login (POST)', () => { - return request(app) - .post('/api/v1/auth/admin/email/login') - .send({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }) - .expect(200) - .expect(({ body }) => { - expect(body.token).toBeDefined(); - expect(body.user.email).toBeDefined(); - }); + it('Should have mailDev server', async () => { + await request(MAILDEV_URL).get('').expect(HttpStatus.OK); + }); }); - it('Login via user endpoint: /api/v1/auth/email/login (POST)', () => { - return request(app) - .post('/api/v1/auth/email/login') - .send({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }) - .expect(422); + /** + * @see {@link https://github.com/RJ-SMTR/api-cct/issues/94#issuecomment-1815016208 Phase 1, requirements #94 - GitHub} + */ + describe('Phase 1: Admin basics and user management', () => { + test('Login admin: POST /api/v1/auth/admin/email/login', () => { + return request(APP_URL) + .post('/api/v1/auth/admin/email/login') + .send({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }) + .expect(HttpStatus.OK) + .expect(({ body }) => { + expect(body.token).toBeDefined(); + expect(body.user.email).toBeDefined(); + }); + }); + + test('Reset admin password', async () => { + await request(APP_URL) + .post('/api/v1/auth/forgot/password') + .send({ + email: ADMIN_2_EMAIL, + }) + .expect(HttpStatus.ACCEPTED); + const forgotLocalDate = new Date(); + forgotLocalDate.setMinutes( + forgotLocalDate.getMinutes() + global.__localTzOffset, + ); + + const hash = await request(MAILDEV_URL) + .get('/email') + .then(({ body }) => + (body as any[]) + .filter( + (letter: any) => + letter.to[0].address.toLowerCase() === + ADMIN_2_EMAIL.toLowerCase() && + /.*reset\-password\/(\w+).*/g.test(letter.text) && + differenceInSeconds(forgotLocalDate, new Date(letter.date)) <= + 10, + ) + .pop() + ?.text.replace(/.*reset\-password\/(\w+).*/g, '$1'), + ); + + const newPassword = Math.random().toString(36).slice(-8); + await request(APP_URL) + .post('/api/v1/auth/reset/password') + .send({ + hash, + password: newPassword, + }) + .expect(HttpStatus.NO_CONTENT); + + await request(APP_URL) + .post('/api/v1/auth/admin/email/login') + .send({ email: ADMIN_2_EMAIL, password: newPassword }) + .expect(HttpStatus.OK); + }, 60000); }); }); diff --git a/test/admin/users.e2e-spec.ts b/test/admin/users.e2e-spec.ts index 64765618..c038f661 100644 --- a/test/admin/users.e2e-spec.ts +++ b/test/admin/users.e2e-spec.ts @@ -1,17 +1,24 @@ -import { APP_URL, ADMIN_EMAIL, ADMIN_PASSWORD } from '../utils/constants'; +import { HttpStatus } from '@nestjs/common'; +import { differenceInSeconds } from 'date-fns'; +import * as fs from 'fs'; +import { generate } from 'gerador-validador-cpf'; +import * as path from 'path'; import * as request from 'supertest'; -import { RoleEnum } from '../../src/roles/roles.enum'; -import { StatusEnum } from '../../src/statuses/statuses.enum'; +import * as XLSX from 'xlsx'; +import { + ADMIN_EMAIL, + ADMIN_PASSWORD, + APP_URL, + LICENSEE_CASE_ACCENT, + LICENSEE_PERMIT_CODE, + MAILDEV_URL, +} from '../utils/constants'; +import { stringUppercaseUnaccent } from 'src/utils/string-utils'; -describe('Users admin (e2e)', () => { +describe('Admin managing users (e2e)', () => { const app = APP_URL; - let newUserFirst; - const newUserEmailFirst = `user-first.${Date.now()}@example.com`; - const newUserPasswordFirst = `secret`; - const newUserChangedPasswordFirst = `new-secret`; - const newUserByAdminEmailFirst = `user-created-by-admin.${Date.now()}@example.com`; - const newUserByAdminPasswordFirst = `secret`; - let apiToken; + const tempFolder = path.join(__dirname, 'temp'); + let apiToken: any = {}; beforeAll(async () => { await request(app) @@ -21,101 +28,239 @@ describe('Users admin (e2e)', () => { apiToken = body.token; }); - await request(app) - .post('/api/v1/auth/email/register') - .send({ - email: newUserEmailFirst, - password: newUserPasswordFirst, - firstName: `First${Date.now()}`, - lastName: 'E2E', - }); - - await request(app) - .post('/api/v1/auth/email/login') - .send({ email: newUserEmailFirst, password: newUserPasswordFirst }) - .then(({ body }) => { - newUserFirst = body.user; - }); - }); - - it('Change password for new user: /api/v1/users/:id (PATCH)', () => { - return request(app) - .patch(`/api/v1/users/${newUserFirst.id}`) - .auth(apiToken, { - type: 'bearer', - }) - .send({ password: newUserChangedPasswordFirst }) - .expect(200); + if (!fs.existsSync(tempFolder)) { + fs.mkdirSync(tempFolder); + } }); - it('Login via registered user: /api/v1/auth/email/login (POST)', () => { - return request(app) - .post('/api/v1/auth/email/login') - .send({ email: newUserEmailFirst, password: newUserChangedPasswordFirst }) - .expect(200) - .expect(({ body }) => { - expect(body.token).toBeDefined(); - }); - }); + describe('Setup tests', () => { + it('Should have UTC and local timezones', () => { + new Date().getTimezoneOffset(); + expect(process.env.TZ).toEqual('UTC'); + expect(global.__localTzOffset).toBeDefined(); + }); - it('Fail create new user by admin: /api/v1/users (POST)', () => { - return request(app) - .post(`/api/v1/users`) - .auth(apiToken, { - type: 'bearer', - }) - .send({ email: 'fail-data' }) - .expect(422); + it('Should have mailDev server', async () => { + await request(MAILDEV_URL).get('').expect(HttpStatus.OK); + }); }); - it('Success create new user by admin: /api/v1/users (POST)', () => { - return request(app) - .post(`/api/v1/users`) - .auth(apiToken, { - type: 'bearer', - }) - .send({ - email: newUserByAdminEmailFirst, - password: newUserByAdminPasswordFirst, - firstName: `UserByAdmin${Date.now()}`, - lastName: 'E2E', - role: { - id: RoleEnum.user, + /** + * Phase 1: manage users + * @see {@link https://github.com/RJ-SMTR/api-cct/issues/94#issuecomment-1815016208 Requirements #94 - GitHub} + */ + describe('Manage users', () => { + test('Filter users', async () => { + // Arrange + const licensee = await request(app) + .get('/api/v1/users/') + .auth(apiToken, { + type: 'bearer', + }) + .query({ permitCode: LICENSEE_PERMIT_CODE }) + .expect(({ body }) => { + expect(body.data.length).toBe(1); + }) + .then(({ body }) => body.data); + const licenseePartOfName = 'user'; + const args = [ + { + filter: { name: stringUppercaseUnaccent(LICENSEE_CASE_ACCENT) }, + expect: (body: any) => + expect( + body.data.some((i: any) => i.fullName === LICENSEE_CASE_ACCENT), + ).toBeTruthy(), }, - status: { - id: StatusEnum.active, + { + filter: { permitCode: licensee.permitCode }, + expect: (body: any) => + expect( + body.data.some((i: any) => i.permitCode === LICENSEE_PERMIT_CODE), + ).toBeTruthy(), }, - }) - .expect(201); - }); + { + filter: { name: licensee.fullName }, + expect: (body: any) => + expect( + body.data.some((i: any) => i.permitCode === LICENSEE_PERMIT_CODE), + ).toBeTruthy(), + }, + { + filter: { email: licensee.email }, + expect: (body: any) => + expect( + body.data.some((i: any) => i.permitCode === LICENSEE_PERMIT_CODE), + ).toBeTruthy(), + }, + { + filter: { name: licenseePartOfName, inviteStatus: 'queued' }, + expect: (body: any) => + expect( + body.data.some((i: any) => i.fullName === 'Queued user'), + ).toBeTruthy(), + }, + { + filter: { name: licenseePartOfName, inviteStatus: 'sent' }, + expect: (body: any) => + expect( + body.data.some((i: any) => i.fullName === 'Sent user'), + ).toBeTruthy(), + }, + { + filter: { name: licenseePartOfName, inviteStatus: 'used' }, + expect: (body: any) => + expect( + body.data.some((i: any) => i.fullName === 'Used user'), + ).toBeTruthy(), + }, + ]; - it('Login via created by admin user: /api/v1/auth/email/login (GET)', () => { - return request(app) - .post('/api/v1/auth/email/login') - .send({ - email: newUserByAdminEmailFirst, - password: newUserByAdminPasswordFirst, - }) - .expect(200) - .expect(({ body }) => { - expect(body.token).toBeDefined(); - }); + // Assert + for (const arg of args) { + await request(app) + .get('/api/v1/users/') + .auth(apiToken, { + type: 'bearer', + }) + .query(arg.filter) + .expect(HttpStatus.OK) + .then(({ body }) => { + arg.expect(body); + return body.data; + }); + } + }, 20000); }); - it('Get list of users by admin: /api/v1/users (GET)', () => { - return request(app) - .get(`/api/v1/users`) - .auth(apiToken, { - type: 'bearer', - }) - .expect(200) - .send() - .expect(({ body }) => { - expect(body.data[0].provider).toBeDefined(); - expect(body.data[0].email).toBeDefined(); - expect(body.data[0].hash).not.toBeDefined(); - expect(body.data[0].password).not.toBeDefined(); - expect(body.data[0].previousPassword).not.toBeDefined(); - }); + /** + * Phase 1: upload users + * @see {@link https://github.com/RJ-SMTR/api-cct/issues/94#issuecomment-1815016208 Requirements #94 - GitHub} + */ + describe('Upload users', () => { + let uploadUsers: any[]; + let users: any[] = []; + + beforeAll(() => { + const randomCode = Math.random().toString(36).slice(-8); + uploadUsers = [ + { + codigo_permissionario: `permitCode_${randomCode}`, + nome: `Café_${randomCode}`, + email: `user.${randomCode}@test.com`, + telefone: `219${Math.random().toString().slice(2, 10)}`, + cpf: generate(), + }, + ]; + }); + + test(`Upload users, status = 'queued'`, async () => { + // Arrange + const excelFilePath = path.join(tempFolder, 'newUsers.xlsx'); + const workbook = XLSX.utils.book_new(); + const worksheet = XLSX.utils.json_to_sheet(uploadUsers); + XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1'); + XLSX.writeFile(workbook, excelFilePath); + + // Assert + await request(app) + .post('/api/v1/users/upload') + .auth(apiToken, { + type: 'bearer', + }) + .attach('file', excelFilePath) + .expect(HttpStatus.CREATED) + .expect(({ body }) => { + expect(body.uploadedUsers).toEqual(1); + }); + + users = await request(app) + .get('/api/v1/users/') + .auth(apiToken, { + type: 'bearer', + }) + .query({ permitCode: uploadUsers[0].codigo_permissionario }) + .expect(({ body }) => { + expect(body.data.length).toBe(1); + expect(body.data[0]?.fullName).toEqual( + stringUppercaseUnaccent(uploadUsers[0].nome), + ); + expect(body.data[0]?.aux_inviteStatus?.name).toEqual('queued'); + }) + .then(({ body }) => body.data); + }); + + test(`Resend new user invite, status = 'sent'`, async () => { + const newUser = users[0]; + expect(newUser?.id).toBeDefined(); + + await request(APP_URL) + .post('/api/v1/auth/email/resend') + .auth(apiToken, { + type: 'bearer', + }) + .send({ + id: newUser.id, + }) + .expect(HttpStatus.NO_CONTENT); + const forgotLocalDate = new Date(); + forgotLocalDate.setMinutes( + forgotLocalDate.getMinutes() + global.__localTzOffset, + ); + + newUser.hash = await request(MAILDEV_URL) + .get('/email') + .then(({ body }) => + (body as any[]) + .filter( + (letter: any) => + letter.to[0].address.toLowerCase() === + newUser.email.toLowerCase() && + /.*conclude\-registration\/(\w+).*/g.test(letter.text) && + differenceInSeconds(forgotLocalDate, new Date(letter.date)) <= + 10, + ) + .pop() + ?.text.replace(/.*conclude\-registration\/(\w+).*/g, '$1'), + ); + + await request(APP_URL) + .post(`/api/v1/auth/licensee/invite/${newUser.hash}`) + .expect(HttpStatus.OK) + .expect(({ body }) => { + expect(body.email).toEqual(newUser.email); + expect(body?.inviteStatus?.name).toEqual('sent'); + }); + + users[0] = newUser; + }); + + test(`New user conclude registration, status = 'used'`, async () => { + const newUser = users[0]; + expect(newUser?.hash).toBeDefined(); + + const newPassword = Math.random().toString(36).slice(-8); + await request(APP_URL) + .post(`/api/v1/auth/licensee/register/${newUser.hash}`) + .send({ password: newPassword }) + .expect(HttpStatus.OK) + .expect(({ body }) => { + expect(body.user.aux_inviteStatus?.name).toEqual('used'); + expect(body.token).toBeDefined(); + }); + + newUser.password = newPassword; + users[0] = newUser; + }); + + test('New user login', async () => { + const newUser = users[0]; + await request(APP_URL) + .post(`/api/v1/auth/licensee/login`) + .send({ permitCode: newUser.permitCode, password: newUser.password }) + .expect(HttpStatus.OK) + .expect(({ body }) => { + expect(body.token).toBeDefined(); + }); + }); }); }); diff --git a/test/global-setup.ts b/test/global-setup.ts index 924680da..3136c6c7 100644 --- a/test/global-setup.ts +++ b/test/global-setup.ts @@ -1,3 +1,11 @@ +import { differenceInMinutes } from 'date-fns'; + module.exports = () => { + global.__localTz = Intl.DateTimeFormat().resolvedOptions().timeZone; + const localDateStr = new Date().toString(); process.env.TZ = 'UTC'; + global.__localTzOffset = differenceInMinutes( + new Date(localDateStr.split(' GMT')[0]).getTime(), + new Date().getTime(), + ); }; diff --git a/test/jest-e2e.json b/test/jest-e2e.json index 7c6bbdff..4a19aea5 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -1,5 +1,9 @@ { - "moduleFileExtensions": ["js", "json", "ts"], + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], "rootDir": ".", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", @@ -10,4 +14,4 @@ "^src/(.*)$": "/../src/$1" }, "globalSetup": "./global-setup.ts" -} +} \ No newline at end of file diff --git a/test/user/auth.e2e-spec.ts b/test/user/auth.e2e-spec.ts index 0315117e..d33c64b9 100644 --- a/test/user/auth.e2e-spec.ts +++ b/test/user/auth.e2e-spec.ts @@ -1,222 +1,80 @@ +import { HttpStatus } from '@nestjs/common'; +import { differenceInSeconds } from 'date-fns'; import * as request from 'supertest'; import { APP_URL, - TESTER_EMAIL, - TESTER_PASSWORD, - MAIL_HOST, - MAIL_PORT, + LICENSEE_2_EMAIL, + LICENSEE_2_PERMIT_CODE, + LICENSEE_PASSWORD, + LICENSEE_PERMIT_CODE, + MAILDEV_URL, } from '../utils/constants'; -describe('Auth user (e2e)', () => { +describe('User auth (e2e)', () => { const app = APP_URL; - const mail = `http://${MAIL_HOST}:${MAIL_PORT}`; - const newUserFirstName = `Tester${Date.now()}`; - const newUserLastName = `E2E`; - const newUserEmail = `User.${Date.now()}@example.com`; - const newUserPassword = `secret`; - it('Login: /api/v1/auth/email/login (POST)', () => { - return request(app) - .post('/api/v1/auth/email/login') - .send({ email: TESTER_EMAIL, password: TESTER_PASSWORD }) - .expect(200) - .expect(({ body }) => { - expect(body.token).toBeDefined(); - expect(body.user.email).toBeDefined(); - expect(body.user.hash).not.toBeDefined(); - expect(body.user.password).not.toBeDefined(); - expect(body.user.previousPassword).not.toBeDefined(); - }); - }); - - it('Login via admin endpoint: /api/v1/auth/admin/email/login (POST)', () => { - return request(app) - .post('/api/v1/auth/admin/email/login') - .send({ email: TESTER_EMAIL, password: TESTER_PASSWORD }) - .expect(422); - }); - - it('Login via admin endpoint with extra spaced: /api/v1/auth/admin/email/login (POST)', () => { - return request(app) - .post('/api/v1/auth/admin/email/login') - .send({ email: TESTER_EMAIL + ' ', password: TESTER_PASSWORD }) - .expect(422); - }); - - it('Do not allow register user with exists email: /api/v1/auth/email/register (POST)', () => { - return request(app) - .post('/api/v1/auth/email/register') - .send({ - email: TESTER_EMAIL, - password: TESTER_PASSWORD, - firstName: 'Tester', - lastName: 'E2E', - }) - .expect(422) - .expect(({ body }) => { - expect(body.errors.email).toBeDefined(); - }); - }); - - it('Register new user: /api/v1/auth/email/register (POST)', async () => { - return request(app) - .post('/api/v1/auth/email/register') - .send({ - email: newUserEmail, - password: newUserPassword, - firstName: newUserFirstName, - lastName: newUserLastName, - }) - .expect(204); - }); + describe('Setup tests', () => { + it('Should have UTC and local timezones', () => { + new Date().getTimezoneOffset(); + expect(process.env.TZ).toEqual('UTC'); + expect(global.__localTzOffset).toBeDefined(); + }); - it('Login unconfirmed user: /api/v1/auth/email/login (POST)', () => { - return request(app) - .post('/api/v1/auth/email/login') - .send({ email: newUserEmail, password: newUserPassword }) - .expect(200) - .expect(({ body }) => { - expect(body.token).toBeDefined(); - }); + it('Should have mailDev server', async () => { + await request(MAILDEV_URL).get('').expect(HttpStatus.OK); + }); }); - it('Confirm email: /api/v1/auth/email/confirm (POST)', async () => { - const hash = await request(mail) - .get('/email') - .then(({ body }) => - body - .find( - (letter) => - letter.to[0].address.toLowerCase() === - newUserEmail.toLowerCase() && - /.*confirm\-email\/(\w+).*/g.test(letter.text), - ) - ?.text.replace(/.*confirm\-email\/(\w+).*/g, '$1'), - ); - - return request(app) - .post('/api/v1/auth/email/confirm') - .send({ - hash, - }) - .expect(204); - }); + /** + * @see {@link https://github.com/RJ-SMTR/api-cct/issues/94#issuecomment-1815016208 Phase 1, requirements #94 - GitHub} + */ + describe('Phase 1: User basics', () => { + test('Login user: POST /api/v1/auth/licensee/login', () => { + return request(app) + .post('/api/v1/auth/licensee/login') + .send({ permitCode: LICENSEE_PERMIT_CODE, password: LICENSEE_PASSWORD }) + .expect(HttpStatus.OK); + }); - it('Can not confirm email with same link twice: /api/v1/auth/email/confirm (POST)', async () => { - const hash = await request(mail) - .get('/email') - .then(({ body }) => - body - .find( - (letter) => - letter.to[0].address.toLowerCase() === - newUserEmail.toLowerCase() && - /.*confirm\-email\/(\w+).*/g.test(letter.text), - ) - ?.text.replace(/.*confirm\-email\/(\w+).*/g, '$1'), + test('Reset user password', async () => { + await request(APP_URL) + .post('/api/v1/auth/forgot/password') + .send({ email: LICENSEE_2_EMAIL }) + .expect(HttpStatus.ACCEPTED); + const forgotLocalDate = new Date(); + forgotLocalDate.setMinutes( + forgotLocalDate.getMinutes() + global.__localTzOffset, ); - return request(app) - .post('/api/v1/auth/email/confirm') - .send({ - hash, - }) - .expect(404); - }); - - it('Login confirmed user: /api/v1/auth/email/login (POST)', () => { - return request(app) - .post('/api/v1/auth/email/login') - .send({ email: newUserEmail, password: newUserPassword }) - .expect(200) - .expect(({ body }) => { - expect(body.token).toBeDefined(); - expect(body.user.email).toBeDefined(); - }); - }); - - it('Confirmed user retrieve profile: /api/v1/auth/me (GET)', async () => { - const newUserApiToken = await request(app) - .post('/api/v1/auth/email/login') - .send({ email: newUserEmail, password: newUserPassword }) - .then(({ body }) => body.token); - - await request(app) - .get('/api/v1/auth/me') - .auth(newUserApiToken, { - type: 'bearer', - }) - .send() - .expect(({ body }) => { - expect(body.provider).toBeDefined(); - expect(body.email).toBeDefined(); - expect(body.hash).not.toBeDefined(); - expect(body.password).not.toBeDefined(); - expect(body.previousPassword).not.toBeDefined(); - }); - }); - - it('New user update profile: /api/v1/auth/me (PATCH)', async () => { - const newUserNewName = Date.now(); - const newUserNewPassword = 'new-secret'; - const newUserApiToken = await request(app) - .post('/api/v1/auth/email/login') - .send({ email: newUserEmail, password: newUserPassword }) - .then(({ body }) => body.token); - - await request(app) - .patch('/api/v1/auth/me') - .auth(newUserApiToken, { - type: 'bearer', - }) - .send({ - firstName: newUserNewName, - password: newUserNewPassword, - }) - .expect(422); - - await request(app) - .patch('/api/v1/auth/me') - .auth(newUserApiToken, { - type: 'bearer', - }) - .send({ - firstName: newUserNewName, - password: newUserNewPassword, - oldPassword: newUserPassword, - }) - .expect(200); - - await request(app) - .post('/api/v1/auth/email/login') - .send({ email: newUserEmail, password: newUserNewPassword }) - .expect(200) - .expect(({ body }) => { - expect(body.token).toBeDefined(); - }); - - await request(app) - .patch('/api/v1/auth/me') - .auth(newUserApiToken, { - type: 'bearer', - }) - .send({ password: newUserPassword, oldPassword: newUserNewPassword }) - .expect(200); - }); - - it('New user delete profile: /api/v1/auth/me (DELETE)', async () => { - const newUserApiToken = await request(app) - .post('/api/v1/auth/email/login') - .send({ email: newUserEmail, password: newUserPassword }) - .then(({ body }) => body.token); - - await request(app).delete('/api/v1/auth/me').auth(newUserApiToken, { - type: 'bearer', - }); - - return request(app) - .post('/api/v1/auth/email/login') - .send({ email: newUserEmail, password: newUserPassword }) - .expect(422); + const hash = await request(MAILDEV_URL) + .get('/email') + .then(({ body }) => + (body as any[]) + .filter( + (letter: any) => + letter.to[0].address.toLowerCase() === + LICENSEE_2_EMAIL.toLowerCase() && + /.*reset\-password\/(\w+).*/g.test(letter.text) && + differenceInSeconds(forgotLocalDate, new Date(letter.date)) <= + 10, + ) + .pop() + ?.text.replace(/.*reset\-password\/(\w+).*/g, '$1'), + ); + + const newPassword = Math.random().toString(36).slice(-8); + await request(APP_URL) + .post('/api/v1/auth/reset/password') + .send({ + hash, + password: newPassword, + }) + .expect(HttpStatus.NO_CONTENT); + + await request(APP_URL) + .post('/api/v1/auth/licensee/login') + .send({ permitCode: LICENSEE_2_PERMIT_CODE, password: newPassword }) + .expect(HttpStatus.OK); + }, 60000); }); }); diff --git a/test/utils/constants.ts b/test/utils/constants.ts index 93eecf33..b3181212 100644 --- a/test/utils/constants.ts +++ b/test/utils/constants.ts @@ -2,12 +2,21 @@ import { config } from 'dotenv'; config(); export const APP_URL = `http://localhost:${process.env.APP_PORT}`; -export const TESTER_EMAIL = 'john.doe@example.com'; -export const TESTER_PASSWORD = 'secret'; +export const MAILDEV_URL = `http://${process.env.MAIL_HOST}:${process.env.MAIL_CLIENT_PORT}`; + export const ADMIN_EMAIL = process.env.TEST_ADMIN_EMAIL || 'admin@example.com'; export const ADMIN_PASSWORD = process.env.TEST_ADMIN_PASSWORD || 'secret'; -export const LICENSEE_PERMIT_CODE = - process.env.TEST_LICENSEE_PERMIT_CODE || '213890329890312'; -export const LICENSEE_PASSWORD = process.env.TEST_LICENSEE_PASSWORD || 'secret'; +export const ADMIN_2_EMAIL = 'admin.test@example.com'; +export const ADMIN_2_PASSWORD = 'secret'; + +export const LICENSEE_EMAIL = 'henrique@example.com'; +export const LICENSEE_PERMIT_CODE = '213890329890312'; +export const LICENSEE_PASSWORD = 'secret'; +export const LICENSEE_CASE_ACCENT = 'Márcia Clara Template'; + +export const LICENSEE_2_EMAIL = 'marcia@example.com'; +export const LICENSEE_2_PERMIT_CODE = '319274392832023'; +export const LICENSEE_2_PASSWORD = 'secret'; + export const MAIL_HOST = process.env.MAIL_HOST; export const MAIL_PORT = process.env.MAIL_CLIENT_PORT; diff --git a/yarn.lock b/yarn.lock index 1ca28f0a..7445cb19 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4990,6 +4990,11 @@ "resolved" "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz" "version" "1.0.0-beta.2" +"gerador-validador-cpf@^5.0.2": + "integrity" "sha512-7nqJilkfIv3HIbB50uP32SOxe/A3TyvVS3AXxwU6cqHq7jMTnkp0WGPaGytY3Yc36RjzysVQ6xhlwcCt70CnOw==" + "resolved" "https://registry.npmjs.org/gerador-validador-cpf/-/gerador-validador-cpf-5.0.2.tgz" + "version" "5.0.2" + "get-caller-file@^2.0.5": "integrity" "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" "resolved" "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz"