From e661d2932498194d614fae51651fd220c752e207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hermerson=20Ara=C3=BAjo?= Date: Sun, 11 Feb 2024 14:20:03 -0300 Subject: [PATCH] feat: add logic to send confirmation code --- .env.test | 1 + src/b3_stock_alerts/AlertService.unit.test.ts | 1 + src/b3_stock_alerts/AuthService.ts | 24 ++++++ src/b3_stock_alerts/AuthService.unit.test.ts | 79 +++++++++++++++++++ src/b3_stock_alerts/ConfirmationCode.ts | 10 +++ ...ilAlertNotification.ts => EmailGateway.ts} | 17 +++- ...unit.test.ts => EmailGateway.unit.test.ts} | 46 ++++++++--- src/b3_stock_alerts/PgUserRepository.ts | 8 ++ .../PgUserRepository.unit.test.ts | 19 +++++ .../StockEventHandler.unit.test.ts | 1 + src/b3_stock_alerts/UserConfirmationCode.ts | 5 ++ src/b3_stock_alerts/UserRepository.ts | 2 + src/b3_stock_alerts/UserService.unit.test.ts | 1 + src/bootstrap.ts | 13 ++- 14 files changed, 208 insertions(+), 19 deletions(-) create mode 100644 src/b3_stock_alerts/ConfirmationCode.ts rename src/b3_stock_alerts/{EmailAlertNotification.ts => EmailGateway.ts} (63%) rename src/b3_stock_alerts/{EmailAlertNotification.unit.test.ts => EmailGateway.unit.test.ts} (66%) create mode 100644 src/b3_stock_alerts/UserConfirmationCode.ts diff --git a/.env.test b/.env.test index 74dd459..dff2c25 100644 --- a/.env.test +++ b/.env.test @@ -1,6 +1,7 @@ NODE_ENV=test SERVER_PORT=5000 +SERVER_URL=http://localhost:5000 DB_HOST=localhost DB_PORT=5432 diff --git a/src/b3_stock_alerts/AlertService.unit.test.ts b/src/b3_stock_alerts/AlertService.unit.test.ts index b9da864..0ba7aa2 100644 --- a/src/b3_stock_alerts/AlertService.unit.test.ts +++ b/src/b3_stock_alerts/AlertService.unit.test.ts @@ -18,6 +18,7 @@ describe('AlertService', () => { getUsers: jest.fn(), updateUser: jest.fn(), getUserByEmail: jest.fn(), + createConfirmationCode: jest.fn(), }; const service = new AlertService(alert_repository_mock, user_repository_mock); diff --git a/src/b3_stock_alerts/AuthService.ts b/src/b3_stock_alerts/AuthService.ts index d6f4b8f..f3ae2f1 100644 --- a/src/b3_stock_alerts/AuthService.ts +++ b/src/b3_stock_alerts/AuthService.ts @@ -1,6 +1,9 @@ import CredentialError from '@shared/CredentialError'; +import NotFoundError from '@shared/NotFoundError'; import { Result } from '@shared/generic_types'; +import { randomInt, randomUUID } from 'crypto'; import Authenticator from './Authenticator'; +import ConfirmationCode from './ConfirmationCode'; import Encryptor from './Encryptor'; import { User } from './User'; import UserRepository from './UserRepository'; @@ -16,6 +19,7 @@ export default class AuthService { private readonly user_repository: UserRepository, private readonly encryptor: Encryptor, private readonly authenticator: Authenticator, + private readonly confirmation_code: ConfirmationCode, ) { } async login(email: string, password: string): Promise> { @@ -43,4 +47,24 @@ export default class AuthService { async verifyCaptcha(user_ip: string, token: string): Promise> { return { data: await this.authenticator.verifyCaptcha(user_ip, token) }; } + + async sendConfirmationCode(email: string): Promise> { + const user = await this.user_repository.getUserByEmail(email); + + if (!user) { + return { error: new NotFoundError('Usuário não encontrado.') }; + } + + const code = randomInt(1000, 9999).toString(); + + await this.confirmation_code.sendCode({ email, code }); + + await this.user_repository.createConfirmationCode({ + id: randomUUID(), + user_id: user.id, + code, + }); + + return {}; + } } diff --git a/src/b3_stock_alerts/AuthService.unit.test.ts b/src/b3_stock_alerts/AuthService.unit.test.ts index aa9c35c..18a3530 100644 --- a/src/b3_stock_alerts/AuthService.unit.test.ts +++ b/src/b3_stock_alerts/AuthService.unit.test.ts @@ -1,5 +1,7 @@ import { faker } from '@faker-js/faker/locale/pt_BR'; import CredentialError from '@shared/CredentialError'; +import NotFoundError from '@shared/NotFoundError'; +import crypto from 'crypto'; import AuthService from './AuthService'; import { User } from './User'; @@ -11,6 +13,7 @@ describe("AuthService's unit tests", () => { updateUser: jest.fn(), getUsers: jest.fn(), getUserByEmail: jest.fn(), + createConfirmationCode: jest.fn(), }; const encryptor_mock = { @@ -25,10 +28,15 @@ describe("AuthService's unit tests", () => { verifyCaptcha: jest.fn(), }; + const confirmation_code_mock = { + sendCode: jest.fn(), + } + const auth_service = new AuthService( user_repository_mock, encryptor_mock, authenticator_mock, + confirmation_code_mock, ); describe('AuthService.login', () => { @@ -97,4 +105,75 @@ describe("AuthService's unit tests", () => { } }); }); + + describe('AuthService.sendConfirmationCode', () => { + const random_int_spy = jest.spyOn(crypto, 'randomInt'); + + afterEach(() => { + random_int_spy.mockClear(); + user_repository_mock.createConfirmationCode.mockClear(); + confirmation_code_mock.sendCode.mockClear(); + }); + + it("return result with NotFoundError if user doesn't exist", async () => { + expect.assertions(1); + + const email = faker.internet.email(); + + user_repository_mock.getUserByEmail.mockResolvedValueOnce(null); + + const result = await auth_service.sendConfirmationCode(email); + + expect(result.error).toBeInstanceOf(NotFoundError); + }); + + it('calls ConfirmationCode.sendCode with correct code and email', async () => { + expect.assertions(3); + + const email = faker.internet.email(); + + const user: User = { + id: faker.string.uuid(), + email: faker.internet.email(), + name: faker.person.fullName(), + password: faker.string.alphanumeric(10), + phone_number: faker.string.numeric(11), + }; + + user_repository_mock.getUserByEmail.mockResolvedValueOnce(user); + + await auth_service.sendConfirmationCode(email); + + expect(random_int_spy).toHaveBeenCalledWith(1000, 9999); + const call = confirmation_code_mock.sendCode.mock.calls[0]; + + expect(call[0].email).toEqual(email); + expect(call[0].code).toBeDefined(); + }); + + it('saves the code after send it for user email', async () => { + expect.assertions(3); + + const email = faker.internet.email(); + + const user: User = { + id: faker.string.uuid(), + email: faker.internet.email(), + name: faker.person.fullName(), + password: faker.string.alphanumeric(10), + phone_number: faker.string.numeric(11), + }; + + random_int_spy.mockReturnValueOnce(1234 as any); + user_repository_mock.getUserByEmail.mockResolvedValueOnce(user); + + await auth_service.sendConfirmationCode(email); + + const params = user_repository_mock.createConfirmationCode.mock.calls[0][0]; + + expect(params.id).toBeDefined(); + expect(params.user_id).toBe(user.id); + expect(params.code).toBe('1234'); + }); + }); }); diff --git a/src/b3_stock_alerts/ConfirmationCode.ts b/src/b3_stock_alerts/ConfirmationCode.ts new file mode 100644 index 0000000..59d123f --- /dev/null +++ b/src/b3_stock_alerts/ConfirmationCode.ts @@ -0,0 +1,10 @@ +export type SendCodeParams = { + email: string; + code: string; +}; + +interface ConfirmationCode { + sendCode(params: SendCodeParams): Promise; +} + +export default ConfirmationCode; diff --git a/src/b3_stock_alerts/EmailAlertNotification.ts b/src/b3_stock_alerts/EmailGateway.ts similarity index 63% rename from src/b3_stock_alerts/EmailAlertNotification.ts rename to src/b3_stock_alerts/EmailGateway.ts index 7c1ae73..ff9c314 100644 --- a/src/b3_stock_alerts/EmailAlertNotification.ts +++ b/src/b3_stock_alerts/EmailGateway.ts @@ -1,7 +1,8 @@ import nodemailer, { Transporter } from 'nodemailer'; import AlertNotification, { AlertNotificationTypes, NotificationData } from './AlertNotification'; +import ConfirmationCode, { SendCodeParams } from './ConfirmationCode'; -export default class EmailAlertNotification implements AlertNotification { +export default class EmailGateway implements AlertNotification, ConfirmationCode { private readonly transporter: Transporter; constructor() { @@ -18,7 +19,7 @@ export default class EmailAlertNotification implements AlertNotification { async notify(data: NotificationData): Promise { const message = { - from: 'test@server.com', + from: process.env.APPLICATION_EMAIL, to: data.user.email, subject: '', text: '', @@ -37,4 +38,16 @@ export default class EmailAlertNotification implements AlertNotification { await this.transporter.sendMail(message); } + + async sendCode(params: SendCodeParams): Promise { + const message = { + from: process.env.APPLICATION_EMAIL, + to: params.email, + subject: 'Código de confirmação', + text: `Segue o código de confirmação ${params.code}. Acesse o link ${process.env.SERVER_URL}/pages/confirm-code?email=${params.email}.`, + html: `

Segue o código de confirmação ${params.code}.


Acesse o link ${process.env.SERVER_URL}/pages/confirm-code?email=${params.email}.

`, + }; + + await this.transporter.sendMail(message); + } } diff --git a/src/b3_stock_alerts/EmailAlertNotification.unit.test.ts b/src/b3_stock_alerts/EmailGateway.unit.test.ts similarity index 66% rename from src/b3_stock_alerts/EmailAlertNotification.unit.test.ts rename to src/b3_stock_alerts/EmailGateway.unit.test.ts index 87bf5ae..531eedf 100644 --- a/src/b3_stock_alerts/EmailAlertNotification.unit.test.ts +++ b/src/b3_stock_alerts/EmailGateway.unit.test.ts @@ -2,13 +2,21 @@ import { faker } from '@faker-js/faker/locale/pt_BR'; import nodemailer from 'nodemailer'; import { AlertNotificationTypes, NotificationData } from './AlertNotification'; -import EmailAlertNotification from './EmailAlertNotification'; +import EmailGateway from './EmailGateway'; jest.mock('nodemailer'); const nodemailer_mock = jest.mocked(nodemailer); -describe('EmailAlertNotification', () => { + +describe("EmailGateway's unit tests", () => { const OLD_ENV = process.env; + const transport_mock = { sendMail: jest.fn() } as any; + nodemailer_mock.createTransport.mockReturnValue(transport_mock); + const email_gateway = new EmailGateway(); + + afterEach(() => { + transport_mock.sendMail.mockClear(); + }); beforeAll(() => { process.env = { @@ -17,6 +25,7 @@ describe('EmailAlertNotification', () => { EMAIL_PORT: '123', EMAIL_USER: 'test', EMAIL_PASSWORD: 'test', + SERVER_URL: 'http://localhost:5000', }; }); @@ -24,15 +33,7 @@ describe('EmailAlertNotification', () => { process.env = OLD_ENV; }); - describe('EmailAlertNotification.notify', () => { - const transport_mock = { sendMail: jest.fn() } as any; - nodemailer_mock.createTransport.mockReturnValue(transport_mock); - const alert_notification = new EmailAlertNotification(); - - afterEach(() => { - transport_mock.sendMail.mockClear(); - }); - + describe('EmailGateway.notify', () => { it('sends an email notification for MAX stock alert', async () => { expect.assertions(1); @@ -49,7 +50,7 @@ describe('EmailAlertNotification', () => { type: AlertNotificationTypes.MAX, }; - await alert_notification.notify(data); + await email_gateway.notify(data); expect(transport_mock.sendMail).toHaveBeenCalledWith({ from: 'test@server.com', @@ -76,7 +77,7 @@ describe('EmailAlertNotification', () => { type: AlertNotificationTypes.MIN, }; - await alert_notification.notify(data); + await email_gateway.notify(data); expect(transport_mock.sendMail).toHaveBeenCalledWith({ from: 'test@server.com', @@ -87,4 +88,23 @@ describe('EmailAlertNotification', () => { }); }); }); + + describe('EmailGateway.notify', () => { + it('sends an email for code confirmation', async () => { + expect.assertions(1); + + const email = faker.internet.email(); + const code = faker.string.alphanumeric() + + await email_gateway.sendCode({ email, code }); + + expect(transport_mock.sendMail).toHaveBeenCalledWith({ + from: 'test@server.com', + to: email, + subject: 'Código de confirmação', + text: `Segue o código de confirmação ${code}. Acesse o link http://localhost:5000/pages/confirm-code?email=${email}.`, + html: `

Segue o código de confirmação ${code}.


Acesse o link http://localhost:5000/pages/confirm-code?email=${email}.

`, + }); + }); + }); }); diff --git a/src/b3_stock_alerts/PgUserRepository.ts b/src/b3_stock_alerts/PgUserRepository.ts index cd9307f..3d4ccc8 100644 --- a/src/b3_stock_alerts/PgUserRepository.ts +++ b/src/b3_stock_alerts/PgUserRepository.ts @@ -1,6 +1,7 @@ import Postgres from '@shared/Postgres'; import { Client } from 'pg'; import { User } from './User'; +import { UserConfirmationCode } from './UserConfirmationCode'; import UserRepository from './UserRepository'; export default class PgUserRepository implements UserRepository { @@ -75,4 +76,11 @@ export default class PgUserRepository implements UserRepository { return result.rows[0]; } + + async createConfirmationCode(confirmation_code: UserConfirmationCode): Promise { + await this.client.query( + 'INSERT INTO user_confirmation_codes (id, user_id, code) VALUES ($1, $2, $3)', + [confirmation_code.id, confirmation_code.user_id, confirmation_code.code], + ); + } } diff --git a/src/b3_stock_alerts/PgUserRepository.unit.test.ts b/src/b3_stock_alerts/PgUserRepository.unit.test.ts index 8fa4b0f..1fa0289 100644 --- a/src/b3_stock_alerts/PgUserRepository.unit.test.ts +++ b/src/b3_stock_alerts/PgUserRepository.unit.test.ts @@ -3,6 +3,7 @@ import { faker } from '@faker-js/faker/locale/pt_BR'; import Postgres from '@shared/Postgres'; import PgUserRepository from './PgUserRepository'; import { User } from './User'; +import { UserConfirmationCode } from './UserConfirmationCode'; const get_client_spy = jest.spyOn(Postgres, 'getClient'); @@ -168,4 +169,22 @@ describe('PgUserRepository', () => { expect(result).toBeNull(); }); }); + + describe('PgUserRepository.createConfirmationCode', () => { + it('inserts a new confirmation code', async () => { + expect.assertions(1); + + const confirmation_code: UserConfirmationCode = { + id: faker.string.uuid(), + user_id: faker.string.uuid(), + code: faker.string.numeric(4), + }; + + await repository.createConfirmationCode(confirmation_code); + + expect(query_mock).toHaveBeenCalledWith('INSERT INTO user_confirmation_codes (id, user_id, code) VALUES ($1, $2, $3)', [ + confirmation_code.id, confirmation_code.user_id, confirmation_code.code, + ]); + }); + }); }); diff --git a/src/b3_stock_alerts/StockEventHandler.unit.test.ts b/src/b3_stock_alerts/StockEventHandler.unit.test.ts index 49350f3..7e86c9c 100644 --- a/src/b3_stock_alerts/StockEventHandler.unit.test.ts +++ b/src/b3_stock_alerts/StockEventHandler.unit.test.ts @@ -15,6 +15,7 @@ describe("StockEventHandler's unit tests", () => { updateUser: jest.fn(), getUsers: jest.fn(), getUserByEmail: jest.fn(), + createConfirmationCode: jest.fn(), }; const handler = new StockEventHandler(alert_notification_mock, user_repository_mock); diff --git a/src/b3_stock_alerts/UserConfirmationCode.ts b/src/b3_stock_alerts/UserConfirmationCode.ts new file mode 100644 index 0000000..664e8b7 --- /dev/null +++ b/src/b3_stock_alerts/UserConfirmationCode.ts @@ -0,0 +1,5 @@ +export type UserConfirmationCode = { + id: string; + user_id: string; + code: string; +} diff --git a/src/b3_stock_alerts/UserRepository.ts b/src/b3_stock_alerts/UserRepository.ts index 5811d2c..928ff2b 100644 --- a/src/b3_stock_alerts/UserRepository.ts +++ b/src/b3_stock_alerts/UserRepository.ts @@ -1,4 +1,5 @@ import { User } from './User'; +import { UserConfirmationCode } from './UserConfirmationCode'; export default interface UserRepository { getUsers(): Promise>; @@ -7,4 +8,5 @@ export default interface UserRepository { updateUser(user: User): Promise; deleteUser(user_id: string): Promise; getUserByEmail(email: string): Promise; + createConfirmationCode(confirmation_code: UserConfirmationCode): Promise; } diff --git a/src/b3_stock_alerts/UserService.unit.test.ts b/src/b3_stock_alerts/UserService.unit.test.ts index 0a0dc87..be54693 100644 --- a/src/b3_stock_alerts/UserService.unit.test.ts +++ b/src/b3_stock_alerts/UserService.unit.test.ts @@ -12,6 +12,7 @@ describe("UserService's unit tests", () => { updateUser: jest.fn(), getUsers: jest.fn(), getUserByEmail: jest.fn(), + createConfirmationCode: jest.fn(), }; const encryptor_mock = { diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 462771d..a4b3b79 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -2,7 +2,7 @@ import AlertService from '@b3_stock_alerts/AlertService'; import AuthService from '@b3_stock_alerts/AuthService'; import BcryptEncryptor from '@b3_stock_alerts/BcryptEncryptor'; import CoreAuthenticator from '@b3_stock_alerts/CoreAuthenticator'; -import EmailAlertNotification from '@b3_stock_alerts/EmailAlertNotification'; +import EmailGateway from '@b3_stock_alerts/EmailGateway'; import PgAlertRepository from '@b3_stock_alerts/PgAlertRepository'; import PgUserRepository from '@b3_stock_alerts/PgUserRepository'; import ScheduleHandler from '@b3_stock_alerts/ScheduleHandler'; @@ -13,8 +13,8 @@ import WSStockSearcher from '@b3_stock_alerts/WSStockSearcher'; const alert_repository = new PgAlertRepository(); const user_repository = new PgUserRepository(); const stock_searcher = new WSStockSearcher(); -const alert_notification = new EmailAlertNotification(); -const stock_event_handler = new StockEventHandler(alert_notification, user_repository); +const email_gateway = new EmailGateway(); +const stock_event_handler = new StockEventHandler(email_gateway, user_repository); export const schedule_handler = new ScheduleHandler(alert_repository, stock_searcher); schedule_handler.on('stock_event', stock_event_handler.handle.bind(stock_event_handler)); schedule_handler.on('uncaughtException', (error) => { @@ -24,4 +24,9 @@ const encryptor = new BcryptEncryptor(); const authenticator = new CoreAuthenticator(); export const user_service = new UserService(user_repository, encryptor); export const alert_service = new AlertService(alert_repository, user_repository); -export const auth_service = new AuthService(user_repository, encryptor, authenticator); +export const auth_service = new AuthService( + user_repository, + encryptor, + authenticator, + email_gateway, +);