From 71344f347b1790f40e4fa01888b17bd427f28418 Mon Sep 17 00:00:00 2001 From: avaitonis Date: Fri, 24 Feb 2023 16:38:32 +0000 Subject: [PATCH] feat: new endpoint GET `/exposure-period` (#39) --- .../dto/exposure-period.dto.ts | 6 + .../dto/get-exposure-period-query.dto.ts | 17 +++ .../exposure-period.controller.test.ts | 48 +++++++ .../exposure-period.controller.ts | 45 ++++++ .../exposure-period/exposure-period.module.ts | 10 ++ .../exposure-period.service.ts | 31 +++++ src/modules/mdm.module.ts | 5 +- test/constants/constants.spi.api-test.ts | 8 +- .../exposure-period.api-test.ts | 130 ++++++++++++++++++ 9 files changed, 294 insertions(+), 6 deletions(-) create mode 100644 src/modules/exposure-period/dto/exposure-period.dto.ts create mode 100644 src/modules/exposure-period/dto/get-exposure-period-query.dto.ts create mode 100644 src/modules/exposure-period/exposure-period.controller.test.ts create mode 100644 src/modules/exposure-period/exposure-period.controller.ts create mode 100644 src/modules/exposure-period/exposure-period.module.ts create mode 100644 src/modules/exposure-period/exposure-period.service.ts create mode 100644 test/exposure-period/exposure-period.api-test.ts diff --git a/src/modules/exposure-period/dto/exposure-period.dto.ts b/src/modules/exposure-period/dto/exposure-period.dto.ts new file mode 100644 index 00000000..d1d72c5e --- /dev/null +++ b/src/modules/exposure-period/dto/exposure-period.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ExposurePeriodDto { + @ApiProperty({ example: 12, description: 'Exposure in months' }) + public exposurePeriod: number; +} diff --git a/src/modules/exposure-period/dto/get-exposure-period-query.dto.ts b/src/modules/exposure-period/dto/get-exposure-period-query.dto.ts new file mode 100644 index 00000000..6edf475e --- /dev/null +++ b/src/modules/exposure-period/dto/get-exposure-period-query.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDate, IsString, Matches } from 'class-validator'; + +export class GetExposurePeriodQueryDto { + @IsDate() + @ApiProperty({ example: '2017-07-04' }) + public startdate: Date; + + @IsDate() + @ApiProperty({ example: '2018-07-04' }) + public enddate: Date; + + @IsString() + @ApiProperty({ example: 'EW', description: 'Two products are accepted: EW and BS' }) + @Matches(/^(EW|BS)$/) + public productgroup: string; +} diff --git a/src/modules/exposure-period/exposure-period.controller.test.ts b/src/modules/exposure-period/exposure-period.controller.test.ts new file mode 100644 index 00000000..854ab321 --- /dev/null +++ b/src/modules/exposure-period/exposure-period.controller.test.ts @@ -0,0 +1,48 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import Chance from 'chance'; + +import { GetExposurePeriodQueryDto } from './dto/get-exposure-period-query.dto'; +import { ExposurePeriodController } from './exposure-period.controller'; +import { ExposurePeriodService } from './exposure-period.service'; + +const chance = new Chance(); + +describe('ConstantsController', () => { + let exposurePeriodController: ExposurePeriodController; + let exposurePeriodService: ExposurePeriodService; + + beforeAll(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [ExposurePeriodController], + providers: [ + ExposurePeriodService, + { + provide: ExposurePeriodService, + useValue: { + calculate: jest.fn().mockResolvedValue([ + { + exposurePeriod: chance.integer(), + }, + ]), + }, + }, + ], + }).compile(); + + exposurePeriodController = app.get(ExposurePeriodController); + exposurePeriodService = app.get(ExposurePeriodService); + }); + + it('should be defined', () => { + expect(exposurePeriodService).toBeDefined(); + }); + + describe('find()', () => { + it('should return all constants', () => { + const query = new GetExposurePeriodQueryDto(); + exposurePeriodController.find(query); + + expect(exposurePeriodService.calculate).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/modules/exposure-period/exposure-period.controller.ts b/src/modules/exposure-period/exposure-period.controller.ts new file mode 100644 index 00000000..2b180c85 --- /dev/null +++ b/src/modules/exposure-period/exposure-period.controller.ts @@ -0,0 +1,45 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; + +import { ExposurePeriodDto } from './dto/exposure-period.dto'; +import { GetExposurePeriodQueryDto } from './dto/get-exposure-period-query.dto'; +import { ExposurePeriodService } from './exposure-period.service'; + +@ApiBearerAuth() +@ApiTags('exposure-period') +@Controller('exposure-period') +export class ExposurePeriodController { + constructor(private readonly exposurePeriodService: ExposurePeriodService) {} + + @Get() + @ApiOperation({ summary: 'Calculate exposure period in months.' }) + @ApiResponse({ + status: 200, + description: 'Calculated exposure period', + type: ExposurePeriodDto, + }) + @ApiParam({ + name: 'startdate', + required: false, + type: 'date', + description: 'Guarantee commencement date for a facility', + example: '2017-07-04', + }) + @ApiParam({ + name: 'enddate', + type: 'date', + required: false, + description: 'Guarantee expiry date for a facility', + example: '2018-07-04', + }) + @ApiParam({ + name: 'productgroup', + type: 'string', + required: false, + description: 'Facility type. It can be EW or BS', + example: 'EW', + }) + find(@Query() query: GetExposurePeriodQueryDto): Promise { + return this.exposurePeriodService.calculate(query.startdate, query.enddate, query.productgroup); + } +} diff --git a/src/modules/exposure-period/exposure-period.module.ts b/src/modules/exposure-period/exposure-period.module.ts new file mode 100644 index 00000000..8edbf6f6 --- /dev/null +++ b/src/modules/exposure-period/exposure-period.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { ExposurePeriodController } from './exposure-period.controller'; +import { ExposurePeriodService } from './exposure-period.service'; + +@Module({ + controllers: [ExposurePeriodController], + providers: [ExposurePeriodService], +}) +export class ExposurePeriodModule {} diff --git a/src/modules/exposure-period/exposure-period.service.ts b/src/modules/exposure-period/exposure-period.service.ts new file mode 100644 index 00000000..b3e04d90 --- /dev/null +++ b/src/modules/exposure-period/exposure-period.service.ts @@ -0,0 +1,31 @@ +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { ExposurePeriodDto } from './dto/exposure-period.dto'; + +@Injectable() +export class ExposurePeriodService { + private readonly logger = new Logger(); + + constructor( + @InjectDataSource('mssql-mdm') + private readonly mdmDataSource: DataSource, + ) {} + + async calculate(startDate: Date, endDate: Date, productGroup: string): Promise { + try { + // TODO: SP USP_MDM_READ_EXPOSURE_PERIOD is not using data/tables from DB. Calculation could be moved to Javascript. + const spResults = await this.mdmDataSource.query('USP_MDM_READ_EXPOSURE_PERIOD @0, @1, @2', [startDate, endDate, productGroup]); + + if (!spResults || !spResults[0] || typeof spResults[0].EXPOSURE_PERIOD === 'undefined') { + throw new InternalServerErrorException('No exposure period result from USP_MDM_READ_EXPOSURE_PERIOD'); + } + + return { exposurePeriod: spResults[0].EXPOSURE_PERIOD }; + } catch (err) { + this.logger.error(err); + throw new InternalServerErrorException(); + } + } +} diff --git a/src/modules/mdm.module.ts b/src/modules/mdm.module.ts index ed7030e7..c7218f06 100644 --- a/src/modules/mdm.module.ts +++ b/src/modules/mdm.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { ConstantsModule } from '@ukef/module/constants/constants.module'; +import { ExposurePeriodModule } from '@ukef/module/exposure-period/exposure-period.module'; import { HealthcheckModule } from '@ukef/module/healthcheck/healthcheck.module'; import { InterestRatesModule } from '@ukef/module/interest-rates/interest-rates.module'; import { MarketsModule } from '@ukef/module/markets/markets.module'; @@ -7,7 +8,7 @@ import { NumbersModule } from '@ukef/module/numbers/numbers.module'; import { SectorIndustriesModule } from '@ukef/module/sector-industries/sector-industries.module'; @Module({ - imports: [ConstantsModule, HealthcheckModule, InterestRatesModule, MarketsModule, NumbersModule, SectorIndustriesModule], - exports: [ConstantsModule, HealthcheckModule, InterestRatesModule, MarketsModule, NumbersModule, SectorIndustriesModule], + imports: [ConstantsModule, ExposurePeriodModule, HealthcheckModule, InterestRatesModule, MarketsModule, NumbersModule, SectorIndustriesModule], + exports: [ConstantsModule, ExposurePeriodModule, HealthcheckModule, InterestRatesModule, MarketsModule, NumbersModule, SectorIndustriesModule], }) export class MdmModule {} diff --git a/test/constants/constants.spi.api-test.ts b/test/constants/constants.spi.api-test.ts index 33bc5ea4..1b5e692f 100644 --- a/test/constants/constants.spi.api-test.ts +++ b/test/constants/constants.spi.api-test.ts @@ -81,20 +81,20 @@ describe('Constants SPI', () => { expect(body.message).toContain('category must match /^[a-zA-Z ]{1,20}$/ regular expression'); }); + // category=null is accepted as correct text input. it(`GET /constants/spi?oecdRiskCategory=null&category=null`, async () => { - const { status, body } = await api.get('/constants/spi?oecdRiskCategory=aaa&category=Some long not existing category;yes'); + const { status, body } = await api.get('/constants/spi?oecdRiskCategory=null&category=null'); expect(status).toBe(400); expect(body.message).toContain('oecdRiskCategory must not be greater than 7'); expect(body.message).toContain('oecdRiskCategory must be an integer number'); - expect(body.message).toContain('category must match /^[a-zA-Z ]{1,20}$/ regular expression'); }); + // category=undefined is accepted as correct text input. it(`GET /constants/spi?oecdRiskCategory=undefined&category=undefined`, async () => { - const { status, body } = await api.get('/constants/spi?oecdRiskCategory=aaa&category=Some long not existing category;yes'); + const { status, body } = await api.get('/constants/spi?oecdRiskCategory=undefined&category=undefined'); expect(status).toBe(400); expect(body.message).toContain('oecdRiskCategory must not be greater than 7'); expect(body.message).toContain('oecdRiskCategory must be an integer number'); - expect(body.message).toContain('category must match /^[a-zA-Z ]{1,20}$/ regular expression'); }); afterAll(async () => { diff --git a/test/exposure-period/exposure-period.api-test.ts b/test/exposure-period/exposure-period.api-test.ts new file mode 100644 index 00000000..b254212a --- /dev/null +++ b/test/exposure-period/exposure-period.api-test.ts @@ -0,0 +1,130 @@ +import { INestApplication } from '@nestjs/common'; + +import { Api } from '../api'; +import { CreateApp } from '../createApp'; + +describe('Exposure period', () => { + let app: INestApplication; + let api: Api; + + beforeAll(async () => { + app = await new CreateApp().init(); + api = new Api(app.getHttpServer()); + }); + + it('GET /exposure-period?startdate=2017-07-04&enddate=2018-07-04&productgroup=EW', async () => { + const { status, body } = await api.get('/exposure-period?startdate=2017-07-04&enddate=2018-07-04&productgroup=EW'); + expect(status).toBe(200); + expect(body.exposurePeriod).toBe(12); + }); + + it('GET /exposure-period?startdate=2017-07-04&enddate=2018-07-05&productgroup=EW', async () => { + const { status, body } = await api.get('/exposure-period?startdate=2017-07-04&enddate=2018-07-05&productgroup=EW'); + expect(status).toBe(200); + expect(body.exposurePeriod).toBe(13); + }); + + it('GET /exposure-period?startdate=2017-07-04&enddate=2018-07-04&productgroup=BS', async () => { + const { status, body } = await api.get('/exposure-period?startdate=2017-07-04&enddate=2018-07-04&productgroup=BS'); + expect(status).toBe(200); + expect(body.exposurePeriod).toBe(13); + }); + + it('GET /exposure-period?startdate=2017-07-04&enddate=2018-07-05&productgroup=BS', async () => { + const { status, body } = await api.get('/exposure-period?startdate=2017-07-04&enddate=2018-07-05&productgroup=BS'); + expect(status).toBe(200); + expect(body.exposurePeriod).toBe(13); + }); + + /** + * Exposure period logic depends on: + * * product group + * * if start date is end of month + * * if end date is end of month + * * if start and end month day matches. For examples 5th March and 5th April is same day since the start of the relevant month. + * Tests have data to test these edge cases. + * EOM = date is end of month. + * DOM = day of month - the actual number of days since the start of the relevant month. + */ + + // EW Start is EOM + it('GET /exposure-period?startdate=2017-03-31&enddate=2017-04-01&productgroup=EW', async () => { + const { status, body } = await api.get('/exposure-period?startdate=2017-03-31&enddate=2017-04-01&productgroup=EW'); + expect(status).toBe(200); + expect(body.exposurePeriod).toBe(1); + }); + + // BS Start is EOM + it('GET /exposure-period?startdate=2017-03-31&enddate=2017-04-29&productgroup=BS', async () => { + const { status, body } = await api.get('/exposure-period?startdate=2017-03-31&enddate=2017-04-29&productgroup=BS'); + expect(status).toBe(200); + expect(body.exposurePeriod).toBe(1); + }); + + // EW Start is EOM, end is EOM + it('GET /exposure-period?startdate=2017-03-31&enddate=2017-04-30&productgroup=EW', async () => { + const { status, body } = await api.get('/exposure-period?startdate=2017-03-31&enddate=2017-04-30&productgroup=EW'); + expect(status).toBe(200); + expect(body.exposurePeriod).toBe(1); + }); + + // BS Start is EOM, end is EOM, +1 for exposure + it('GET /exposure-period?startdate=2017-03-31&enddate=2017-04-30&productgroup=BS', async () => { + const { status, body } = await api.get('/exposure-period?startdate=2017-03-31&enddate=2017-04-30&productgroup=BS'); + expect(status).toBe(200); + expect(body.exposurePeriod).toBe(2); + }); + + // EW Start DOM = End DOM + it('GET /exposure-period?startdate=2017-03-05&enddate=2017-04-05&productgroup=EW', async () => { + const { status, body } = await api.get('/exposure-period?startdate=2017-03-05&enddate=2017-04-05&productgroup=EW'); + expect(status).toBe(200); + expect(body.exposurePeriod).toBe(1); + }); + + // BS Start DOM = End DOM, +1 for exposure + it('GET /exposure-period?startdate=2017-03-05&enddate=2017-04-05&productgroup=BS', async () => { + const { status, body } = await api.get('/exposure-period?startdate=2017-03-05&enddate=2017-04-05&productgroup=BS'); + expect(status).toBe(200); + expect(body.exposurePeriod).toBe(2); + }); + + // Input error handling checks + + it('GET /exposure-period', async () => { + const { status, body } = await api.get('/exposure-period'); + expect(status).toBe(400); + expect(body.message).toContain('startdate must be a Date instance'); + expect(body.message).toContain('enddate must be a Date instance'); + expect(body.message).toContain('productgroup must match /^(EW|BS)$/ regular expression'); + expect(body.message).toContain('productgroup must be a string'); + }); + + it('GET /exposure-period?startdate=2017-01-32&enddate=2017-02-32&productgroup=test', async () => { + const { status, body } = await api.get('/exposure-period?startdate=2017-01-32&enddate=2017-02-32&productgroup=test'); + expect(status).toBe(400); + expect(body.message).toContain('startdate must be a Date instance'); + expect(body.message).toContain('enddate must be a Date instance'); + expect(body.message).toContain('productgroup must match /^(EW|BS)$/ regular expression'); + }); + + it('GET /exposure-period?startdate=null&enddate=null&productgroup=null', async () => { + const { status, body } = await api.get('/exposure-period?startdate=null&enddate=null&productgroup=null'); + expect(status).toBe(400); + expect(body.message).toContain('startdate must be a Date instance'); + expect(body.message).toContain('enddate must be a Date instance'); + expect(body.message).toContain('productgroup must match /^(EW|BS)$/ regular expression'); + }); + + it('GET /exposure-period?startdate=undefined&enddate=undefined&productgroup=undefined', async () => { + const { status, body } = await api.get('/exposure-period?startdate=undefined&enddate=undefined&productgroup=undefined'); + expect(status).toBe(400); + expect(body.message).toContain('startdate must be a Date instance'); + expect(body.message).toContain('enddate must be a Date instance'); + expect(body.message).toContain('productgroup must match /^(EW|BS)$/ regular expression'); + }); + + afterAll(async () => { + await app.close(); + }); +});