From 01651095fb02a3df284b63a0a7b80fea81f6536d Mon Sep 17 00:00:00 2001 From: Guillaume Ongenae Date: Tue, 8 Jun 2021 16:54:58 +0200 Subject: [PATCH] feat(hooks.service): handle aggregator_link_required --- .../controllers/hooks.controller.spec.ts | 9 ++- src/hooks/dto/aggregator-link-required.dto.ts | 13 ++++ src/hooks/dto/event.dto.ts | 5 +- src/hooks/services/hooks.service.spec.ts | 78 +++++++++++++++++-- src/hooks/services/hooks.service.ts | 69 +++++++++++++++- 5 files changed, 160 insertions(+), 14 deletions(-) create mode 100644 src/hooks/dto/aggregator-link-required.dto.ts diff --git a/src/hooks/controllers/hooks.controller.spec.ts b/src/hooks/controllers/hooks.controller.spec.ts index 144b13cc..6aea1e6d 100644 --- a/src/hooks/controllers/hooks.controller.spec.ts +++ b/src/hooks/controllers/hooks.controller.spec.ts @@ -1,4 +1,5 @@ import { EventName } from '@algoan/rest'; +import { ContextIdFactory } from '@nestjs/core'; import { Test, TestingModule } from '@nestjs/testing'; import { AggregatorModule } from '../../aggregator/aggregator.module'; @@ -14,14 +15,18 @@ describe('Hooks Controller', () => { let hooksService: HooksService; beforeEach(async () => { + // To mock scoped DI + const contextId = ContextIdFactory.create(); + jest.spyOn(ContextIdFactory, 'getByRequest').mockImplementation(() => contextId); + const module: TestingModule = await Test.createTestingModule({ imports: [AppModule, AggregatorModule, AlgoanModule, ConfigModule], providers: [HooksService], controllers: [HooksController], }).compile(); - controller = module.get(HooksController); - hooksService = module.get(HooksService); + controller = await module.resolve(HooksController, contextId); + hooksService = await module.resolve(HooksService, contextId); }); it('should be defined', () => { diff --git a/src/hooks/dto/aggregator-link-required.dto.ts b/src/hooks/dto/aggregator-link-required.dto.ts new file mode 100644 index 00000000..9cd4aa8b --- /dev/null +++ b/src/hooks/dto/aggregator-link-required.dto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +/** + * Payload for event `aggregator_link_required` + */ +export class AggregatorLinkRequiredDTO { + /** + * Customer identifier + */ + @IsString() + @IsNotEmpty() + public customerId: string; +} diff --git a/src/hooks/dto/event.dto.ts b/src/hooks/dto/event.dto.ts index bed4ec45..7052b182 100644 --- a/src/hooks/dto/event.dto.ts +++ b/src/hooks/dto/event.dto.ts @@ -1,15 +1,16 @@ import { Type } from 'class-transformer'; import { Allow, IsInt, IsNotEmpty, IsOptional, ValidateNested } from 'class-validator'; +import { AggregatorLinkRequiredDTO } from './aggregator-link-required.dto'; +import { BankreaderRequiredDTO } from './bankreader-required.dto'; import { ServiceAccountCreatedDTO } from './service-account-created.dto'; import { ServiceAccountDeletedDTO } from './service-account-deleted.dto'; import { SubscriptionDTO } from './subscription.dto'; -import { BankreaderRequiredDTO } from './bankreader-required.dto'; /** * Events payload types */ -type Events = ServiceAccountCreatedDTO | ServiceAccountDeletedDTO | BankreaderRequiredDTO; +type Events = ServiceAccountCreatedDTO | ServiceAccountDeletedDTO | BankreaderRequiredDTO | AggregatorLinkRequiredDTO; /** * Event diff --git a/src/hooks/services/hooks.service.spec.ts b/src/hooks/services/hooks.service.spec.ts index 816a4f0d..82236b06 100644 --- a/src/hooks/services/hooks.service.spec.ts +++ b/src/hooks/services/hooks.service.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import { AccountType, Algoan, @@ -16,17 +17,24 @@ import { SubscriptionEvent, UsageType, } from '@algoan/rest'; -/* eslint-disable max-lines */ +import { ContextIdFactory } from '@nestjs/core'; import { Test, TestingModule } from '@nestjs/testing'; +import { config } from 'node-config-ts'; import { AggregatorModule } from '../../aggregator/aggregator.module'; import { mockAccount, mockPersonalInformation, mockTransaction } from '../../aggregator/interfaces/bridge-mock'; import { AggregatorService } from '../../aggregator/services/aggregator.service'; import { mapBridgeAccount, mapBridgeTransactions } from '../../aggregator/services/bridge/bridge.utils'; import { AlgoanModule } from '../../algoan/algoan.module'; +import { customerMock } from '../../algoan/dto/customer.objects.mock'; +import { AlgoanAnalysisService } from '../../algoan/services/algoan-analysis.service'; +import { AlgoanCustomerService } from '../../algoan/services/algoan-customer.service'; +import { AlgoanHttpService } from '../../algoan/services/algoan-http.service'; import { AlgoanService } from '../../algoan/services/algoan.service'; import { AppModule } from '../../app.module'; +import { CONFIG } from '../../config/config.module'; import { ConfigModule } from '../../config/config.module'; +import { AggregatorLinkRequiredDTO } from '../dto/aggregator-link-required.dto'; import { BankreaderLinkRequiredDTO } from '../dto/bandreader-link-required.dto'; import { EventDTO } from '../dto/event.dto'; import { HooksService } from './hooks.service'; @@ -35,6 +43,11 @@ describe('HooksService', () => { let hooksService: HooksService; let aggregatorService: AggregatorService; let algoanService: AlgoanService; + let algoanHttpService: AlgoanHttpService; + let algoanCustomerService: AlgoanCustomerService; + let algoanAnalysisService: AlgoanAnalysisService; + let serviceAccount: ServiceAccount; + const mockEvent = { subscription: { id: 'mockEventSubId', @@ -87,16 +100,34 @@ describe('HooksService', () => { ); beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [AppModule, AggregatorModule, AlgoanModule, ConfigModule], - providers: [HooksService], + // To mock scoped DI + const contextId = ContextIdFactory.create(); + jest.spyOn(ContextIdFactory, 'getByRequest').mockImplementation(() => contextId); + + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [AppModule, AlgoanModule, AggregatorModule, ConfigModule], + providers: [ + HooksService, + { + provide: CONFIG, + useValue: config, + }, + { + provide: ServiceAccount, + useValue: mockServiceAccount, + }, + ], }).compile(); jest.spyOn(Algoan.prototype, 'initRestHooks').mockResolvedValue(); - hooksService = module.get(HooksService); - aggregatorService = module.get(AggregatorService); - algoanService = module.get(AlgoanService); + hooksService = await moduleRef.resolve(HooksService, contextId); + aggregatorService = await moduleRef.resolve(AggregatorService, contextId); + algoanService = await moduleRef.resolve(AlgoanService, contextId); + algoanHttpService = await moduleRef.resolve(AlgoanHttpService, contextId); + algoanCustomerService = await moduleRef.resolve(AlgoanCustomerService, contextId); + algoanAnalysisService = await moduleRef.resolve(AlgoanAnalysisService, contextId); + serviceAccount = await moduleRef.resolve(ServiceAccount, contextId); await algoanService.onModuleInit(); }); @@ -116,6 +147,15 @@ describe('HooksService', () => { .mockResolvedValue(({} as unknown) as ISubscriptionEvent & { id: string }); jest.spyOn(algoanService.algoanClient, 'getServiceAccountBySubscriptionId').mockReturnValue(mockServiceAccount); }); + + it('handles aggregator link required', async () => { + mockEvent.subscription.eventName = EventName.AGGREGATOR_LINK_REQUIRED; + const spy = jest.spyOn(hooksService, 'handleAggregatorLinkRequired').mockResolvedValue(); + await hooksService.handleWebhook(mockEvent as EventDTO, 'mockSignature'); + + expect(spy).toBeCalledWith(mockServiceAccount, mockEvent.payload); + }); + it('handles bankreader link required', async () => { mockEvent.subscription.eventName = EventName.BANKREADER_LINK_REQUIRED; const spy = jest.spyOn(hooksService, 'handleBankreaderLinkRequiredEvent').mockResolvedValue(); @@ -133,6 +173,30 @@ describe('HooksService', () => { }); }); + it('generates a redirect url on aggregator link required', async () => { + const mockEventPayload: AggregatorLinkRequiredDTO = { customerId: customerMock.id }; + const algoanAuthenticateSpy = jest.spyOn(algoanHttpService, 'authenticate').mockReturnValue(); + const getCustomerSpy = jest.spyOn(algoanCustomerService, 'getCustomerById').mockResolvedValue(customerMock); + const updateCustomerSpy = jest.spyOn(algoanCustomerService, 'updateCustomer').mockResolvedValue(customerMock); + const aggregatorSpy = jest + .spyOn(aggregatorService, 'generateRedirectUrl') + .mockReturnValue(Promise.resolve('mockRedirectUrl')); + mockServiceAccount.config = mockServiceAccountConfig; + await hooksService.handleAggregatorLinkRequired(mockServiceAccount, mockEventPayload); + + expect(algoanAuthenticateSpy).toBeCalledWith(mockServiceAccount.clientId, mockServiceAccount.clientSecret); + expect(getCustomerSpy).toBeCalledWith(mockEventPayload.customerId); + expect(aggregatorSpy).toBeCalledWith( + customerMock.id, + customerMock.aggregationDetails?.callbackUrl, + customerMock.personalDetails?.contact?.email, + mockServiceAccountConfig, + ); + expect(updateCustomerSpy).toBeCalledWith(customerMock.id, { + aggregationDetails: { aggregatorName: 'BRIDGE', redirectUrl: 'mockRedirectUrl' }, + }); + }); + it('generates a redirect url on bankreader link required', async () => { const serviceAccountSpy = jest .spyOn(mockServiceAccount, 'getBanksUserById') diff --git a/src/hooks/services/hooks.service.ts b/src/hooks/services/hooks.service.ts index 2a0bd463..2dcd1561 100644 --- a/src/hooks/services/hooks.service.ts +++ b/src/hooks/services/hooks.service.ts @@ -10,11 +10,11 @@ import { Subscription, SubscriptionEvent, } from '@algoan/rest'; -import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common'; import * as delay from 'delay'; import { isEmpty } from 'lodash'; import * as moment from 'moment'; -import { config } from 'node-config-ts'; +import { config, Config } from 'node-config-ts'; import { AuthenticationResponse, @@ -25,7 +25,14 @@ import { import { AggregatorService } from '../../aggregator/services/aggregator.service'; import { ClientConfig } from '../../aggregator/services/bridge/bridge.client'; import { mapBridgeAccount, mapBridgeTransactions } from '../../aggregator/services/bridge/bridge.utils'; +import { AggregationDetailsAggregatorName, AggregationDetailsMode } from '../../algoan/dto/customer.enums'; +import { AggregationDetails, Customer } from '../../algoan/dto/customer.objects'; +import { AlgoanAnalysisService } from '../../algoan/services/algoan-analysis.service'; +import { AlgoanCustomerService } from '../../algoan/services/algoan-customer.service'; +import { AlgoanHttpService } from '../../algoan/services/algoan-http.service'; import { AlgoanService } from '../../algoan/services/algoan.service'; +import { CONFIG } from '../../config/config.module'; +import { AggregatorLinkRequiredDTO } from '../dto/aggregator-link-required.dto'; import { BankreaderLinkRequiredDTO } from '../dto/bandreader-link-required.dto'; import { BankreaderRequiredDTO } from '../dto/bankreader-required.dto'; import { EventDTO } from '../dto/event.dto'; @@ -40,7 +47,14 @@ export class HooksService { */ private readonly logger: Logger = new Logger(HooksService.name); - constructor(private readonly algoanService: AlgoanService, private readonly aggregator: AggregatorService) {} + constructor( + @Inject(CONFIG) private readonly _config: Config, + private readonly algoanHttpService: AlgoanHttpService, + private readonly algoanCustomerService: AlgoanCustomerService, + private readonly algoanAnalysisService: AlgoanAnalysisService, + private readonly algoanService: AlgoanService, + private readonly aggregator: AggregatorService, + ) {} /** * Handle Algoan webhooks @@ -90,6 +104,10 @@ export class HooksService { try { switch (event.subscription.eventName) { + case EventName.AGGREGATOR_LINK_REQUIRED: + await this.handleAggregatorLinkRequired(serviceAccount, event.payload as AggregatorLinkRequiredDTO); + break; + case EventName.BANKREADER_LINK_REQUIRED: await this.handleBankreaderLinkRequiredEvent(serviceAccount, event.payload as BankreaderLinkRequiredDTO); break; @@ -116,6 +134,51 @@ export class HooksService { void se.update({ status: EventStatus.PROCESSED }); } + /** + * Handle the "aggregator_link_required" event + * Looks for a callback URL and generates a new redirect URL + * @param serviceAccount Concerned Algoan service account attached to the subscription + * @param payload Payload sent, containing the Banks User id + */ + public async handleAggregatorLinkRequired( + serviceAccount: ServiceAccount, + payload: AggregatorLinkRequiredDTO, + ): Promise { + // Authenticate to algoan + this.algoanHttpService.authenticate(serviceAccount.clientId, serviceAccount.clientSecret); + + // Get user information and client config + const customer: Customer = await this.algoanCustomerService.getCustomerById(payload.customerId); + this.logger.debug({ customer, serviceAccount }, `Found Customer with id ${customer.id}`); + + const aggregationDetails: AggregationDetails = { + aggregatorName: AggregationDetailsAggregatorName.BRIDGE, + }; + switch (customer.aggregationDetails?.mode) { + case AggregationDetailsMode.REDIRECT: + // Generates a redirect URL + aggregationDetails.redirectUrl = await this.aggregator.generateRedirectUrl( + customer.id, + customer.aggregationDetails?.callbackUrl, + customer.personalDetails?.contact?.email, + serviceAccount.config as ClientConfig, + ); + break; + + default: + throw new Error(`Invalid bank connection mode ${customer.aggregationDetails?.mode}`); + } + + // Update user with redirect link information and userId if provided + await this.algoanCustomerService.updateCustomer(payload.customerId, { + aggregationDetails, + }); + + this.logger.debug(`Updated Customer ${payload.customerId}`); + + return; + } + /** * Handle the "bankreader_link_required" event * Looks for a callback URL and generates a new redirect URL