From c01b756e4c97952a0da0b6bb7eb230dd30450b8d Mon Sep 17 00:00:00 2001 From: Kamil Gajowy Date: Mon, 17 May 2021 19:53:51 +0200 Subject: [PATCH] feat(api): projects: shapefile for protected areas (#189) --- .../modules/projects/projects.controller.ts | 22 ++++ api/src/modules/projects/projects.module.ts | 2 + .../protected-areas.facade.spec.ts | 94 ++++++++++++++++++ .../protected-areas/protected-areas.facade.ts | 34 +++++++ .../protected-areas/protected-areas.module.ts | 15 +++ .../projects/protected-areas/queue-name.ts | 1 + api/src/utils/__mocks__/fake-logger.ts | 7 ++ ...otected-areas-upload-shapefile.e2e-spec.ts | 38 +++++++ .../steps/stations-shapefile.zip | Bin 0 -> 4250 bytes .../steps/submits-projects-pa-shapefile.ts | 16 +++ .../project-protected-areas/steps/world.ts | 37 +++++++ api/test/steps/given-project.ts | 36 +++++++ 12 files changed, 302 insertions(+) create mode 100644 api/src/modules/projects/protected-areas/protected-areas.facade.spec.ts create mode 100644 api/src/modules/projects/protected-areas/protected-areas.facade.ts create mode 100644 api/src/modules/projects/protected-areas/protected-areas.module.ts create mode 100644 api/src/modules/projects/protected-areas/queue-name.ts create mode 100644 api/src/utils/__mocks__/fake-logger.ts create mode 100644 api/test/project-protected-areas/project-protected-areas-upload-shapefile.e2e-spec.ts create mode 100644 api/test/project-protected-areas/steps/stations-shapefile.zip create mode 100644 api/test/project-protected-areas/steps/submits-projects-pa-shapefile.ts create mode 100644 api/test/project-protected-areas/steps/world.ts create mode 100644 api/test/steps/given-project.ts diff --git a/api/src/modules/projects/projects.controller.ts b/api/src/modules/projects/projects.controller.ts index f20211d807..63b1d02730 100644 --- a/api/src/modules/projects/projects.controller.ts +++ b/api/src/modules/projects/projects.controller.ts @@ -20,6 +20,7 @@ import { ProjectsService } from './projects.service'; import { ApiBearerAuth, ApiForbiddenResponse, + ApiNoContentResponse, ApiOkResponse, ApiOperation, ApiTags, @@ -46,6 +47,9 @@ import { } from 'nestjs-base-service'; import { GeoFeatureResult } from 'modules/geo-features/geo-feature.api.entity'; import { GeoFeaturesService } from 'modules/geo-features/geo-features.service'; +import { ApiConsumesShapefile } from '../../decorators/shapefile.decorator'; +import { Request } from 'express'; +import { ProtectedAreasFacade } from './protected-areas/protected-areas.facade'; @UseGuards(JwtAuthGuard) @ApiBearerAuth() @@ -55,6 +59,7 @@ export class ProjectsController { constructor( public readonly service: ProjectsService, private readonly geoFeaturesService: GeoFeaturesService, + private readonly protectedAreaShapefile: ProtectedAreasFacade, ) {} @ApiOperation({ @@ -162,4 +167,21 @@ export class ProjectsController { async delete(@Param('id') id: string): Promise { return await this.service.remove(id); } + + @ApiConsumesShapefile(false) + @ApiOperation({ + description: 'Upload shapefile for project-specific protected areas', + }) + @UseInterceptors(FileInterceptor('file', uploadOptions)) + @ApiNoContentResponse() + @Post(':id/protected-areas/shapefile') + async shapefileForProtectedArea( + @Param('id') projectId: string, + @Req() request: Request, + ): Promise { + // TODO #1 pre-validate project existence + + this.protectedAreaShapefile.convert(projectId, request.file); + return; + } } diff --git a/api/src/modules/projects/projects.module.ts b/api/src/modules/projects/projects.module.ts index a572b45777..0ed473beeb 100644 --- a/api/src/modules/projects/projects.module.ts +++ b/api/src/modules/projects/projects.module.ts @@ -10,6 +10,7 @@ import { AdminAreasModule } from 'modules/admin-areas/admin-areas.module'; import { CountriesModule } from 'modules/countries/countries.module'; import { PlanningUnitsModule } from 'modules/planning-units/planning-units.module'; import { GeoFeaturesModule } from 'modules/geo-features/geo-features.module'; +import { ProtectedAreasModule } from './protected-areas/protected-areas.module'; @Module({ imports: [ @@ -20,6 +21,7 @@ import { GeoFeaturesModule } from 'modules/geo-features/geo-features.module'; TypeOrmModule.forFeature([Project]), UsersModule, PlanningUnitsModule, + ProtectedAreasModule, ], providers: [ProjectsService], controllers: [ProjectsController], diff --git a/api/src/modules/projects/protected-areas/protected-areas.facade.spec.ts b/api/src/modules/projects/protected-areas/protected-areas.facade.spec.ts new file mode 100644 index 0000000000..a4096e9ef0 --- /dev/null +++ b/api/src/modules/projects/protected-areas/protected-areas.facade.spec.ts @@ -0,0 +1,94 @@ +import { + ProtectedAreasFacade, + ProtectedAreasJobInput, +} from './protected-areas.facade'; +import { Test } from '@nestjs/testing'; +import { Logger } from '@nestjs/common'; +import { Queue } from 'bullmq'; + +import { QueueService } from '../../queue/queue.service'; +import { FakeLogger } from '../../../utils/__mocks__/fake-logger'; + +let sut: ProtectedAreasFacade; +let logger: FakeLogger; +let addJobMock: jest.SpyInstance; + +const projectId = 'project-id'; +const file: Express.Multer.File = { + filename: 'file-name', +} as Express.Multer.File; + +beforeEach(async () => { + addJobMock = jest.fn(); + const sandbox = await Test.createTestingModule({ + providers: [ + ProtectedAreasFacade, + { + provide: QueueService, + useValue: ({ + queue: ({ + add: addJobMock, + } as unknown) as Queue, + } as unknown) as QueueService, + }, + { + provide: Logger, + useClass: FakeLogger, + }, + ], + }).compile(); + + sut = sandbox.get(ProtectedAreasFacade); + logger = sandbox.get(Logger); +}); + +describe(`when job submits successfully`, () => { + let result: unknown; + beforeEach(() => { + // Asset + addJobMock.mockResolvedValue({ job: { id: 1 } }); + // Act + result = sut.convert(projectId, file); + }); + + it(`should return`, () => { + expect(result).toEqual(undefined); + }); + + it(`should put job to queue`, () => { + expect(addJobMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "protected-areas-for-project-id", + Object { + "file": Object { + "filename": "file-name", + }, + "projectId": "project-id", + }, + ] + `); + }); +}); + +describe(`when job submission fails`, () => { + let result: unknown; + beforeEach(() => { + // Asset + addJobMock.mockRejectedValue(new Error('Oups')); + // Act + result = sut.convert(projectId, file); + }); + + it(`should return`, () => { + expect(result).toEqual(undefined); + }); + + it(`should log the error`, () => { + expect(logger.error.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Failed submitting job to queue for project-id", + [Error: Oups], + ] + `); + }); +}); diff --git a/api/src/modules/projects/protected-areas/protected-areas.facade.ts b/api/src/modules/projects/protected-areas/protected-areas.facade.ts new file mode 100644 index 0000000000..705a9512b4 --- /dev/null +++ b/api/src/modules/projects/protected-areas/protected-areas.facade.ts @@ -0,0 +1,34 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Express } from 'express'; +import { QueueService } from '../../queue/queue.service'; + +export interface ProtectedAreasJobInput { + projectId: string; + file: Express.Multer.File; +} + +@Injectable() +export class ProtectedAreasFacade { + constructor( + private readonly queueService: QueueService, + private readonly logger: Logger = new Logger(ProtectedAreasFacade.name), + ) {} + + convert(projectId: string, file: Express.Multer.File): void { + this.queueService.queue + .add(`protected-areas-for-${projectId}`, { + projectId, + file, + }) + .then(() => { + // ok + }) + .catch((error) => { + this.logger.error( + `Failed submitting job to queue for ${projectId}`, + error, + ); + throw error; // failed submission + }); + } +} diff --git a/api/src/modules/projects/protected-areas/protected-areas.module.ts b/api/src/modules/projects/protected-areas/protected-areas.module.ts new file mode 100644 index 0000000000..c659801a83 --- /dev/null +++ b/api/src/modules/projects/protected-areas/protected-areas.module.ts @@ -0,0 +1,15 @@ +import { Logger, Module } from '@nestjs/common'; +import { ProtectedAreasFacade } from './protected-areas.facade'; +import { QueueModule } from '../../queue/queue.module'; +import { queueName } from './queue-name'; + +@Module({ + imports: [ + QueueModule.register({ + name: queueName, + }), + ], + providers: [Logger, ProtectedAreasFacade], + exports: [ProtectedAreasFacade], +}) +export class ProtectedAreasModule {} diff --git a/api/src/modules/projects/protected-areas/queue-name.ts b/api/src/modules/projects/protected-areas/queue-name.ts new file mode 100644 index 0000000000..56eb8e6c52 --- /dev/null +++ b/api/src/modules/projects/protected-areas/queue-name.ts @@ -0,0 +1 @@ +export const queueName = 'project-protected-areas'; diff --git a/api/src/utils/__mocks__/fake-logger.ts b/api/src/utils/__mocks__/fake-logger.ts new file mode 100644 index 0000000000..f9d9cddcaf --- /dev/null +++ b/api/src/utils/__mocks__/fake-logger.ts @@ -0,0 +1,7 @@ +export class FakeLogger { + debug = jest.fn(); + error = jest.fn(); + log = jest.fn(); + verbose = jest.fn(); + warn = jest.fn(); +} diff --git a/api/test/project-protected-areas/project-protected-areas-upload-shapefile.e2e-spec.ts b/api/test/project-protected-areas/project-protected-areas-upload-shapefile.e2e-spec.ts new file mode 100644 index 0000000000..f1d2c56885 --- /dev/null +++ b/api/test/project-protected-areas/project-protected-areas-upload-shapefile.e2e-spec.ts @@ -0,0 +1,38 @@ +import { INestApplication } from '@nestjs/common'; +import { bootstrapApplication } from '../utils/api-application'; +import { createWorld, World } from './steps/world'; + +let app: INestApplication; +let world: World; + +beforeAll(async () => { + app = await bootstrapApplication(); + world = await createWorld(app); +}); + +afterAll(async () => { + await world.cleanup(); + await app.close(); +}); + +describe(`when project is not available`, () => { + it.skip(`should fail`, () => { + // TODO once implemented + }); +}); + +describe(`when project is available`, () => { + it(`submits shapefile to the system`, async () => { + expect( + (await world.WhenSubmittingShapefileFor(world.projectId)).status, + ).toEqual(201); + const job = world.GetSubmittedJobs()[0]; + expect(job).toMatchObject({ + name: `protected-areas-for-${world.projectId}`, + data: { + projectId: world.projectId, + file: expect.anything(), + }, + }); + }); +}); diff --git a/api/test/project-protected-areas/steps/stations-shapefile.zip b/api/test/project-protected-areas/steps/stations-shapefile.zip new file mode 100644 index 0000000000000000000000000000000000000000..84884ee850e5f1c445e7c678c042297de5101c3f GIT binary patch literal 4250 zcmb7H2T)V%whkagK|qOg5(NhV58Zsc5fEp`d$+7u0I1ta7PrZpNCO1?$ZYZEQ(zB2y**%VpCdx6xKMdr zQWupp_Iz62m%hvk+X&?N0Z)-i!S(nSTwdwkmPU!yi3`)LoltzHakR&(X-dYE<-r4UTI)qI{+-L0s6oQ}9FF@m@59p~LJL#sM}zyp|T?J7vCpt>3;! zer*gGZLM(GZoh=hTUQA}r*y+)PFK&Sds1?vv$aiupjjJjZc}zvU`UzQVvTIqZY@ri zv^z4kv;Hh_xKItL^0Hk5iBV%$?|1Ppr4#-JqJc3 z@WR9>xw>{}8?)DDatR@%ozWgADXa(cXrphUGJ3631y)iRHh!3`j4^kbPv6a%sx+%O zC{lljZz%|@&kqw<+ZE1y8uuKf_r$ePInrb{eK&qS+RSq!Xkk{iccgi#NpcOs&^G>#H$-!QlWRqHR^xXO5eYOL zvu%t8n-1M;uVI*sg2FOU^eEgAfjH(Vr$iSg`H9t&C>fRK-$xCBFGfwTyw`v z6!{d8>(kd~BUGnKXO#cS5m#QcXF^-EJ}8)h`x{Ax%GU8w#uEO8-58uy*QZaB(10Y| zsdIYfpmNG`^ss)tKX2y>-8xu{pEaO44?8 z6=O`igp_#M`DYq5ySrK5{^{TLIF*)fK$jd&0z zMfnwYD>BT8Otd>PjFg~0^bsDkGpcv0_e`amGc}4&YL1oEHwQjIvdOqjdN0pCV+&Ho zJ4OyRCbS}WnJtKX{WMF(1Jq32;TMWgoc$JlBmvyCu+E2+SMNAmS=28u zC{`{<$ySfU*9DL9kX!5~TNFM?eAeoKrhEb`RUrRm+_%-ao~aP$77DC&m0Ec^V;mv0 zgM-Tbf#0l;;xo=$X8K+tr*0C{DQC-EN_t!u7pLGjMoE5&km}AdCMmRjM0!~P9`=Z;NLs0F^J3FgmGt_r z>j7N?Lnr?&@;Twrm{SV?5Ls5(;x)miU#6vuI+5aQw%~MV>0X)G-m0v^*1qVzOOT{h zQJlD%f6+16)PRv`;Z@khIR*d#W`CG!UADdnWpOf>^W&gs~R_Qe#;8M)v81$U%G&R-~XozUZVlf5o>CS{V5XtQ+6?+tR^y2^w6@dZbc^)+-i4HoeL`fc~~QTUy&`edc5tcnC3Kll4sK8rcyF}+-CV%9wLkGd@9n4ZDDivz;_U=-0`dwn}OS;yCsE;{MUqJH32zB14C9L;scAyr>d>Vvq}v?sTdWTESHg9+#is)ZRJH8 z=@y&5de`HB&nofVZ|!=O!Jps;0dyV67FwO`>*q=Y3q$1_&$n*~E?7!UDys)vk-k;= z?oEl?SWBCYHrK=us0gc?sRVq(GK&8Ap;Rm@Rmn!IxgM^N?SO-Y_qh!C4G&}$Aja&% z{PerKr;hRi2Q>JYeP;Z-Vjbum7rCHkqug=}AhLW*8mx}b#|~bbMY>^m0_9}TJZpk? zDq3DX6e?eZFNGR~xEr%5U*xv8t_T_Df}0kCvOE$vp6+b{Hn+!yo!fYt6U6-8JE4EpS~@PXdQqhJ7ya&Z&`sh^-H9&qjk%^?TTjUKh1> zow#SW6()l^A2aR=jmD>{ZVn*5yUY0wjoN-6uu2l>I5l9KGKffbaqC?&T)N9%>g?3| zHWoEG<2+=;XEKUNuE^nd47_#jv#Zru zv3u5ei~LN$v>NqJeIc8p!t(JP;iZB)n5Iu2o_KpV=yW+f{EC62Mr zZl+muutM)shx*X6)51fb3GCt>xzXWG$MHrY$Nwh1_i7C{rJ(5X+d_(rLfpA!WRuhq|U5rI=SVLoigWWuNaUwkV#t)$lbna1NNg9UV<}t+eaAl+-yiq==Fc2Cgf{B<=T6{W ze0|lCW(tjsEV42Mp|oFhw?AdFVF%ris5)@ya#PzS2eQ*`W3OYjr860z+rG{I#^Y=F zin#d}CL?O#_X%_tDXPJz%f6B=dZ0sa!7 zF|C*5-ee9Y##pm7mIV%;?R(F5?GN + request(app.getHttpServer()) + .post(`/api/v1/projects/${projectId}/protected-areas/shapefile`) + .set('Authorization', `Bearer ${jwt}`) + .attach(`file`, shapefile); diff --git a/api/test/project-protected-areas/steps/world.ts b/api/test/project-protected-areas/steps/world.ts new file mode 100644 index 0000000000..415abc61cb --- /dev/null +++ b/api/test/project-protected-areas/steps/world.ts @@ -0,0 +1,37 @@ +import { INestApplication } from '@nestjs/common'; +import supertest from 'supertest'; +import { Job } from 'bullmq'; + +import { QueueToken } from '../../../src/modules/queue/queue.tokens'; +import { GivenUserIsLoggedIn } from '../../steps/given-user-is-logged-in'; +import { GivenProjectExists } from '../../steps/given-project'; + +import { SubmitsProjectsPaShapefile } from './submits-projects-pa-shapefile'; + +export interface World { + cleanup: () => Promise; + projectId: string; + organizationId: string; + WhenSubmittingShapefileFor: (projectId: string) => supertest.Test; + GetSubmittedJobs: () => Job[]; +} + +export const createWorld = async (app: INestApplication): Promise => { + const jwtToken = await GivenUserIsLoggedIn(app); + const queue = app.get(QueueToken); + const { + projectId, + cleanup: projectCleanup, + organizationId, + } = await GivenProjectExists(app, jwtToken); + const shapeFilePath = __dirname + '/stations-shapefile.zip'; + + return { + projectId, + organizationId, + WhenSubmittingShapefileFor: (projectId: string) => + SubmitsProjectsPaShapefile(app, jwtToken, projectId, shapeFilePath), + GetSubmittedJobs: () => Object.values(queue.jobs), + cleanup: async () => Promise.all([projectCleanup()]).then(() => undefined), + }; +}; diff --git a/api/test/steps/given-project.ts b/api/test/steps/given-project.ts new file mode 100644 index 0000000000..9801f44a4d --- /dev/null +++ b/api/test/steps/given-project.ts @@ -0,0 +1,36 @@ +import { INestApplication } from '@nestjs/common'; +import { ProjectsTestUtils } from '../utils/projects.test.utils'; +import { OrganizationsTestUtils } from '../utils/organizations.test.utils'; +import { E2E_CONFIG } from '../e2e.config'; + +export const GivenProjectExists = async ( + app: INestApplication, + jwt: string, +): Promise<{ + projectId: string; + organizationId: string; + cleanup: () => Promise; +}> => { + const organizationId = ( + await OrganizationsTestUtils.createOrganization( + app, + jwt, + E2E_CONFIG.organizations.valid.minimal(), + ) + ).data.id; + const projectId = ( + await ProjectsTestUtils.createProject(app, jwt, { + ...E2E_CONFIG.projects.valid.minimal(), + organizationId, + }) + ).data.id; + return { + projectId, + organizationId, + cleanup: async () => { + // TODO DEBT: no cascade remove? + await ProjectsTestUtils.deleteProject(app, jwt, projectId); + await OrganizationsTestUtils.deleteOrganization(app, jwt, organizationId); + }, + }; +};