From cf20031e80ce176359364a8501d06c89fb028770 Mon Sep 17 00:00:00 2001 From: Kamil Gajowy Date: Tue, 18 May 2021 13:50:03 +0200 Subject: [PATCH] feat(api): cost-surface: application logic --- .../__mocks__/adjust-cost-service-fake.ts | 15 ++ .../__mocks__/cost-surface-events-fake.ts | 20 +++ .../__mocks__/shapefile-converter-fake.ts | 12 ++ .../__mocks__/surface-cost.data.ts | 14 ++ .../adapters/cost-surface-api-events.ts | 13 ++ .../geoprocessing-cost-from-shapefile.ts | 13 ++ .../cost-surface/cost-surface-events.port.ts | 15 ++ .../cost-surface/cost-surface.facade.spec.ts | 141 ++++++++++++++++++ .../cost-surface/cost-surface.facade.ts | 50 ++++--- .../cost-surface/resolve-pu-with-cost.ts | 7 + .../modules/scenarios/scenarios.controller.ts | 4 +- api/src/modules/scenarios/scenarios.module.ts | 13 ++ 12 files changed, 292 insertions(+), 25 deletions(-) create mode 100644 api/src/modules/scenarios/cost-surface/__mocks__/adjust-cost-service-fake.ts create mode 100644 api/src/modules/scenarios/cost-surface/__mocks__/cost-surface-events-fake.ts create mode 100644 api/src/modules/scenarios/cost-surface/__mocks__/shapefile-converter-fake.ts create mode 100644 api/src/modules/scenarios/cost-surface/__mocks__/surface-cost.data.ts create mode 100644 api/src/modules/scenarios/cost-surface/adapters/cost-surface-api-events.ts create mode 100644 api/src/modules/scenarios/cost-surface/adapters/geoprocessing-cost-from-shapefile.ts create mode 100644 api/src/modules/scenarios/cost-surface/cost-surface-events.port.ts create mode 100644 api/src/modules/scenarios/cost-surface/cost-surface.facade.spec.ts create mode 100644 api/src/modules/scenarios/cost-surface/resolve-pu-with-cost.ts diff --git a/api/src/modules/scenarios/cost-surface/__mocks__/adjust-cost-service-fake.ts b/api/src/modules/scenarios/cost-surface/__mocks__/adjust-cost-service-fake.ts new file mode 100644 index 0000000000..f0ea4a751b --- /dev/null +++ b/api/src/modules/scenarios/cost-surface/__mocks__/adjust-cost-service-fake.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { AdjustCostSurface } from '../../../analysis/entry-points/adjust-cost-surface'; +import { CostSurfaceInputDto } from '../../../analysis/entry-points/adjust-cost-surface-input'; + +@Injectable() +export class AdjustCostServiceFake implements AdjustCostSurface { + mock = jest.fn(); + + async update( + scenarioId: string, + constraints: CostSurfaceInputDto, + ): Promise { + return this.mock(scenarioId, constraints); + } +} diff --git a/api/src/modules/scenarios/cost-surface/__mocks__/cost-surface-events-fake.ts b/api/src/modules/scenarios/cost-surface/__mocks__/cost-surface-events-fake.ts new file mode 100644 index 0000000000..0b8e98406b --- /dev/null +++ b/api/src/modules/scenarios/cost-surface/__mocks__/cost-surface-events-fake.ts @@ -0,0 +1,20 @@ +import { Injectable, Scope } from '@nestjs/common'; +import { + CostSurfaceEventsPort, + CostSurfaceState, +} from '../cost-surface-events.port'; + +@Injectable() +export class CostSurfaceEventsFake implements CostSurfaceEventsPort { + mock = jest.fn(); + events: [string, CostSurfaceState][] = []; + + async event( + scenarioId: string, + state: CostSurfaceState, + context?: Record | Error, + ): Promise { + this.events.push([scenarioId, state]); + return this.mock(scenarioId, state, context); + } +} diff --git a/api/src/modules/scenarios/cost-surface/__mocks__/shapefile-converter-fake.ts b/api/src/modules/scenarios/cost-surface/__mocks__/shapefile-converter-fake.ts new file mode 100644 index 0000000000..6f34b6cbee --- /dev/null +++ b/api/src/modules/scenarios/cost-surface/__mocks__/shapefile-converter-fake.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { ResolvePuWithCost } from '../resolve-pu-with-cost'; +import { CostSurfaceInputDto } from '../../../analysis/entry-points/adjust-cost-surface-input'; + +@Injectable() +export class ShapefileConverterFake implements ResolvePuWithCost { + mock = jest.fn(); + + async fromShapefile(file: Express.Multer.File): Promise { + return this.mock(file); + } +} diff --git a/api/src/modules/scenarios/cost-surface/__mocks__/surface-cost.data.ts b/api/src/modules/scenarios/cost-surface/__mocks__/surface-cost.data.ts new file mode 100644 index 0000000000..f08bf8934e --- /dev/null +++ b/api/src/modules/scenarios/cost-surface/__mocks__/surface-cost.data.ts @@ -0,0 +1,14 @@ +import { CostSurfaceInputDto } from '../../../analysis/entry-points/adjust-cost-surface-input'; + +export const getValidSurfaceCost = (): CostSurfaceInputDto => ({ + planningUnits: [ + { + id: 'pu-id-1', + cost: 300, + }, + { + id: 'pu-id-2', + cost: 2000, + }, + ], +}); diff --git a/api/src/modules/scenarios/cost-surface/adapters/cost-surface-api-events.ts b/api/src/modules/scenarios/cost-surface/adapters/cost-surface-api-events.ts new file mode 100644 index 0000000000..8b8a889b09 --- /dev/null +++ b/api/src/modules/scenarios/cost-surface/adapters/cost-surface-api-events.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { + CostSurfaceEventsPort, + CostSurfaceState, +} from '../cost-surface-events.port'; + +@Injectable() +export class CostSurfaceApiEvents implements CostSurfaceEventsPort { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async event(scenarioId: string, state: CostSurfaceState): Promise { + // + } +} diff --git a/api/src/modules/scenarios/cost-surface/adapters/geoprocessing-cost-from-shapefile.ts b/api/src/modules/scenarios/cost-surface/adapters/geoprocessing-cost-from-shapefile.ts new file mode 100644 index 0000000000..2c25810d53 --- /dev/null +++ b/api/src/modules/scenarios/cost-surface/adapters/geoprocessing-cost-from-shapefile.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { ResolvePuWithCost } from '../resolve-pu-with-cost'; +import { CostSurfaceInputDto } from '../../../analysis/entry-points/adjust-cost-surface-input'; + +@Injectable() +export class GeoprocessingCostFromShapefile implements ResolvePuWithCost { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async fromShapefile(file: Express.Multer.File): Promise { + return { + planningUnits: [], + }; + } +} diff --git a/api/src/modules/scenarios/cost-surface/cost-surface-events.port.ts b/api/src/modules/scenarios/cost-surface/cost-surface-events.port.ts new file mode 100644 index 0000000000..33545b06ca --- /dev/null +++ b/api/src/modules/scenarios/cost-surface/cost-surface-events.port.ts @@ -0,0 +1,15 @@ +export enum CostSurfaceState { + Submitted = 'submitted', + ShapefileConverted = 'shapefile-converted', + ShapefileConversionFailed = 'shapefile-conversion-failed', + CostUpdateFailed = 'cost-update-failed', + Finished = 'finished', +} + +export abstract class CostSurfaceEventsPort { + abstract event( + scenarioId: string, + state: CostSurfaceState, + context?: Record | Error, + ): Promise; +} diff --git a/api/src/modules/scenarios/cost-surface/cost-surface.facade.spec.ts b/api/src/modules/scenarios/cost-surface/cost-surface.facade.spec.ts new file mode 100644 index 0000000000..ed95d201e7 --- /dev/null +++ b/api/src/modules/scenarios/cost-surface/cost-surface.facade.spec.ts @@ -0,0 +1,141 @@ +import { CostSurfaceFacade } from './cost-surface.facade'; +import { Test } from '@nestjs/testing'; +import { Request } from 'express'; +import { AdjustCostSurface } from '../../analysis/entry-points/adjust-cost-surface'; +import { AdjustCostServiceFake } from './__mocks__/adjust-cost-service-fake'; +import { ResolvePuWithCost } from './resolve-pu-with-cost'; +import { ShapefileConverterFake } from './__mocks__/shapefile-converter-fake'; +import { CostSurfaceEventsPort } from './cost-surface-events.port'; +import { CostSurfaceEventsFake } from './__mocks__/cost-surface-events-fake'; +import { getValidSurfaceCost } from './__mocks__/surface-cost.data'; + +let sut: CostSurfaceFacade; + +let costService: AdjustCostServiceFake; +let fileConverter: ShapefileConverterFake; +let events: CostSurfaceEventsFake; + +beforeEach(async () => { + const sandbox = await Test.createTestingModule({ + providers: [ + { + provide: AdjustCostSurface, + useClass: AdjustCostServiceFake, + }, + { + provide: ResolvePuWithCost, + useClass: ShapefileConverterFake, + }, + { + provide: CostSurfaceEventsPort, + useClass: CostSurfaceEventsFake, + }, + CostSurfaceFacade, + ], + }).compile(); + + sut = sandbox.get(CostSurfaceFacade); + costService = sandbox.get(AdjustCostSurface); + fileConverter = sandbox.get(ResolvePuWithCost); + events = sandbox.get(CostSurfaceEventsPort); +}); + +const scenarioId = 'scenarioId'; +const request: Request = Object.freeze({ + file: ({ fakeFile: 1 } as unknown) as Express.Multer.File, +} as unknown) as Request; + +describe(`when file couldn't be converted`, () => { + beforeEach(async () => { + fileConverter.mock.mockRejectedValue(new Error('Not a shapefile.')); + + // Act + await sut.convert(scenarioId, request); + }); + + it(`should emit relevant events`, () => { + expect(events.events).toMatchInlineSnapshot(` + Array [ + Array [ + "scenarioId", + "submitted", + ], + Array [ + "scenarioId", + "shapefile-conversion-failed", + ], + ] + `); + }); +}); + +describe(`when file can be converted`, () => { + beforeEach(() => { + fileConverter.mock.mockResolvedValue(getValidSurfaceCost()); + }); + + describe(`when cost couldn't be adjusted`, () => { + beforeEach(async () => { + costService.mock.mockRejectedValue(new Error('SQL Error')); + + // Act + await sut.convert(scenarioId, request); + }); + + it(`should emit relevant events`, () => { + expect(events.events).toMatchInlineSnapshot(` + Array [ + Array [ + "scenarioId", + "submitted", + ], + Array [ + "scenarioId", + "shapefile-converted", + ], + Array [ + "scenarioId", + "cost-update-failed", + ], + ] + `); + }); + }); + + describe(`when cost can be adjusted`, () => { + beforeEach(async () => { + costService.mock.mockResolvedValue(undefined); + + // Act + await sut.convert(scenarioId, request); + }); + + it(`proxies file to port`, () => { + expect(fileConverter.mock.mock.calls[0][0]).toEqual(request.file); + }); + + it(`proxies port output to update service`, () => { + expect(costService.mock.mock.calls[0][0]).toEqual(scenarioId); + expect(costService.mock.mock.calls[0][1]).toEqual(getValidSurfaceCost()); + }); + + it(`emits valid events chain`, () => { + expect(events.events).toMatchInlineSnapshot(` + Array [ + Array [ + "scenarioId", + "submitted", + ], + Array [ + "scenarioId", + "shapefile-converted", + ], + Array [ + "scenarioId", + "finished", + ], + ] + `); + }); + }); +}); diff --git a/api/src/modules/scenarios/cost-surface/cost-surface.facade.ts b/api/src/modules/scenarios/cost-surface/cost-surface.facade.ts index 2c6f58b9e8..fbff2e678f 100644 --- a/api/src/modules/scenarios/cost-surface/cost-surface.facade.ts +++ b/api/src/modules/scenarios/cost-surface/cost-surface.facade.ts @@ -1,42 +1,44 @@ import { Injectable } from '@nestjs/common'; -import { Request, Response } from 'express'; +import { Request } from 'express'; import { AdjustCostSurface } from '../../analysis/entry-points/adjust-cost-surface'; -import { ProxyService } from '../../proxy/proxy.service'; +import { ResolvePuWithCost } from './resolve-pu-with-cost'; +import { + CostSurfaceEventsPort, + CostSurfaceState, +} from './cost-surface-events.port'; +import { CostSurfaceInputDto } from '../../analysis/entry-points/adjust-cost-surface-input'; @Injectable() export class CostSurfaceFacade { constructor( private readonly adjustCostSurfaceService: AdjustCostSurface, - private readonly proxyService: ProxyService, + private readonly shapefileConverter: ResolvePuWithCost, + private readonly events: CostSurfaceEventsPort, ) {} /** * non blocking - will do job in "background" - * - * @param scenarioId - * @param request */ - convert(scenarioId: string, request: Request) { - // TODO #0 Generate & Dispatch Api Event (some wrapping service for /dummy/"terminating" if already running) + async convert(scenarioId: string, request: Request) { + await this.events.event(scenarioId, CostSurfaceState.Submitted); + let costSurface: CostSurfaceInputDto; - // TODO #1 Call Proxy Service to get Planning Units and their surface cost - // this.proxyService.... - modify this to send back data, not act on Response + try { + costSurface = await this.shapefileConverter.fromShapefile(request.file); + } catch (error) { + await this.events.event( + scenarioId, + CostSurfaceState.ShapefileConversionFailed, + ); + return; + } - // TODO #2 Call Analysis-module with scenario id & output from the above + await this.events.event(scenarioId, CostSurfaceState.ShapefileConverted); this.adjustCostSurfaceService - .update(scenarioId, { - planningUnits: [ - { - id: '0', - cost: 100, - }, - ], - }) - .then(() => { - // dispatch ApiEvent - Done - }) - .catch(() => { - // dispatch ApiEvent - Failed + .update(scenarioId, costSurface) + .then(() => this.events.event(scenarioId, CostSurfaceState.Finished)) + .catch(async () => { + await this.events.event(scenarioId, CostSurfaceState.CostUpdateFailed); }); } } diff --git a/api/src/modules/scenarios/cost-surface/resolve-pu-with-cost.ts b/api/src/modules/scenarios/cost-surface/resolve-pu-with-cost.ts new file mode 100644 index 0000000000..60bff6c422 --- /dev/null +++ b/api/src/modules/scenarios/cost-surface/resolve-pu-with-cost.ts @@ -0,0 +1,7 @@ +import { CostSurfaceInputDto } from '../../analysis/entry-points/adjust-cost-surface-input'; + +export abstract class ResolvePuWithCost { + abstract fromShapefile( + file: Express.Multer.File, + ): Promise; +} diff --git a/api/src/modules/scenarios/scenarios.controller.ts b/api/src/modules/scenarios/scenarios.controller.ts index 83b87b1ba3..9d51d6a63d 100644 --- a/api/src/modules/scenarios/scenarios.controller.ts +++ b/api/src/modules/scenarios/scenarios.controller.ts @@ -137,7 +137,9 @@ export class ScenariosController { * or just ...BaseService */ - this.costSurface.convert(scenarioId, request); + this.costSurface.convert(scenarioId, request).then(() => { + // + }); return; } diff --git a/api/src/modules/scenarios/scenarios.module.ts b/api/src/modules/scenarios/scenarios.module.ts index 365b4d9557..8ce9d669da 100644 --- a/api/src/modules/scenarios/scenarios.module.ts +++ b/api/src/modules/scenarios/scenarios.module.ts @@ -14,6 +14,10 @@ import { ProxyService } from 'modules/proxy/proxy.service'; import { WdpaAreaCalculationService } from './wdpa-area-calculation.service'; import { AnalysisModule } from '../analysis/analysis.module'; import { CostSurfaceFacade } from './cost-surface/cost-surface.facade'; +import { ResolvePuWithCost } from './cost-surface/resolve-pu-with-cost'; +import { CostSurfaceEventsPort } from './cost-surface/cost-surface-events.port'; +import { GeoprocessingCostFromShapefile } from './cost-surface/adapters/geoprocessing-cost-from-shapefile'; +import { CostSurfaceApiEvents } from './cost-surface/adapters/cost-surface-api-events'; @Module({ imports: [ @@ -30,6 +34,15 @@ import { CostSurfaceFacade } from './cost-surface/cost-surface.facade'; ProxyService, WdpaAreaCalculationService, CostSurfaceFacade, + // internals for cost-surface + { + provide: ResolvePuWithCost, + useClass: GeoprocessingCostFromShapefile, + }, + { + provide: CostSurfaceEventsPort, + useClass: CostSurfaceApiEvents, + }, ], controllers: [ScenariosController], exports: [ScenariosService],