Skip to content

Commit

Permalink
feat(api): projects: shapefile for protected areas (#189)
Browse files Browse the repository at this point in the history
  • Loading branch information
kgajowy committed May 17, 2021
1 parent f9ef34e commit 6455cea
Show file tree
Hide file tree
Showing 12 changed files with 302 additions and 0 deletions.
22 changes: 22 additions & 0 deletions api/src/modules/projects/projects.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ProjectsService } from './projects.service';
import {
ApiBearerAuth,
ApiForbiddenResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
Expand All @@ -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()
Expand All @@ -55,6 +59,7 @@ export class ProjectsController {
constructor(
public readonly service: ProjectsService,
private readonly geoFeaturesService: GeoFeaturesService,
private readonly protectedAreaShapefile: ProtectedAreasFacade,
) {}

@ApiOperation({
Expand Down Expand Up @@ -162,4 +167,21 @@ export class ProjectsController {
async delete(@Param('id') id: string): Promise<void> {
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<void> {
// TODO #1 pre-validate project existence

this.protectedAreaShapefile.convert(projectId, request.file);
return;
}
}
2 changes: 2 additions & 0 deletions api/src/modules/projects/projects.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -20,6 +21,7 @@ import { GeoFeaturesModule } from 'modules/geo-features/geo-features.module';
TypeOrmModule.forFeature([Project]),
UsersModule,
PlanningUnitsModule,
ProtectedAreasModule,
],
providers: [ProjectsService],
controllers: [ProjectsController],
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ProtectedAreasJobInput>,
},
{
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],
]
`);
});
});
34 changes: 34 additions & 0 deletions api/src/modules/projects/protected-areas/protected-areas.facade.ts
Original file line number Diff line number Diff line change
@@ -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<ProtectedAreasJobInput>,
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
});
}
}
15 changes: 15 additions & 0 deletions api/src/modules/projects/protected-areas/protected-areas.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
1 change: 1 addition & 0 deletions api/src/modules/projects/protected-areas/queue-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const queueName = 'project-protected-areas';
7 changes: 7 additions & 0 deletions api/src/utils/__mocks__/fake-logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class FakeLogger {
debug = jest.fn();
error = jest.fn();
log = jest.fn();
verbose = jest.fn();
warn = jest.fn();
}
Original file line number Diff line number Diff line change
@@ -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(),
},
});
});
});
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';

export const SubmitsProjectsPaShapefile = (
app: INestApplication,
jwt: string,
projectId: string,
/**
* path to file
*/
shapefile: string,
) =>
request(app.getHttpServer())
.post(`/api/v1/projects/${projectId}/protected-areas/shapefile`)
.set('Authorization', `Bearer ${jwt}`)
.attach(`file`, shapefile);
37 changes: 37 additions & 0 deletions api/test/project-protected-areas/steps/world.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
projectId: string;
organizationId: string;
WhenSubmittingShapefileFor: (projectId: string) => supertest.Test;
GetSubmittedJobs: () => Job[];
}

export const createWorld = async (app: INestApplication): Promise<World> => {
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),
};
};
36 changes: 36 additions & 0 deletions api/test/steps/given-project.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}> => {
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);
},
};
};

0 comments on commit 6455cea

Please sign in to comment.